@skillcdn/console 0.1.17 → 0.1.18

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 (100) hide show
  1. package/README.md +3 -3
  2. package/dist/api.d.ts +2 -1
  3. package/dist/api.d.ts.map +1 -1
  4. package/dist/api.js +1 -0
  5. package/dist/api.js.map +1 -1
  6. package/dist/cli/cli.d.ts +10 -0
  7. package/dist/cli/cli.d.ts.map +1 -1
  8. package/dist/cli/cli.js +145 -14
  9. package/dist/cli/cli.js.map +1 -1
  10. package/dist/cli/format.d.ts +1 -1
  11. package/dist/cli/format.d.ts.map +1 -1
  12. package/dist/cli/format.js +8 -3
  13. package/dist/cli/format.js.map +1 -1
  14. package/dist/cli/help.d.ts.map +1 -1
  15. package/dist/cli/help.js +7 -5
  16. package/dist/cli/help.js.map +1 -1
  17. package/dist/cli/install.d.ts +21 -0
  18. package/dist/cli/install.d.ts.map +1 -0
  19. package/dist/cli/install.js +87 -0
  20. package/dist/cli/install.js.map +1 -0
  21. package/dist/cli/main.js +22 -3
  22. package/dist/cli/main.js.map +1 -1
  23. package/dist/cli/notices.d.ts +13 -0
  24. package/dist/cli/notices.d.ts.map +1 -0
  25. package/dist/cli/notices.js +31 -0
  26. package/dist/cli/notices.js.map +1 -0
  27. package/dist/client.d.ts +15 -2
  28. package/dist/client.d.ts.map +1 -1
  29. package/dist/client.js +8 -3
  30. package/dist/client.js.map +1 -1
  31. package/dist/compat.d.ts +66 -0
  32. package/dist/compat.d.ts.map +1 -0
  33. package/dist/compat.js +115 -0
  34. package/dist/compat.js.map +1 -0
  35. package/dist/components/calendar.d.ts +3 -1
  36. package/dist/components/calendar.d.ts.map +1 -1
  37. package/dist/components/calendar.js +21 -2
  38. package/dist/components/calendar.js.map +1 -1
  39. package/dist/components/goals.d.ts +3 -1
  40. package/dist/components/goals.d.ts.map +1 -1
  41. package/dist/components/goals.js +23 -11
  42. package/dist/components/goals.js.map +1 -1
  43. package/dist/components/roadmap.d.ts +37 -0
  44. package/dist/components/roadmap.d.ts.map +1 -0
  45. package/dist/components/roadmap.js +76 -0
  46. package/dist/components/roadmap.js.map +1 -0
  47. package/dist/components/tokens.d.ts +2 -0
  48. package/dist/components/tokens.d.ts.map +1 -1
  49. package/dist/components/tokens.js +13 -3
  50. package/dist/components/tokens.js.map +1 -1
  51. package/dist/console.css +204 -0
  52. package/dist/console.d.ts.map +1 -1
  53. package/dist/console.js +8 -7
  54. package/dist/console.js.map +1 -1
  55. package/dist/data.js +1 -1
  56. package/dist/data.js.map +1 -1
  57. package/dist/i18n/en.d.ts +24 -0
  58. package/dist/i18n/en.d.ts.map +1 -1
  59. package/dist/i18n/en.js +24 -0
  60. package/dist/i18n/en.js.map +1 -1
  61. package/dist/i18n/ko.d.ts.map +1 -1
  62. package/dist/i18n/ko.js +24 -0
  63. package/dist/i18n/ko.js.map +1 -1
  64. package/dist/index.d.ts +2 -1
  65. package/dist/index.d.ts.map +1 -1
  66. package/dist/index.js +2 -1
  67. package/dist/index.js.map +1 -1
  68. package/dist/router.d.ts +6 -1
  69. package/dist/router.d.ts.map +1 -1
  70. package/dist/router.js +10 -1
  71. package/dist/router.js.map +1 -1
  72. package/dist/schemas.d.ts +42 -0
  73. package/dist/schemas.d.ts.map +1 -1
  74. package/dist/schemas.js +18 -0
  75. package/dist/schemas.js.map +1 -1
  76. package/docs/specs/cli.md +9 -6
  77. package/docs/specs/rest.md +9 -5
  78. package/package.json +3 -2
  79. package/skills/working-the-board/SKILL.md +4 -3
  80. package/src/api.ts +23 -0
  81. package/src/cli/cli.ts +178 -15
  82. package/src/cli/format.ts +8 -3
  83. package/src/cli/help.ts +7 -5
  84. package/src/cli/install.ts +111 -0
  85. package/src/cli/main.ts +23 -3
  86. package/src/cli/notices.ts +48 -0
  87. package/src/client.ts +31 -6
  88. package/src/compat.ts +173 -0
  89. package/src/components/calendar.tsx +41 -1
  90. package/src/components/goals.tsx +86 -7
  91. package/src/components/roadmap.tsx +242 -0
  92. package/src/components/tokens.tsx +38 -1
  93. package/src/console.tsx +19 -0
  94. package/src/data.ts +1 -1
  95. package/src/i18n/en.ts +24 -0
  96. package/src/i18n/ko.ts +24 -0
  97. package/src/index.ts +8 -0
  98. package/src/router.ts +13 -2
  99. package/src/schemas.ts +20 -0
  100. package/src/styles/console.css +204 -0
package/src/client.ts CHANGED
@@ -107,7 +107,7 @@ import {
107
107
  restVersionsSchema,
108
108
  restWorkSchema,
109
109
  } from "./schemas.js";
110
- import type { TaskState } from "./vocabulary.js";
110
+ import type { GoalState, TaskState } from "./vocabulary.js";
111
111
 
112
112
  // The client of the console's REST API: what the default console and a custom one talk to the
113
113
  // server with, and what the command line, a script or a console of a person's own talks to it
@@ -154,6 +154,12 @@ export interface ClientOptions {
154
154
  * the page's own origin is what the console answers to.
155
155
  */
156
156
  readonly token?: string;
157
+ /**
158
+ * What the client calls itself, sent as `user-agent` where the platform lets a client say
159
+ * (Node; a browser keeps its own): the command sends its product token, by which the console
160
+ * holds it to the versions it takes (ADR-0025).
161
+ */
162
+ readonly userAgent?: string;
157
163
  }
158
164
 
159
165
  export class ApiError extends Error {
@@ -189,6 +195,13 @@ export interface Page {
189
195
  }
190
196
 
191
197
  /** What the board is narrowed to: one state, the open ones, a category, a goal, an assignee, or what is due by a day. */
198
+ /** What the roadmap is narrowed by: the goals in one state, or sorted into one category (ADR-0026). */
199
+ export interface GoalFilter {
200
+ readonly state?: GoalState | undefined;
201
+ /** The key of a category of the workspace. */
202
+ readonly category?: string | undefined;
203
+ }
204
+
192
205
  export interface TaskFilter extends Page {
193
206
  readonly state?: TaskState | undefined;
194
207
  /** True for the tasks not done or dropped; false for the rest. */
@@ -302,7 +315,8 @@ export interface ProjectClient {
302
315
  /** Where the bytes of a file attached to a document are read, by the file's id. */
303
316
  documentFileUrl(path: string, fileId: string): string;
304
317
  /** The roadmap (ADR-0019): the goals, the active first, each with its tasks counted by state. */
305
- goals(signal?: AbortSignal): Promise<RestGoals>;
318
+ /** The roadmap, or what the filter keeps of it: the active goals first. */
319
+ goals(filter?: GoalFilter, signal?: AbortSignal): Promise<RestGoals>;
306
320
  goal(id: string, signal?: AbortSignal): Promise<RestGoal>;
307
321
  /** Sets a goal; for an owner, with a session or a token. */
308
322
  createGoal(input: RestGoalInput): Promise<RestGoal>;
@@ -381,7 +395,8 @@ const INVALID = "The server answered unexpectedly.";
381
395
  export function createClient(options: ClientOptions = {}): ConsoleClient {
382
396
  const base = (options.baseUrl ?? "").replace(/\/+$/, "");
383
397
  const send = options.fetch ?? ((input, init) => fetch(input, init));
384
- const { token } = options;
398
+ const { token, userAgent } = options;
399
+ const named: Record<string, string> = userAgent === undefined ? {} : { "user-agent": userAgent };
385
400
 
386
401
  const withQuery = (path: string, params: Record<string, string | undefined>): string => {
387
402
  const query = new URLSearchParams();
@@ -423,6 +438,7 @@ export function createClient(options: ClientOptions = {}): ConsoleClient {
423
438
  // A form names its own type, boundary included; everything else is JSON.
424
439
  ...(body === undefined || form ? {} : { "content-type": "application/json" }),
425
440
  ...(token === undefined ? {} : { authorization: `Bearer ${token}` }),
441
+ ...named,
426
442
  },
427
443
  ...(body === undefined ? {} : { body: form ? body : JSON.stringify(body) }),
428
444
  });
@@ -457,7 +473,10 @@ export function createClient(options: ClientOptions = {}): ConsoleClient {
457
473
  method: "GET",
458
474
  ...(signal === undefined ? {} : { signal }),
459
475
  credentials: token === undefined ? "same-origin" : "omit",
460
- headers: token === undefined ? {} : { authorization: `Bearer ${token}` },
476
+ headers: {
477
+ ...(token === undefined ? {} : { authorization: `Bearer ${token}` }),
478
+ ...named,
479
+ },
461
480
  });
462
481
  } catch (error) {
463
482
  if (signal?.aborted === true) {
@@ -681,8 +700,14 @@ export function createClient(options: ClientOptions = {}): ConsoleClient {
681
700
  request("POST", `${base}${at("docs", path)}/files`, formOf(input), restDocumentSchema),
682
701
  documentFileUrl: (path, fileId) =>
683
702
  `${base}${at("docs", path)}/files/${encodeURIComponent(fileId)}`,
684
- goals: (signal) =>
685
- request("GET", `${base}${at("goals")}`, undefined, restGoalsSchema, signal),
703
+ goals: (filter = {}, signal) =>
704
+ request(
705
+ "GET",
706
+ withQuery(at("goals"), { state: filter.state, category: filter.category }),
707
+ undefined,
708
+ restGoalsSchema,
709
+ signal,
710
+ ),
686
711
  goal: (id, signal) =>
687
712
  request("GET", `${base}${at("goals", id)}`, undefined, restGoalSchema, signal),
688
713
  createGoal: (input) => request("POST", `${base}${at("goals")}`, input, restGoalSchema),
package/src/compat.ts ADDED
@@ -0,0 +1,173 @@
1
+ // Whether the command and the console go together (ADR-0025). The command names its version on
2
+ // every request, in the product token of its user-agent; the console answers every request of
3
+ // the API with its own version and the range of command versions it takes, and refuses a
4
+ // command outside the range with `426` and the words to update. One rule for both sides, kept
5
+ // here so that the command and the console read the same one, and narrowed by the console's
6
+ // policy below only, never widened: what the rule promises stays predictable.
7
+
8
+ /** The product token the command names itself with: `skillcdn-console/<version>`. */
9
+ export const CLIENT_PRODUCT = "skillcdn-console";
10
+ /** On every answer of the API: the console's version. */
11
+ export const VERSION_HEADER = "console-version";
12
+ /** On every answer of the API: the range of command versions the console takes, as npm spells one. */
13
+ export const CLIENT_RANGE_HEADER = "console-client-range";
14
+
15
+ export interface Version {
16
+ readonly major: number;
17
+ readonly minor: number;
18
+ readonly patch: number;
19
+ }
20
+
21
+ /** `x.y.z`, each a number of at most nine digits without a leading zero; no tag, no build. */
22
+ const VERSION = /^(0|[1-9]\d{0,8})\.(0|[1-9]\d{0,8})\.(0|[1-9]\d{0,8})$/;
23
+ const MAX_VERSION_LENGTH = 32;
24
+
25
+ /** A version as the package spells one, or nothing for any other text. */
26
+ export function parseVersion(text: string | null | undefined): Version | undefined {
27
+ if (typeof text !== "string" || text.length > MAX_VERSION_LENGTH) {
28
+ return undefined;
29
+ }
30
+ const match = VERSION.exec(text);
31
+ return match === null
32
+ ? undefined
33
+ : { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]) };
34
+ }
35
+
36
+ export function formatVersion(version: Version): string {
37
+ return `${version.major}.${version.minor}.${version.patch}`;
38
+ }
39
+
40
+ /** Negative when `a` is the older, zero when they are the same, positive when `a` is the newer. */
41
+ export function compareVersions(a: Version, b: Version): number {
42
+ return a.major - b.major || a.minor - b.minor || a.patch - b.patch;
43
+ }
44
+
45
+ /** A half-open range of versions: from `atLeast` on, and before `below`. */
46
+ export interface VersionRange {
47
+ readonly atLeast: Version;
48
+ readonly below: Version;
49
+ }
50
+
51
+ /** The range as npm spells one: `>=0.1.18 <0.2.0`. */
52
+ export function formatRange(range: VersionRange): string {
53
+ return `>=${formatVersion(range.atLeast)} <${formatVersion(range.below)}`;
54
+ }
55
+
56
+ const RANGE = /^>=(\S+) <(\S+)$/;
57
+ const MAX_RANGE_LENGTH = 2 * MAX_VERSION_LENGTH + 4;
58
+
59
+ /** A range as `formatRange` spells one, or nothing for any other text. */
60
+ export function parseRange(text: string | null | undefined): VersionRange | undefined {
61
+ if (typeof text !== "string" || text.length > MAX_RANGE_LENGTH) {
62
+ return undefined;
63
+ }
64
+ const match = RANGE.exec(text);
65
+ const atLeast = parseVersion(match?.[1]);
66
+ const below = parseVersion(match?.[2]);
67
+ return atLeast !== undefined && below !== undefined && compareVersions(atLeast, below) < 0
68
+ ? { atLeast, below }
69
+ : undefined;
70
+ }
71
+
72
+ export function inRange(version: Version, range: VersionRange): boolean {
73
+ return compareVersions(version, range.atLeast) >= 0 && compareVersions(version, range.below) < 0;
74
+ }
75
+
76
+ /**
77
+ * The common rule. Before 1.0, a command from the console's own version on and within the
78
+ * console's minor (`^0.1.18`): any release may change the contract until then (ADR-0007), so the
79
+ * console's own version is the floor, and the minor is the line. From 1.0, the console's major
80
+ * and a minor within one of the console's, either way, whatever the patch: a minor adds and a
81
+ * major breaks, as the version promises from then on.
82
+ */
83
+ export function commonRange(server: Version): VersionRange {
84
+ if (server.major === 0) {
85
+ return { atLeast: server, below: { major: 0, minor: server.minor + 1, patch: 0 } };
86
+ }
87
+ return {
88
+ atLeast: { major: server.major, minor: Math.max(0, server.minor - 1), patch: 0 },
89
+ below: { major: server.major, minor: server.minor + 2, patch: 0 },
90
+ };
91
+ }
92
+
93
+ /**
94
+ * What the console narrows the common rule by, and nothing it widens it by: a floor above the
95
+ * rule's, for a contract that moved within what the rule still allows, and versions refused
96
+ * whatever the rule says, for a release whose command is wrong. Changed with a changeset that
97
+ * says why.
98
+ */
99
+ export interface ClientPolicy {
100
+ /** The oldest command this console answers, when the common rule's floor is too low. */
101
+ readonly atLeast?: string | undefined;
102
+ /** Versions of the command refused outright. */
103
+ readonly refused?: readonly string[] | undefined;
104
+ }
105
+
106
+ /** The console's policy today: the common rule as it is. */
107
+ export const CLIENT_POLICY: ClientPolicy = {};
108
+
109
+ /** The range of command versions a console of `server` takes: the common rule, narrowed by the policy's floor where that floor is within it. */
110
+ export function clientRange(server: Version, policy: ClientPolicy = CLIENT_POLICY): VersionRange {
111
+ const common = commonRange(server);
112
+ const floor = parseVersion(policy.atLeast);
113
+ return floor !== undefined &&
114
+ compareVersions(floor, common.atLeast) > 0 &&
115
+ compareVersions(floor, common.below) < 0
116
+ ? { atLeast: floor, below: common.below }
117
+ : common;
118
+ }
119
+
120
+ /**
121
+ * What a command of one version is to a console of another: within the range and the same
122
+ * (`current`), older (`behind`) or newer (`ahead`), which the console answers; or outside it,
123
+ * older (`outdated`) or newer (`too_new`), which the console refuses.
124
+ */
125
+ export type ClientVerdict = "current" | "behind" | "ahead" | "outdated" | "too_new";
126
+
127
+ export interface ClientJudgement {
128
+ readonly verdict: ClientVerdict;
129
+ readonly range: VersionRange;
130
+ }
131
+
132
+ export function judgeClient(
133
+ client: Version,
134
+ server: Version,
135
+ policy: ClientPolicy = CLIENT_POLICY,
136
+ ): ClientJudgement {
137
+ const range = clientRange(server, policy);
138
+ const order = compareVersions(client, server);
139
+ const refused = (policy.refused ?? []).some((text) => {
140
+ const version = parseVersion(text);
141
+ return version !== undefined && compareVersions(version, client) === 0;
142
+ });
143
+ if (refused) {
144
+ return { verdict: order > 0 ? "too_new" : "outdated", range };
145
+ }
146
+ if (compareVersions(client, range.atLeast) < 0) {
147
+ return { verdict: "outdated", range };
148
+ }
149
+ if (compareVersions(client, range.below) >= 0) {
150
+ return { verdict: "too_new", range };
151
+ }
152
+ return { verdict: order === 0 ? "current" : order < 0 ? "behind" : "ahead", range };
153
+ }
154
+
155
+ /** What the command sends as its user-agent: its product token, and, in a comment, where it runs. */
156
+ export function userAgentOf(version: string, comment?: string): string {
157
+ // A comment holds printable ASCII without parentheses or backslashes; nothing else is said.
158
+ const safe = comment?.replaceAll(/[^\x20-\x27\x2a-\x5b\x5d-\x7e]/g, "").trim();
159
+ return `${CLIENT_PRODUCT}/${version}${safe === undefined || safe.length === 0 ? "" : ` (${safe})`}`;
160
+ }
161
+
162
+ const MAX_USER_AGENT_LENGTH = 512;
163
+ const CLIENT_TOKEN = new RegExp(
164
+ `(?:^|\\s)${CLIENT_PRODUCT}/(\\d{1,9}\\.\\d{1,9}\\.\\d{1,9})(?![\\w.-])`,
165
+ );
166
+
167
+ /** The version of the command a request's user-agent names, or nothing for any other client. */
168
+ export function clientVersionOf(userAgent: string | null | undefined): Version | undefined {
169
+ if (typeof userAgent !== "string") {
170
+ return undefined;
171
+ }
172
+ return parseVersion(CLIENT_TOKEN.exec(userAgent.slice(0, MAX_USER_AGENT_LENGTH))?.[1]);
173
+ }
@@ -48,9 +48,11 @@ export interface CalendarProps {
48
48
  readonly today?: string | undefined;
49
49
  /** The workspace's categories, for the legend to say what the colours mean. */
50
50
  readonly categories?: readonly RestCategory[] | undefined;
51
- /** The project's goals, so that the task under the pointer is shown with its goal's title. */
51
+ /** The project's goals, so that the task under the pointer is shown with its goal's title, and, given `goalHref`, so that each goal marks its target day. */
52
52
  readonly goals?: readonly RestGoal[] | undefined;
53
53
  readonly taskHref: (task: RestTask) => string;
54
+ /** Where a goal's page is: given, the goals mark their target days on the month (ADR-0026). */
55
+ readonly goalHref?: ((goal: RestGoal) => string) | undefined;
54
56
  /** Where the calendar is on another month. */
55
57
  readonly monthHref: (month: string) => string;
56
58
  }
@@ -324,12 +326,32 @@ interface WeekRowProps {
324
326
  readonly pointed: Pointed | null;
325
327
  /** The project's goals, so that the item under the pointer names its goal; none across projects. */
326
328
  readonly goals?: readonly RestGoal[] | undefined;
329
+ /** The goals whose target day is each day, where the goals mark the month. */
330
+ readonly goalsOn?: ReadonlyMap<string, readonly RestGoal[]> | undefined;
331
+ readonly goalHref?: ((goal: RestGoal) => string) | undefined;
327
332
  /** The pointer, or the focus, arrived on a task's item on a day. */
328
333
  readonly onPoint: (task: RestTask, day: string, item: HTMLElement) => void;
329
334
  /** It left. */
330
335
  readonly onLeave: () => void;
331
336
  }
332
337
 
338
+ /** The goals that aim at each day, by day: those with a target day, still planned or worked towards. */
339
+ function goalsOnDays(goals: readonly RestGoal[]): ReadonlyMap<string, readonly RestGoal[]> {
340
+ const on = new Map<string, RestGoal[]>();
341
+ for (const goal of goals) {
342
+ if (goal.dueOn === null || goal.state === "dropped") {
343
+ continue;
344
+ }
345
+ const list = on.get(goal.dueOn);
346
+ if (list === undefined) {
347
+ on.set(goal.dueOn, [goal]);
348
+ } else {
349
+ list.push(goal);
350
+ }
351
+ }
352
+ return on;
353
+ }
354
+
333
355
  /** One week as a row of cells: the bars across the days in their lanes, then the tasks on each day. */
334
356
  function WeekRow(props: WeekRowProps) {
335
357
  const { week, segments, now } = props;
@@ -361,6 +383,21 @@ function WeekRow(props: WeekRowProps) {
361
383
  )}
362
384
  >
363
385
  <span className="sc-calendar-day">{Number(cell.day.slice(8))}</span>
386
+ {props.goalHref !== undefined &&
387
+ (props.goalsOn?.get(cell.day) ?? []).map((goal) => (
388
+ <a
389
+ key={goal.id}
390
+ className={cx(
391
+ "sc-calendar-goal",
392
+ `sc-category-${goal.category?.color ?? "gray"}`,
393
+ goal.state === "done" && "sc-calendar-over",
394
+ )}
395
+ href={props.goalHref?.(goal)}
396
+ >
397
+ <span className="sc-calendar-flag" aria-hidden="true" />
398
+ <span className="sc-calendar-task-title">{goal.title}</span>
399
+ </a>
400
+ ))}
364
401
  {Array.from({ length: lanes }, (_, lane) => {
365
402
  const segment = segments.find(
366
403
  (candidate) =>
@@ -435,6 +472,7 @@ export function Calendar(props: CalendarProps) {
435
472
  const firstDay = firstDayOfWeek(tag);
436
473
  const weeks = monthGrid(props.month, firstDay);
437
474
  const on = onDays(props.tasks);
475
+ const goalsOn = props.goalHref === undefined ? undefined : goalsOnDays(props.goals ?? []);
438
476
  const inMonth = (day: string) => monthOf(day) === props.month;
439
477
  const monthWeeks = weeks.map((week) => ({ week, segments: weekSegments(week, props.tasks) }));
440
478
  const nothing =
@@ -492,6 +530,8 @@ export function Calendar(props: CalendarProps) {
492
530
  taskHref={props.taskHref}
493
531
  pointed={pointed}
494
532
  goals={props.goals}
533
+ goalsOn={goalsOn}
534
+ goalHref={props.goalHref}
495
535
  onPoint={point}
496
536
  onLeave={leave}
497
537
  people
@@ -4,20 +4,32 @@ import {
4
4
  type GoalState,
5
5
  MAX_BODY_LENGTH,
6
6
  MAX_TITLE_LENGTH,
7
+ type RestCategory,
7
8
  type RestGoal,
8
9
  type RestGoalInput,
9
10
  type RestTask,
10
11
  TASK_STATES,
11
12
  } from "../api.js";
12
- import { useWords } from "../i18n/index.js";
13
+ import { formatDays } from "../dates.js";
14
+ import { useLanguage, useWords } from "../i18n/index.js";
13
15
  import { Markdown } from "./markdown.js";
14
16
  import { TaskList } from "./task-list.js";
15
- import { Button, Day, DueBadge, GoalStateBadge, PersonChip, ProgressBar, Time } from "./ui.js";
17
+ import {
18
+ Button,
19
+ CategoryBadge,
20
+ Day,
21
+ DueBadge,
22
+ GoalStateBadge,
23
+ PersonChip,
24
+ ProgressBar,
25
+ Time,
26
+ } from "./ui.js";
16
27
 
17
- // The roadmap of a project (ADR-0019): its goals, each with the day it is meant to be reached
18
- // by and how far its tasks are, read from the board as it stands. The list, one goal with its
19
- // tasks, and the form an owner sets one with. Each takes its data as props and nothing from
20
- // the network; what people wrote is shown as text, never as HTML.
28
+ // The roadmap of a project (ADR-0019): its goals, each planned in days as a task is, a start
29
+ // and a target, sorted into a category of the workspace (ADR-0026), with how far its tasks
30
+ // are, read from the board as it stands. The list, one goal with its tasks, and the form an
31
+ // owner sets one with. Each takes its data as props and nothing from the network; what people
32
+ // wrote is shown as text, never as HTML.
21
33
 
22
34
  /** A goal's day on the calendar's terms: reached or let go, it is a plain fact; else it may be late. */
23
35
  const dueState = (goal: RestGoal) =>
@@ -33,6 +45,7 @@ export interface GoalListProps {
33
45
 
34
46
  export function GoalList(props: GoalListProps) {
35
47
  const words = useWords().roadmap;
48
+ const { tag } = useLanguage();
36
49
  if (props.goals.length === 0) {
37
50
  return <>{props.empty ?? null}</>;
38
51
  }
@@ -44,7 +57,11 @@ export function GoalList(props: GoalListProps) {
44
57
  <a className="sc-goal-title" href={props.goalHref(goal)}>
45
58
  {goal.title}
46
59
  </a>
60
+ {goal.category !== null && <CategoryBadge category={goal.category} />}
47
61
  <GoalStateBadge state={goal.state} />
62
+ {goal.startsOn !== null && goal.dueOn !== null && goal.startsOn < goal.dueOn && (
63
+ <span className="sc-goal-days">{formatDays(goal.startsOn, goal.dueOn, tag)}</span>
64
+ )}
48
65
  {goal.dueOn !== null && (
49
66
  <DueBadge dueOn={goal.dueOn} state={dueState(goal)} today={props.today} />
50
67
  )}
@@ -59,6 +76,8 @@ export function GoalList(props: GoalListProps) {
59
76
  export interface GoalFormProps {
60
77
  /** The goal to change; left out, the form sets a new one. */
61
78
  readonly goal?: RestGoal | undefined;
79
+ /** The workspace's categories to sort the goal into; left out, the form offers none. */
80
+ readonly categories?: readonly RestCategory[] | undefined;
62
81
  readonly busy?: boolean | undefined;
63
82
  readonly error?: string | undefined;
64
83
  readonly onSubmit: (input: RestGoalInput) => void;
@@ -72,11 +91,20 @@ export function GoalForm(props: GoalFormProps) {
72
91
  const [title, setTitle] = useState(goal?.title ?? "");
73
92
  const [body, setBody] = useState(goal?.body ?? "");
74
93
  const [state, setState] = useState<GoalState>(goal?.state ?? "planned");
94
+ const [category, setCategory] = useState(goal?.category?.key ?? "");
95
+ const [startsOn, setStartsOn] = useState(goal?.startsOn ?? "");
75
96
  const [dueOn, setDueOn] = useState(goal?.dueOn ?? "");
76
97
 
77
98
  const submit = (event: FormEvent) => {
78
99
  event.preventDefault();
79
- props.onSubmit({ title: title.trim(), body, state, dueOn: dueOn === "" ? null : dueOn });
100
+ props.onSubmit({
101
+ title: title.trim(),
102
+ body,
103
+ state,
104
+ category: category === "" ? null : category,
105
+ startsOn: startsOn === "" ? null : startsOn,
106
+ dueOn: dueOn === "" ? null : dueOn,
107
+ });
80
108
  };
81
109
 
82
110
  return (
@@ -117,12 +145,42 @@ export function GoalForm(props: GoalFormProps) {
117
145
  ))}
118
146
  </select>
119
147
  </label>
148
+ {props.categories !== undefined && (
149
+ <label className="sc-field">
150
+ <span className="sc-field-label">{form.category}</span>
151
+ <select
152
+ className="sc-input"
153
+ value={category}
154
+ onChange={(event) => setCategory(event.target.value)}
155
+ >
156
+ <option value="">{form.noCategory}</option>
157
+ {props.categories.map((candidate) => (
158
+ <option key={candidate.key} value={candidate.key}>
159
+ {candidate.name}
160
+ </option>
161
+ ))}
162
+ </select>
163
+ </label>
164
+ )}
165
+ </div>
166
+ <div className="sc-field-row">
167
+ <label className="sc-field">
168
+ <span className="sc-field-label">{form.startsOn}</span>
169
+ <input
170
+ className="sc-input"
171
+ type="date"
172
+ value={startsOn}
173
+ max={dueOn === "" ? undefined : dueOn}
174
+ onChange={(event) => setStartsOn(event.target.value)}
175
+ />
176
+ </label>
120
177
  <label className="sc-field">
121
178
  <span className="sc-field-label">{form.dueOn}</span>
122
179
  <input
123
180
  className="sc-input"
124
181
  type="date"
125
182
  value={dueOn}
183
+ min={startsOn === "" ? undefined : startsOn}
126
184
  onChange={(event) => setDueOn(event.target.value)}
127
185
  />
128
186
  </label>
@@ -175,6 +233,7 @@ export function GoalView(props: GoalViewProps) {
175
233
  <header className="sc-task-header">
176
234
  <h1 className="sc-task-title">{goal.title}</h1>
177
235
  <div className="sc-task-badges">
236
+ {goal.category !== null && <CategoryBadge category={goal.category} />}
178
237
  <GoalStateBadge state={goal.state} />
179
238
  {goal.dueOn !== null && (
180
239
  <DueBadge dueOn={goal.dueOn} state={dueState(goal)} today={props.today} />
@@ -233,6 +292,26 @@ export function GoalView(props: GoalViewProps) {
233
292
  )}
234
293
  </dd>
235
294
  </div>
295
+ <div>
296
+ <dt>{roadmap.category}</dt>
297
+ <dd>
298
+ {goal.category === null ? (
299
+ <span className="sc-muted">{roadmap.noCategory}</span>
300
+ ) : (
301
+ <CategoryBadge category={goal.category} />
302
+ )}
303
+ </dd>
304
+ </div>
305
+ <div>
306
+ <dt>{roadmap.starts}</dt>
307
+ <dd>
308
+ {goal.startsOn === null ? (
309
+ <span className="sc-muted">{roadmap.noStart}</span>
310
+ ) : (
311
+ <Day day={goal.startsOn} />
312
+ )}
313
+ </dd>
314
+ </div>
236
315
  <div>
237
316
  <dt>{roadmap.target}</dt>
238
317
  <dd>