@skillcdn/console 0.1.13 → 0.1.15

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 (170) hide show
  1. package/README.md +5 -4
  2. package/SKILLCDN.md +2 -0
  3. package/dist/agent-name.d.ts +3 -0
  4. package/dist/agent-name.d.ts.map +1 -0
  5. package/dist/agent-name.js +9 -0
  6. package/dist/agent-name.js.map +1 -0
  7. package/dist/api.d.ts +6 -4
  8. package/dist/api.d.ts.map +1 -1
  9. package/dist/api.js +6 -4
  10. package/dist/api.js.map +1 -1
  11. package/dist/cli/cli.d.ts +2 -0
  12. package/dist/cli/cli.d.ts.map +1 -1
  13. package/dist/cli/cli.js +215 -22
  14. package/dist/cli/cli.js.map +1 -1
  15. package/dist/cli/format.d.ts +14 -4
  16. package/dist/cli/format.d.ts.map +1 -1
  17. package/dist/cli/format.js +59 -10
  18. package/dist/cli/format.js.map +1 -1
  19. package/dist/cli/help.d.ts.map +1 -1
  20. package/dist/cli/help.js +27 -11
  21. package/dist/cli/help.js.map +1 -1
  22. package/dist/cli/main.js +5 -0
  23. package/dist/cli/main.js.map +1 -1
  24. package/dist/client.d.ts +20 -2
  25. package/dist/client.d.ts.map +1 -1
  26. package/dist/client.js +11 -2
  27. package/dist/client.js.map +1 -1
  28. package/dist/components/calendar.d.ts +15 -0
  29. package/dist/components/calendar.d.ts.map +1 -1
  30. package/dist/components/calendar.js +78 -41
  31. package/dist/components/calendar.js.map +1 -1
  32. package/dist/components/connect.d.ts +4 -1
  33. package/dist/components/connect.d.ts.map +1 -1
  34. package/dist/components/connect.js +7 -3
  35. package/dist/components/connect.js.map +1 -1
  36. package/dist/components/decision-list.d.ts.map +1 -1
  37. package/dist/components/decision-list.js +3 -3
  38. package/dist/components/decision-list.js.map +1 -1
  39. package/dist/components/documents.d.ts +5 -0
  40. package/dist/components/documents.d.ts.map +1 -1
  41. package/dist/components/documents.js +3 -2
  42. package/dist/components/documents.js.map +1 -1
  43. package/dist/components/event-feed.d.ts +1 -1
  44. package/dist/components/event-feed.d.ts.map +1 -1
  45. package/dist/components/event-feed.js +10 -1
  46. package/dist/components/event-feed.js.map +1 -1
  47. package/dist/components/people.d.ts +24 -1
  48. package/dist/components/people.d.ts.map +1 -1
  49. package/dist/components/people.js +35 -3
  50. package/dist/components/people.js.map +1 -1
  51. package/dist/components/projects.d.ts +6 -0
  52. package/dist/components/projects.d.ts.map +1 -1
  53. package/dist/components/projects.js +3 -3
  54. package/dist/components/projects.js.map +1 -1
  55. package/dist/components/rules.d.ts +40 -0
  56. package/dist/components/rules.d.ts.map +1 -0
  57. package/dist/components/rules.js +46 -0
  58. package/dist/components/rules.js.map +1 -0
  59. package/dist/components/runs.d.ts +1 -1
  60. package/dist/components/runs.d.ts.map +1 -1
  61. package/dist/components/runs.js +2 -1
  62. package/dist/components/runs.js.map +1 -1
  63. package/dist/components/skills.d.ts.map +1 -1
  64. package/dist/components/skills.js +5 -1
  65. package/dist/components/skills.js.map +1 -1
  66. package/dist/components/tokens.d.ts.map +1 -1
  67. package/dist/components/tokens.js +2 -9
  68. package/dist/components/tokens.js.map +1 -1
  69. package/dist/components/ui.d.ts +7 -2
  70. package/dist/components/ui.d.ts.map +1 -1
  71. package/dist/components/ui.js +13 -0
  72. package/dist/components/ui.js.map +1 -1
  73. package/dist/components/work.d.ts +9 -1
  74. package/dist/components/work.d.ts.map +1 -1
  75. package/dist/components/work.js +8 -3
  76. package/dist/components/work.js.map +1 -1
  77. package/dist/connect-page.d.ts.map +1 -1
  78. package/dist/connect-page.js +1 -1
  79. package/dist/connect-page.js.map +1 -1
  80. package/dist/console.css +253 -13
  81. package/dist/console.d.ts.map +1 -1
  82. package/dist/console.js +203 -49
  83. package/dist/console.js.map +1 -1
  84. package/dist/data.d.ts +19 -2
  85. package/dist/data.d.ts.map +1 -1
  86. package/dist/data.js +27 -1
  87. package/dist/data.js.map +1 -1
  88. package/dist/dates.d.ts +4 -0
  89. package/dist/dates.d.ts.map +1 -1
  90. package/dist/dates.js +26 -0
  91. package/dist/dates.js.map +1 -1
  92. package/dist/i18n/en.d.ts +90 -28
  93. package/dist/i18n/en.d.ts.map +1 -1
  94. package/dist/i18n/en.js +89 -31
  95. package/dist/i18n/en.js.map +1 -1
  96. package/dist/i18n/ko.d.ts.map +1 -1
  97. package/dist/i18n/ko.js +89 -31
  98. package/dist/i18n/ko.js.map +1 -1
  99. package/dist/index.d.ts +6 -5
  100. package/dist/index.d.ts.map +1 -1
  101. package/dist/index.js +6 -5
  102. package/dist/index.js.map +1 -1
  103. package/dist/limits.d.ts +4 -0
  104. package/dist/limits.d.ts.map +1 -1
  105. package/dist/limits.js +4 -0
  106. package/dist/limits.js.map +1 -1
  107. package/dist/router.d.ts +34 -2
  108. package/dist/router.d.ts.map +1 -1
  109. package/dist/router.js +55 -7
  110. package/dist/router.js.map +1 -1
  111. package/dist/routes.d.ts +14 -4
  112. package/dist/routes.d.ts.map +1 -1
  113. package/dist/routes.js +17 -2
  114. package/dist/routes.js.map +1 -1
  115. package/dist/rules.d.ts +28 -0
  116. package/dist/rules.d.ts.map +1 -0
  117. package/dist/rules.js +102 -0
  118. package/dist/rules.js.map +1 -0
  119. package/dist/schemas.d.ts +652 -4
  120. package/dist/schemas.d.ts.map +1 -1
  121. package/dist/schemas.js +96 -6
  122. package/dist/schemas.js.map +1 -1
  123. package/dist/vocabulary.d.ts +21 -1
  124. package/dist/vocabulary.d.ts.map +1 -1
  125. package/dist/vocabulary.js +21 -0
  126. package/dist/vocabulary.js.map +1 -1
  127. package/docs/specs/cli.md +13 -9
  128. package/docs/specs/rest.md +32 -8
  129. package/package.json +1 -1
  130. package/skills/building-a-console/SKILL.md +1 -1
  131. package/skills/deploying-a-console/SKILL.md +5 -5
  132. package/skills/deploying-a-console/assets/secrets.example.json +2 -1
  133. package/skills/deploying-a-console/references/access.md +10 -6
  134. package/skills/deploying-a-console/references/resources.md +10 -7
  135. package/skills/working-the-board/SKILL.md +10 -9
  136. package/skills/working-the-board/references/project-rules.md +36 -0
  137. package/src/agent-name.ts +9 -0
  138. package/src/api.ts +37 -0
  139. package/src/cli/cli.ts +253 -24
  140. package/src/cli/format.ts +74 -10
  141. package/src/cli/help.ts +30 -10
  142. package/src/cli/main.ts +5 -0
  143. package/src/client.ts +47 -1
  144. package/src/components/calendar.tsx +242 -101
  145. package/src/components/connect.tsx +12 -6
  146. package/src/components/decision-list.tsx +2 -2
  147. package/src/components/documents.tsx +7 -3
  148. package/src/components/event-feed.tsx +16 -3
  149. package/src/components/people.tsx +244 -35
  150. package/src/components/projects.tsx +31 -8
  151. package/src/components/rules.tsx +167 -0
  152. package/src/components/runs.tsx +4 -2
  153. package/src/components/skills.tsx +22 -11
  154. package/src/components/tokens.tsx +2 -14
  155. package/src/components/ui.tsx +25 -2
  156. package/src/components/work.tsx +69 -43
  157. package/src/connect-page.tsx +0 -1
  158. package/src/console.tsx +531 -67
  159. package/src/data.ts +52 -2
  160. package/src/dates.ts +28 -0
  161. package/src/i18n/en.ts +99 -38
  162. package/src/i18n/ko.ts +95 -38
  163. package/src/index.ts +21 -1
  164. package/src/limits.ts +4 -0
  165. package/src/router.ts +77 -12
  166. package/src/routes.ts +23 -3
  167. package/src/rules.ts +109 -0
  168. package/src/schemas.ts +121 -4
  169. package/src/styles/console.css +253 -13
  170. package/src/vocabulary.ts +30 -0
package/src/client.ts CHANGED
@@ -4,9 +4,11 @@ import {
4
4
  categoryPath,
5
5
  connectPath,
6
6
  personTokensPath,
7
+ personWorkPath,
7
8
  projectPath,
8
9
  REST_ROUTES,
9
10
  restPath,
11
+ rulesPath,
10
12
  } from "./routes.js";
11
13
  import {
12
14
  type RestAnswerInput,
@@ -31,6 +33,8 @@ import {
31
33
  type RestGoalInput,
32
34
  type RestGoalPatch,
33
35
  type RestGoals,
36
+ type RestListing,
37
+ type RestListingInput,
34
38
  type RestMe,
35
39
  type RestMember,
36
40
  type RestMemberInput,
@@ -40,11 +44,15 @@ import {
40
44
  type RestPeople,
41
45
  type RestPerson,
42
46
  type RestPersonPatch,
47
+ type RestPersonWork,
43
48
  type RestProject,
44
49
  type RestProjectInput,
45
50
  type RestProjectPatch,
51
+ type RestProjectRules,
46
52
  type RestProjects,
47
53
  type RestReportInput,
54
+ type RestRules,
55
+ type RestRulesInput,
48
56
  type RestRun,
49
57
  type RestRunEndInput,
50
58
  type RestRunInput,
@@ -73,13 +81,17 @@ import {
73
81
  restEventsSchema,
74
82
  restGoalSchema,
75
83
  restGoalsSchema,
84
+ restListingSchema,
76
85
  restMemberSchema,
77
86
  restMembersSchema,
78
87
  restMeSchema,
79
88
  restPeopleSchema,
80
89
  restPersonSchema,
90
+ restPersonWorkSchema,
91
+ restProjectRulesSchema,
81
92
  restProjectSchema,
82
93
  restProjectsSchema,
94
+ restRulesSchema,
83
95
  restRunSchema,
84
96
  restRunsSchema,
85
97
  restSkillsSchema,
@@ -243,6 +255,8 @@ export interface ProjectClient {
243
255
  eventStreamUrl(after: number): string;
244
256
  /** The project's skills, as SkillCDN serves them at its address, or the organization's. */
245
257
  skills(signal?: AbortSignal): Promise<RestSkills>;
258
+ /** The rules that hold in the project (ADR-0023): the workspace's, and what the project adds. */
259
+ rules(signal?: AbortSignal): Promise<RestProjectRules>;
246
260
  /** A folder's pages and folders, or the pages a search finds. */
247
261
  documents(filter?: DocumentFilter, signal?: AbortSignal): Promise<RestDocuments>;
248
262
  /** One document, by its path: its latest version, its links both ways, its files. */
@@ -271,11 +285,17 @@ export interface ProjectClient {
271
285
 
272
286
  export interface ConsoleClient {
273
287
  me(signal?: AbortSignal): Promise<RestMe>;
288
+ /** Gives up the token this client acts with, so that it is good for nothing from now on: what `console logout` does. For a token; a session has none. */
289
+ giveUpToken(): Promise<void>;
274
290
  /** Keeps the person's choice of language, or none; for a person signed in. */
275
291
  updateMe(patch: RestMePatch): Promise<RestMe>;
276
292
  people(signal?: AbortSignal): Promise<RestPeople>;
277
- /** Changes what a person is; for an administrator. */
293
+ /** Changes what a person is, or whether they are in; for an administrator. */
278
294
  updatePerson(id: string, patch: RestPersonPatch): Promise<RestPerson>;
295
+ /** Lets a login in (ADR-0022): whoever signs in with that account becomes a person; for an administrator. */
296
+ createListing(input: RestListingInput): Promise<RestListing>;
297
+ /** Takes a listing away before anyone signed in with it; for an administrator. */
298
+ removeListing(id: string): Promise<void>;
279
299
  /** The projects the asker may see, with what they are in each. */
280
300
  projects(signal?: AbortSignal): Promise<RestProjects>;
281
301
  /** Makes a project, whose owner the asker becomes. */
@@ -286,6 +306,14 @@ export interface ConsoleClient {
286
306
  events(after: number, signal?: AbortSignal): Promise<RestEvents>;
287
307
  /** The categories tasks are sorted into (ADR-0019), by name. */
288
308
  categories(signal?: AbortSignal): Promise<RestCategories>;
309
+ /** The workspace's rules (ADR-0023): the console's own until an administrator writes the organization's. */
310
+ rules(signal?: AbortSignal): Promise<RestRules>;
311
+ /** Writes the organization's rules; for an administrator. The console's own, sent as they are, write nothing. */
312
+ writeRules(input: RestRulesInput): Promise<RestRules>;
313
+ /** Puts the organization's rules away, so that the console's own hold again; for an administrator. */
314
+ archiveRules(): Promise<RestRules>;
315
+ /** The versions of the organization's rules, newest first; none while the console's own hold. */
316
+ rulesVersions(signal?: AbortSignal): Promise<RestVersions>;
289
317
  /** Makes a category; for an administrator signed in. */
290
318
  createCategory(input: RestCategoryInput): Promise<RestCategory>;
291
319
  updateCategory(key: string, patch: RestCategoryPatch): Promise<RestCategory>;
@@ -293,6 +321,8 @@ export interface ConsoleClient {
293
321
  removeCategory(key: string): Promise<void>;
294
322
  /** What is on the asker's plate across the projects they may see: their open tasks, and the decisions waiting. */
295
323
  work(signal?: AbortSignal): Promise<RestWork>;
324
+ /** Another person's plate, within what the asker may see: for an administrator, or an owner of a project they are in. */
325
+ personWork(personId: string, signal?: AbortSignal): Promise<RestPersonWork>;
296
326
  /** The tokens of whoever asks. */
297
327
  tokens(signal?: AbortSignal): Promise<RestTokens>;
298
328
  /** The tokens of another person: for an administrator. */
@@ -520,6 +550,8 @@ export function createClient(options: ClientOptions = {}): ConsoleClient {
520
550
  eventStreamUrl: (after) => withQuery(`${at("events")}/stream`, { after: String(after) }),
521
551
  skills: (signal) =>
522
552
  request("GET", `${base}${at("skills")}`, undefined, restSkillsSchema, signal),
553
+ rules: (signal) =>
554
+ request("GET", `${base}${at("rules")}`, undefined, restProjectRulesSchema, signal),
523
555
  documents: (filter = {}, signal) =>
524
556
  request(
525
557
  "GET",
@@ -572,10 +604,15 @@ export function createClient(options: ClientOptions = {}): ConsoleClient {
572
604
 
573
605
  return {
574
606
  me: (signal) => request("GET", `${base}${REST_ROUTES.me}`, undefined, restMeSchema, signal),
607
+ giveUpToken: () => request("DELETE", `${base}${REST_ROUTES.meToken}`, undefined, undefined),
575
608
  people: (signal) =>
576
609
  request("GET", `${base}${REST_ROUTES.people}`, undefined, restPeopleSchema, signal),
577
610
  updatePerson: (id, patch) =>
578
611
  request("PATCH", `${base}${restPath("people", id)}`, patch, restPersonSchema),
612
+ createListing: (input) =>
613
+ request("POST", `${base}${REST_ROUTES.listings}`, input, restListingSchema),
614
+ removeListing: (id) =>
615
+ request("DELETE", `${base}${restPath("listings", id)}`, undefined, undefined),
579
616
  projects: (signal) =>
580
617
  request("GET", `${base}${REST_ROUTES.projects}`, undefined, restProjectsSchema, signal),
581
618
  createProject: (input) =>
@@ -591,6 +628,13 @@ export function createClient(options: ClientOptions = {}): ConsoleClient {
591
628
  ),
592
629
  categories: (signal) =>
593
630
  request("GET", `${base}${REST_ROUTES.categories}`, undefined, restCategoriesSchema, signal),
631
+ rules: (signal) =>
632
+ request("GET", `${base}${REST_ROUTES.rules}`, undefined, restRulesSchema, signal),
633
+ writeRules: (input) => request("PUT", `${base}${REST_ROUTES.rules}`, input, restRulesSchema),
634
+ archiveRules: () =>
635
+ request("POST", `${base}${rulesPath("archive")}`, undefined, restRulesSchema),
636
+ rulesVersions: (signal) =>
637
+ request("GET", `${base}${rulesPath("versions")}`, undefined, restVersionsSchema, signal),
594
638
  createCategory: (input) =>
595
639
  request("POST", `${base}${REST_ROUTES.categories}`, input, restCategorySchema),
596
640
  updateCategory: (key, patch) =>
@@ -598,6 +642,8 @@ export function createClient(options: ClientOptions = {}): ConsoleClient {
598
642
  removeCategory: (key) => request("DELETE", `${base}${categoryPath(key)}`, undefined, undefined),
599
643
  work: (signal) =>
600
644
  request("GET", `${base}${REST_ROUTES.work}`, undefined, restWorkSchema, signal),
645
+ personWork: (personId, signal) =>
646
+ request("GET", `${base}${personWorkPath(personId)}`, undefined, restPersonWorkSchema, signal),
601
647
  tokens: (signal) =>
602
648
  request("GET", `${base}${REST_ROUTES.tokens}`, undefined, restTokensSchema, signal),
603
649
  createToken: (input) =>
@@ -1,8 +1,11 @@
1
+ import type { ReactNode, SyntheticEvent } from "react";
1
2
  import { OPEN_TASK_STATES, type RestCategory, type RestPerson, type RestTask } from "../api.js";
2
3
  import {
4
+ addDays,
3
5
  addMonths,
4
6
  daysBetween,
5
7
  firstDayOfWeek,
8
+ formatDays,
6
9
  formatMonth,
7
10
  formatWeekday,
8
11
  type GridDay,
@@ -10,15 +13,18 @@ import {
10
13
  monthOf,
11
14
  today as todayOf,
12
15
  weekdays,
16
+ weekOf,
13
17
  } from "../dates.js";
14
18
  import { useLanguage, useWords } from "../i18n/index.js";
15
- import { cx, PersonChip, personName } from "./ui.js";
19
+ import { Avatar, cx, PersonChip, personName } from "./ui.js";
16
20
 
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).
21
+ // The calendar of a project (ADR-0019): the month as a grid, or one week on its own. A task
22
+ // planned from a start to a due day lies across its days as a bar, in a lane of its own within
23
+ // each week so that the bars of one week never cross; a task with one day alone, a due day or
24
+ // a start, sits on that day as a dot. On a project's calendar the person who has a task stands
25
+ // before its title, since the days hold several people's work. Who has what says whether the
26
+ // work is on its days. Takes its data as props and nothing from the network; the month or the
27
+ // week shown is the address's (ADR-0010).
22
28
 
23
29
  const isOpen = (task: RestTask): boolean => OPEN_TASK_STATES.includes(task.state);
24
30
 
@@ -99,40 +105,165 @@ function push(map: Map<string, RestTask[]>, day: string, task: RestTask): void {
99
105
  }
100
106
  }
101
107
 
102
- export function Calendar(props: CalendarProps) {
103
- const words = useWords().calendar;
104
- const { tag } = useLanguage();
105
- const now = props.today ?? todayOf();
106
- const firstDay = firstDayOfWeek(tag);
107
- const weeks = monthGrid(props.month, firstDay);
108
- const due = new Map<string, RestTask[]>();
109
- const starting = new Map<string, RestTask[]>();
110
- for (const task of props.tasks) {
108
+ /** The tasks that sit on one day each, by day: due on it, or beginning on it with no due day yet. */
109
+ function onDays(tasks: readonly RestTask[]): ReadonlyMap<string, readonly RestTask[]> {
110
+ const on = new Map<string, RestTask[]>();
111
+ for (const task of tasks) {
111
112
  if (spans(task)) {
112
113
  continue;
113
114
  }
114
- if (task.dueOn !== null) {
115
- push(due, task.dueOn, task);
116
- } else if (task.startsOn !== null) {
117
- push(starting, task.startsOn, task);
115
+ const day = task.dueOn ?? task.startsOn;
116
+ if (day !== null) {
117
+ push(on, day, task);
118
118
  }
119
119
  }
120
120
  // Within a day, what is still open comes first; the rest reads as what was.
121
- for (const list of due.values()) {
121
+ for (const list of on.values()) {
122
122
  list.sort((a, b) => Number(isOpen(b)) - Number(isOpen(a)) || a.number - b.number);
123
123
  }
124
+ return on;
125
+ }
126
+
127
+ /** How a task reads: struck through once it is over, and marked once its due day is past. */
128
+ const tone = (task: RestTask, now: string): string =>
129
+ cx(
130
+ !isOpen(task) && "sc-calendar-over",
131
+ isOpen(task) && task.dueOn !== null && task.dueOn < now && "sc-calendar-late",
132
+ );
133
+
134
+ interface WeekRowProps {
135
+ readonly week: readonly GridDay[];
136
+ readonly segments: readonly WeekSegment[];
137
+ /** The tasks on one day each, by day. */
138
+ readonly on: ReadonlyMap<string, readonly RestTask[]>;
139
+ readonly now: string;
140
+ readonly taskHref: (task: RestTask) => string;
141
+ /** What stands before a task's title where the tasks are of several projects: the project's mark. */
142
+ readonly markOf?: ((task: RestTask) => ReactNode) | undefined;
143
+ /** Whether the person who has a task stands before its title, where the days hold several people's work. */
144
+ readonly people?: boolean | undefined;
145
+ }
146
+
147
+ /**
148
+ * Marks an item whose title its line cut short, or which shows none (a bar on its later days),
149
+ * so that the tip it carries in `data-tip` is shown at once while the pointer is on it. Measured
150
+ * as the pointer arrives, since only the browser knows where the line ended.
151
+ */
152
+ function reveal(event: SyntheticEvent<HTMLElement>): void {
153
+ const item = event.currentTarget;
154
+ const title = item.querySelector(".sc-calendar-task-title");
155
+ item.toggleAttribute("data-clipped", title === null || title.scrollWidth > title.clientWidth);
156
+ }
157
+
158
+ /** One week as a row of cells: the bars across the days in their lanes, then the tasks on each day. */
159
+ function WeekRow(props: WeekRowProps) {
160
+ const { week, segments, now } = props;
161
+ const tip = (task: RestTask): string =>
162
+ props.people === true && task.assignee !== null
163
+ ? `#${task.number} ${task.title} \u00B7 ${personName(task.assignee)}`
164
+ : `#${task.number} ${task.title}`;
165
+ const who = (task: RestTask): ReactNode =>
166
+ props.people === true && task.assignee !== null ? (
167
+ <Avatar person={task.assignee} size="xs" />
168
+ ) : null;
169
+ const lanes = segments.reduce((most, segment) => Math.max(most, segment.lane + 1), 0);
170
+ return (
171
+ <tr>
172
+ {week.map((cell, column) => (
173
+ <td
174
+ key={cell.day}
175
+ className={cx(
176
+ "sc-calendar-cell",
177
+ !cell.inMonth && "sc-calendar-cell-outside",
178
+ cell.day === now && "sc-calendar-cell-today",
179
+ )}
180
+ >
181
+ <span className="sc-calendar-day">{Number(cell.day.slice(8))}</span>
182
+ {Array.from({ length: lanes }, (_, lane) => {
183
+ const segment = segments.find(
184
+ (candidate) =>
185
+ candidate.lane === lane && candidate.from <= column && column <= candidate.to,
186
+ );
187
+ if (segment === undefined) {
188
+ // The lane is someone else's on this day: the bars below keep their height.
189
+ // biome-ignore lint/suspicious/noArrayIndexKey: an empty lane is nothing but its position, and the lanes of a week never reorder
190
+ return <span key={lane} className="sc-calendar-lane" aria-hidden="true" />;
191
+ }
192
+ const { task } = segment;
193
+ return (
194
+ <a
195
+ key={task.id}
196
+ className={cx(
197
+ "sc-calendar-bar",
198
+ `sc-category-${task.category?.color ?? "gray"}`,
199
+ segment.from === column && segment.begins && "sc-calendar-bar-begins",
200
+ segment.to === column && segment.ends && "sc-calendar-bar-ends",
201
+ tone(task, now),
202
+ "sc-tip",
203
+ )}
204
+ href={props.taskHref(task)}
205
+ data-tip={tip(task)}
206
+ onMouseEnter={reveal}
207
+ onFocus={reveal}
208
+ >
209
+ {segment.from === column && (
210
+ <>
211
+ {props.markOf?.(task)}
212
+ {who(task)}
213
+ <span className="sc-calendar-task-title">{task.title}</span>
214
+ </>
215
+ )}
216
+ </a>
217
+ );
218
+ })}
219
+ {(props.on.get(cell.day) ?? []).map((task) => {
220
+ const mark = props.markOf?.(task);
221
+ return (
222
+ <a
223
+ key={task.id}
224
+ className={cx("sc-calendar-task", tone(task, now), "sc-tip")}
225
+ href={props.taskHref(task)}
226
+ data-tip={tip(task)}
227
+ onMouseEnter={reveal}
228
+ onFocus={reveal}
229
+ >
230
+ {mark === undefined || mark === null ? (
231
+ <span
232
+ className={cx(
233
+ "sc-calendar-dot",
234
+ `sc-category-${task.category?.color ?? "gray"}`,
235
+ )}
236
+ />
237
+ ) : (
238
+ mark
239
+ )}
240
+ {who(task)}
241
+ <span className="sc-calendar-task-title">{task.title}</span>
242
+ </a>
243
+ );
244
+ })}
245
+ </td>
246
+ ))}
247
+ </tr>
248
+ );
249
+ }
250
+
251
+ export function Calendar(props: CalendarProps) {
252
+ const words = useWords().calendar;
253
+ const { tag } = useLanguage();
254
+ const now = props.today ?? todayOf();
255
+ const firstDay = firstDayOfWeek(tag);
256
+ const weeks = monthGrid(props.month, firstDay);
257
+ const on = onDays(props.tasks);
124
258
  const inMonth = (day: string) => monthOf(day) === props.month;
125
259
  const monthWeeks = weeks.map((week) => ({ week, segments: weekSegments(week, props.tasks) }));
126
260
  const nothing =
127
- [...due.keys()].every((day) => !inMonth(day)) &&
128
- [...starting.keys()].every((day) => !inMonth(day)) &&
261
+ [...on.keys()].every((day) => !inMonth(day)) &&
129
262
  monthWeeks.every(({ week, segments }) =>
130
263
  segments.every(
131
264
  (segment) => !week.slice(segment.from, segment.to + 1).some((cell) => cell.inMonth),
132
265
  ),
133
266
  );
134
- const tone = (task: RestTask, day: string) =>
135
- cx(!isOpen(task) && "sc-calendar-over", isOpen(task) && day < now && "sc-calendar-late");
136
267
  return (
137
268
  <div className="sc-calendar">
138
269
  <div className="sc-calendar-head">
@@ -171,83 +302,17 @@ export function Calendar(props: CalendarProps) {
171
302
  </tr>
172
303
  </thead>
173
304
  <tbody>
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
- })}
305
+ {monthWeeks.map(({ week, segments }) => (
306
+ <WeekRow
307
+ key={week[0]?.day}
308
+ week={week}
309
+ segments={segments}
310
+ on={on}
311
+ now={now}
312
+ taskHref={props.taskHref}
313
+ people
314
+ />
315
+ ))}
251
316
  </tbody>
252
317
  </table>
253
318
  {(props.categories ?? []).length > 0 && (
@@ -267,6 +332,82 @@ export function Calendar(props: CalendarProps) {
267
332
  );
268
333
  }
269
334
 
335
+ export interface WeekProps {
336
+ readonly tasks: readonly RestTask[];
337
+ /** A day of the week shown; today's week when left out. */
338
+ readonly day?: string | undefined;
339
+ /** The day to count from; the browser's today when left out. */
340
+ readonly today?: string | undefined;
341
+ readonly taskHref: (task: RestTask) => string;
342
+ /** Where the week of a day is shown. */
343
+ readonly weekHref: (day: string) => string;
344
+ /** What stands before a task's title where the tasks are of several projects: the project's mark. */
345
+ readonly markOf?: ((task: RestTask) => ReactNode) | undefined;
346
+ }
347
+
348
+ /** One week on its own: the days as columns, the tasks on them, and the weeks around a step away. */
349
+ export function Week(props: WeekProps) {
350
+ const words = useWords().calendar.week;
351
+ const { tag } = useLanguage();
352
+ const now = props.today ?? todayOf();
353
+ const firstDay = firstDayOfWeek(tag);
354
+ const days = weekOf(props.day ?? now, firstDay);
355
+ const first = days[0] ?? now;
356
+ const last = days[days.length - 1] ?? now;
357
+ const week: GridDay[] = days.map((day) => ({ day, inMonth: true }));
358
+ const on = onDays(props.tasks);
359
+ const segments = weekSegments(week, props.tasks);
360
+ const nothing = segments.length === 0 && days.every((day) => !on.has(day));
361
+ return (
362
+ <div className="sc-calendar sc-week">
363
+ <div className="sc-calendar-head">
364
+ <h2 className="sc-calendar-month">{formatDays(first, last, tag)}</h2>
365
+ <nav className="sc-calendar-nav" aria-label={words.title}>
366
+ <a
367
+ className="sc-button sc-button-ghost sc-button-sm"
368
+ href={props.weekHref(addDays(first, -7))}
369
+ aria-label={words.previous}
370
+ >
371
+ {"<"}
372
+ </a>
373
+ <a className="sc-button sc-button-ghost sc-button-sm" href={props.weekHref(now)}>
374
+ {words.thisWeek}
375
+ </a>
376
+ <a
377
+ className="sc-button sc-button-ghost sc-button-sm"
378
+ href={props.weekHref(addDays(first, 7))}
379
+ aria-label={words.next}
380
+ >
381
+ {">"}
382
+ </a>
383
+ </nav>
384
+ </div>
385
+ <table className="sc-calendar-grid" aria-label={words.title}>
386
+ <thead>
387
+ <tr>
388
+ {weekdays(firstDay).map((weekday) => (
389
+ <th key={weekday} scope="col">
390
+ {formatWeekday(weekday, tag)}
391
+ </th>
392
+ ))}
393
+ </tr>
394
+ </thead>
395
+ <tbody>
396
+ <WeekRow
397
+ week={week}
398
+ segments={segments}
399
+ on={on}
400
+ now={now}
401
+ taskHref={props.taskHref}
402
+ markOf={props.markOf}
403
+ />
404
+ </tbody>
405
+ </table>
406
+ {nothing && <p className="sc-muted">{words.nothing}</p>}
407
+ </div>
408
+ );
409
+ }
410
+
270
411
  /** One person's share of the open work, as the table of who has what says it. */
271
412
  export interface WorkloadRow {
272
413
  /** The person, or nobody for the tasks assigned to no one. */
@@ -2,7 +2,7 @@ import { useState } from "react";
2
2
  import { normalizeConnectCode, type RestConnectRequest, type RestTokenInput } from "../api.js";
3
3
  import { useWords } from "../i18n/index.js";
4
4
  import { TokenForm } from "./tokens.js";
5
- import { Button, Callout, Time } from "./ui.js";
5
+ import { Button, Callout, CopyButton, Time } from "./ui.js";
6
6
 
7
7
  // Connecting an agent (ADR-0011): where a person types the code their agent showed, what asks
8
8
  // to connect under a code with the way to approve it, and the words that tell a person what to
@@ -106,16 +106,22 @@ export interface ConnectWordsProps {
106
106
  readonly origin: string;
107
107
  }
108
108
 
109
- /** What a person says to their agent to connect it, in words for someone who is not a developer. */
109
+ /**
110
+ * What a person says to their agent to connect it: the words to paste into its chat, as they
111
+ * are, with the way to copy them; and the commands for a person who runs them by hand.
112
+ */
110
113
  export function ConnectWords(props: ConnectWordsProps) {
111
114
  const words = useWords().connect.words;
115
+ const quote = words.quote(props.origin);
112
116
  return (
113
117
  <section className="sc-panel sc-connect" aria-label={words.title}>
114
118
  <h2 className="sc-section-title">{words.title}</h2>
115
- <p>
116
- {words.tellYourAgent} <q>{words.quote(props.origin)}</q> {words.doesTheRest}
117
- </p>
118
- <p>{words.thenApprove}</p>
119
+ <p>{words.tellYourAgent}</p>
120
+ <div className="sc-copyable">
121
+ <pre className="sc-code">{quote}</pre>
122
+ <CopyButton text={quote} size="sm" />
123
+ </div>
124
+ <p>{words.doesTheRest}</p>
119
125
  <p>{words.byHand}</p>
120
126
  <pre className="sc-code">
121
127
  npm install -g @skillcdn/console{"\n"}console login --url {props.origin}
@@ -1,5 +1,6 @@
1
1
  import { useState } from "react";
2
2
  import {
3
+ agentLabel,
3
4
  MAX_BODY_LENGTH,
4
5
  MAX_NOTE_LENGTH,
5
6
  type RestAnswerInput,
@@ -128,7 +129,7 @@ export function DecisionCard(props: DecisionCardProps) {
128
129
  <PersonChip person={decision.raisedBy} />{" "}
129
130
  {decision.run === null
130
131
  ? words.decisions.asked
131
- : words.decisions.askedThrough(decision.run.agent)}{" "}
132
+ : words.decisions.askedThrough(agentLabel(decision.run.agent))}{" "}
132
133
  <Time iso={decision.createdAt} />
133
134
  {task !== undefined && (
134
135
  <>
@@ -326,7 +327,6 @@ export function DecisionList(props: DecisionListProps) {
326
327
  <h2 className="sc-section-title">
327
328
  {words.decisions.record} <span className="sc-column-count">{answered.length}</span>
328
329
  </h2>
329
- <p className="sc-lead">{words.decisions.recordLead}</p>
330
330
  <DecisionLog
331
331
  decisions={answered}
332
332
  tasks={props.tasks}
@@ -1,4 +1,5 @@
1
1
  import { type FormEvent, type ReactNode, useState } from "react";
2
+ import { agentLabel } from "../agent-name.js";
2
3
  import {
3
4
  foldersAbove,
4
5
  isDocumentPath,
@@ -59,7 +60,7 @@ export function DocumentCrumbs(props: {
59
60
  }
60
61
 
61
62
  /** Who wrote a version, and through which agent, as a chip and a word. */
62
- function Writer(props: {
63
+ export function Writer(props: {
63
64
  readonly by: RestDocumentSummary["updatedBy"];
64
65
  readonly agent: string | null;
65
66
  }) {
@@ -68,8 +69,11 @@ function Writer(props: {
68
69
  <>
69
70
  <PersonChip person={props.by} />
70
71
  {props.agent !== null && (
71
- <span className="sc-feed-agent" title={words.docs.asAgentTitle}>
72
- {words.feed.as(props.agent)}
72
+ <span
73
+ className="sc-feed-agent"
74
+ title={agentLabel(props.agent) === props.agent ? words.docs.asAgentTitle : props.agent}
75
+ >
76
+ {words.feed.as(agentLabel(props.agent))}
73
77
  </span>
74
78
  )}
75
79
  </>