@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.
- package/README.md +3 -3
- package/dist/api.d.ts +2 -1
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +1 -0
- package/dist/api.js.map +1 -1
- package/dist/cli/cli.d.ts +10 -0
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +145 -14
- package/dist/cli/cli.js.map +1 -1
- package/dist/cli/format.d.ts +1 -1
- package/dist/cli/format.d.ts.map +1 -1
- package/dist/cli/format.js +8 -3
- package/dist/cli/format.js.map +1 -1
- package/dist/cli/help.d.ts.map +1 -1
- package/dist/cli/help.js +7 -5
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/install.d.ts +21 -0
- package/dist/cli/install.d.ts.map +1 -0
- package/dist/cli/install.js +87 -0
- package/dist/cli/install.js.map +1 -0
- package/dist/cli/main.js +22 -3
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/notices.d.ts +13 -0
- package/dist/cli/notices.d.ts.map +1 -0
- package/dist/cli/notices.js +31 -0
- package/dist/cli/notices.js.map +1 -0
- package/dist/client.d.ts +15 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +8 -3
- package/dist/client.js.map +1 -1
- package/dist/compat.d.ts +66 -0
- package/dist/compat.d.ts.map +1 -0
- package/dist/compat.js +115 -0
- package/dist/compat.js.map +1 -0
- package/dist/components/calendar.d.ts +3 -1
- package/dist/components/calendar.d.ts.map +1 -1
- package/dist/components/calendar.js +21 -2
- package/dist/components/calendar.js.map +1 -1
- package/dist/components/goals.d.ts +3 -1
- package/dist/components/goals.d.ts.map +1 -1
- package/dist/components/goals.js +23 -11
- package/dist/components/goals.js.map +1 -1
- package/dist/components/roadmap.d.ts +37 -0
- package/dist/components/roadmap.d.ts.map +1 -0
- package/dist/components/roadmap.js +76 -0
- package/dist/components/roadmap.js.map +1 -0
- package/dist/components/tokens.d.ts +2 -0
- package/dist/components/tokens.d.ts.map +1 -1
- package/dist/components/tokens.js +13 -3
- package/dist/components/tokens.js.map +1 -1
- package/dist/console.css +204 -0
- package/dist/console.d.ts.map +1 -1
- package/dist/console.js +8 -7
- package/dist/console.js.map +1 -1
- package/dist/data.js +1 -1
- package/dist/data.js.map +1 -1
- package/dist/i18n/en.d.ts +24 -0
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +24 -0
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/ko.d.ts.map +1 -1
- package/dist/i18n/ko.js +24 -0
- package/dist/i18n/ko.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/router.d.ts +6 -1
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +10 -1
- package/dist/router.js.map +1 -1
- package/dist/schemas.d.ts +42 -0
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +18 -0
- package/dist/schemas.js.map +1 -1
- package/docs/specs/cli.md +9 -6
- package/docs/specs/rest.md +9 -5
- package/package.json +3 -2
- package/skills/working-the-board/SKILL.md +4 -3
- package/src/api.ts +23 -0
- package/src/cli/cli.ts +178 -15
- package/src/cli/format.ts +8 -3
- package/src/cli/help.ts +7 -5
- package/src/cli/install.ts +111 -0
- package/src/cli/main.ts +23 -3
- package/src/cli/notices.ts +48 -0
- package/src/client.ts +31 -6
- package/src/compat.ts +173 -0
- package/src/components/calendar.tsx +41 -1
- package/src/components/goals.tsx +86 -7
- package/src/components/roadmap.tsx +242 -0
- package/src/components/tokens.tsx +38 -1
- package/src/console.tsx +19 -0
- package/src/data.ts +1 -1
- package/src/i18n/en.ts +24 -0
- package/src/i18n/ko.ts +24 -0
- package/src/index.ts +8 -0
- package/src/router.ts +13 -2
- package/src/schemas.ts +20 -0
- 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
|
-
|
|
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:
|
|
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(
|
|
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
|
package/src/components/goals.tsx
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
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({
|
|
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>
|