@skillcdn/console 0.1.17 → 0.1.19

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 (154) hide show
  1. package/README.md +4 -4
  2. package/SKILLCDN.md +1 -1
  3. package/dist/api.d.ts +3 -2
  4. package/dist/api.d.ts.map +1 -1
  5. package/dist/api.js +2 -1
  6. package/dist/api.js.map +1 -1
  7. package/dist/cli/cli.d.ts +11 -1
  8. package/dist/cli/cli.d.ts.map +1 -1
  9. package/dist/cli/cli.js +496 -51
  10. package/dist/cli/cli.js.map +1 -1
  11. package/dist/cli/credentials.d.ts +102 -3
  12. package/dist/cli/credentials.d.ts.map +1 -1
  13. package/dist/cli/credentials.js +177 -8
  14. package/dist/cli/credentials.js.map +1 -1
  15. package/dist/cli/directory.d.ts +6 -0
  16. package/dist/cli/directory.d.ts.map +1 -1
  17. package/dist/cli/directory.js +37 -6
  18. package/dist/cli/directory.js.map +1 -1
  19. package/dist/cli/format.d.ts +11 -2
  20. package/dist/cli/format.d.ts.map +1 -1
  21. package/dist/cli/format.js +114 -5
  22. package/dist/cli/format.js.map +1 -1
  23. package/dist/cli/help.d.ts.map +1 -1
  24. package/dist/cli/help.js +49 -25
  25. package/dist/cli/help.js.map +1 -1
  26. package/dist/cli/install.d.ts +21 -0
  27. package/dist/cli/install.d.ts.map +1 -0
  28. package/dist/cli/install.js +87 -0
  29. package/dist/cli/install.js.map +1 -0
  30. package/dist/cli/main.js +22 -3
  31. package/dist/cli/main.js.map +1 -1
  32. package/dist/cli/notices.d.ts +13 -0
  33. package/dist/cli/notices.d.ts.map +1 -0
  34. package/dist/cli/notices.js +31 -0
  35. package/dist/cli/notices.js.map +1 -0
  36. package/dist/client.d.ts +36 -3
  37. package/dist/client.d.ts.map +1 -1
  38. package/dist/client.js +22 -4
  39. package/dist/client.js.map +1 -1
  40. package/dist/compat.d.ts +66 -0
  41. package/dist/compat.d.ts.map +1 -0
  42. package/dist/compat.js +115 -0
  43. package/dist/compat.js.map +1 -0
  44. package/dist/components/board.d.ts +9 -1
  45. package/dist/components/board.d.ts.map +1 -1
  46. package/dist/components/board.js +82 -5
  47. package/dist/components/board.js.map +1 -1
  48. package/dist/components/calendar.d.ts +3 -1
  49. package/dist/components/calendar.d.ts.map +1 -1
  50. package/dist/components/calendar.js +21 -2
  51. package/dist/components/calendar.js.map +1 -1
  52. package/dist/components/comments.d.ts.map +1 -1
  53. package/dist/components/comments.js +12 -4
  54. package/dist/components/comments.js.map +1 -1
  55. package/dist/components/event-feed.d.ts.map +1 -1
  56. package/dist/components/event-feed.js +4 -0
  57. package/dist/components/event-feed.js.map +1 -1
  58. package/dist/components/goals.d.ts +20 -2
  59. package/dist/components/goals.d.ts.map +1 -1
  60. package/dist/components/goals.js +50 -13
  61. package/dist/components/goals.js.map +1 -1
  62. package/dist/components/markdown.d.ts +6 -0
  63. package/dist/components/markdown.d.ts.map +1 -1
  64. package/dist/components/markdown.js +7 -0
  65. package/dist/components/markdown.js.map +1 -1
  66. package/dist/components/pictures.d.ts +68 -0
  67. package/dist/components/pictures.d.ts.map +1 -0
  68. package/dist/components/pictures.js +213 -0
  69. package/dist/components/pictures.js.map +1 -0
  70. package/dist/components/projects.d.ts +12 -0
  71. package/dist/components/projects.d.ts.map +1 -1
  72. package/dist/components/projects.js +15 -0
  73. package/dist/components/projects.js.map +1 -1
  74. package/dist/components/roadmap.d.ts +37 -0
  75. package/dist/components/roadmap.d.ts.map +1 -0
  76. package/dist/components/roadmap.js +76 -0
  77. package/dist/components/roadmap.js.map +1 -0
  78. package/dist/components/task-view.d.ts +3 -0
  79. package/dist/components/task-view.d.ts.map +1 -1
  80. package/dist/components/task-view.js +6 -4
  81. package/dist/components/task-view.js.map +1 -1
  82. package/dist/components/tokens.d.ts +2 -0
  83. package/dist/components/tokens.d.ts.map +1 -1
  84. package/dist/components/tokens.js +13 -3
  85. package/dist/components/tokens.js.map +1 -1
  86. package/dist/console.css +486 -3
  87. package/dist/console.d.ts.map +1 -1
  88. package/dist/console.js +132 -17
  89. package/dist/console.js.map +1 -1
  90. package/dist/data.d.ts +16 -1
  91. package/dist/data.d.ts.map +1 -1
  92. package/dist/data.js +48 -3
  93. package/dist/data.js.map +1 -1
  94. package/dist/i18n/en.d.ts +76 -0
  95. package/dist/i18n/en.d.ts.map +1 -1
  96. package/dist/i18n/en.js +74 -2
  97. package/dist/i18n/en.js.map +1 -1
  98. package/dist/i18n/ko.d.ts.map +1 -1
  99. package/dist/i18n/ko.js +74 -2
  100. package/dist/i18n/ko.js.map +1 -1
  101. package/dist/index.d.ts +6 -4
  102. package/dist/index.d.ts.map +1 -1
  103. package/dist/index.js +6 -4
  104. package/dist/index.js.map +1 -1
  105. package/dist/router.d.ts +31 -7
  106. package/dist/router.d.ts.map +1 -1
  107. package/dist/router.js +48 -8
  108. package/dist/router.js.map +1 -1
  109. package/dist/rules.js +5 -5
  110. package/dist/rules.js.map +1 -1
  111. package/dist/schemas.d.ts +200 -0
  112. package/dist/schemas.d.ts.map +1 -1
  113. package/dist/schemas.js +96 -0
  114. package/dist/schemas.js.map +1 -1
  115. package/dist/vocabulary.d.ts +2 -2
  116. package/dist/vocabulary.d.ts.map +1 -1
  117. package/dist/vocabulary.js +3 -0
  118. package/dist/vocabulary.js.map +1 -1
  119. package/docs/specs/cli.md +26 -15
  120. package/docs/specs/rest.md +31 -9
  121. package/package.json +3 -2
  122. package/skills/working-the-board/SKILL.md +11 -4
  123. package/src/api.ts +36 -0
  124. package/src/cli/cli.ts +616 -54
  125. package/src/cli/credentials.ts +276 -14
  126. package/src/cli/directory.ts +43 -6
  127. package/src/cli/format.ts +146 -5
  128. package/src/cli/help.ts +51 -25
  129. package/src/cli/install.ts +111 -0
  130. package/src/cli/main.ts +23 -3
  131. package/src/cli/notices.ts +48 -0
  132. package/src/client.ts +91 -6
  133. package/src/compat.ts +173 -0
  134. package/src/components/board.tsx +154 -8
  135. package/src/components/calendar.tsx +41 -1
  136. package/src/components/comments.tsx +20 -10
  137. package/src/components/event-feed.tsx +4 -0
  138. package/src/components/goals.tsx +234 -88
  139. package/src/components/markdown.tsx +19 -0
  140. package/src/components/pictures.tsx +389 -0
  141. package/src/components/projects.tsx +68 -0
  142. package/src/components/roadmap.tsx +242 -0
  143. package/src/components/task-view.tsx +43 -2
  144. package/src/components/tokens.tsx +38 -1
  145. package/src/console.tsx +278 -4
  146. package/src/data.ts +73 -2
  147. package/src/i18n/en.ts +77 -2
  148. package/src/i18n/ko.ts +77 -2
  149. package/src/index.ts +35 -0
  150. package/src/router.ts +70 -11
  151. package/src/rules.ts +5 -5
  152. package/src/schemas.ts +117 -0
  153. package/src/styles/console.css +486 -3
  154. package/src/vocabulary.ts +3 -0
@@ -0,0 +1,389 @@
1
+ import {
2
+ type KeyboardEvent,
3
+ type MouseEvent,
4
+ type PointerEvent,
5
+ type ReactNode,
6
+ useCallback,
7
+ useEffect,
8
+ useRef,
9
+ useState,
10
+ type WheelEvent,
11
+ } from "react";
12
+ import { createPortal } from "react-dom";
13
+ import { useWords } from "../i18n/index.js";
14
+ import { Button, cx, formatBytes } from "./ui.js";
15
+
16
+ // A picture put on the board is seen small where it is and large over the page: a thumbnail no
17
+ // taller than a few lines of text, and an overlay that keeps the page under it, zooms with the
18
+ // wheel, the buttons and two fingers, moves with a drag, and closes with Escape, its button or a
19
+ // click beside the picture, giving the focus back to the thumbnail that opened it. The console
20
+ // keeps a file as it was handed in and makes no smaller copy of it, so the thumbnail and the
21
+ // overlay show the same bytes, loaded once and lazily. Takes its data as props and nothing from
22
+ // the network; the overlay is rendered into the document's body, over everything.
23
+
24
+ /** A picture the pages can show: where it is read, what it is called, and how large the file is. */
25
+ export interface Picture {
26
+ readonly src: string;
27
+ readonly alt: string;
28
+ readonly name: string;
29
+ readonly size?: number | undefined;
30
+ }
31
+
32
+ /** How the picture is seen in the overlay: its scale, and where its centre is, in pixels from the stage's centre. */
33
+ export interface PictureView {
34
+ readonly scale: number;
35
+ readonly x: number;
36
+ readonly y: number;
37
+ }
38
+
39
+ /** How far the picture zooms: from fitting the stage to eight times that. */
40
+ export const MIN_SCALE = 1;
41
+ export const MAX_SCALE = 8;
42
+ /** What one press of a zoom button does to the scale. */
43
+ export const ZOOM_STEP = 1.25;
44
+ /** How far a pointer travels before a press is a drag and no longer a click. */
45
+ const DRAG_SLACK = 4;
46
+
47
+ /** The picture as it first appears: fitted to the stage, centred. */
48
+ export const FITTED: PictureView = { scale: 1, x: 0, y: 0 };
49
+
50
+ export function clampScale(scale: number): number {
51
+ if (!Number.isFinite(scale)) {
52
+ return MIN_SCALE;
53
+ }
54
+ return Math.min(MAX_SCALE, Math.max(MIN_SCALE, scale));
55
+ }
56
+
57
+ /**
58
+ * The view after zooming by `factor` around a point of the stage, so that what was under the
59
+ * pointer stays under it. Zooming back to the fitted scale puts the picture back in the centre.
60
+ */
61
+ export function zoomAround(
62
+ view: PictureView,
63
+ factor: number,
64
+ point: { readonly x: number; readonly y: number },
65
+ ): PictureView {
66
+ const scale = clampScale(view.scale * factor);
67
+ if (scale === view.scale) {
68
+ return view;
69
+ }
70
+ if (scale === MIN_SCALE) {
71
+ return FITTED;
72
+ }
73
+ const ratio = scale / view.scale;
74
+ return {
75
+ scale,
76
+ x: point.x - (point.x - view.x) * ratio,
77
+ y: point.y - (point.y - view.y) * ratio,
78
+ };
79
+ }
80
+
81
+ /** What a notch of the wheel does to the scale: in for a wheel rolled up, out for one rolled down, a step per hundred units. */
82
+ export function wheelFactor(deltaY: number): number {
83
+ if (!Number.isFinite(deltaY) || deltaY === 0) {
84
+ return 1;
85
+ }
86
+ return ZOOM_STEP ** (-deltaY / 100);
87
+ }
88
+
89
+ /** The view moved by a drag; a fitted picture stays where it is. */
90
+ export function panBy(view: PictureView, dx: number, dy: number): PictureView {
91
+ if (view.scale === MIN_SCALE) {
92
+ return view;
93
+ }
94
+ return { ...view, x: view.x + dx, y: view.y + dy };
95
+ }
96
+
97
+ export interface PictureThumbProps {
98
+ readonly picture: Picture;
99
+ /** Called with the picture and the thumbnail that was pressed, so that the focus comes back to it. */
100
+ readonly onOpen: (picture: Picture, opener: HTMLElement) => void;
101
+ readonly className?: string | undefined;
102
+ }
103
+
104
+ /** A picture seen small where it is, which opens large over the page. */
105
+ export function PictureThumb(props: PictureThumbProps) {
106
+ const words = useWords().pictures;
107
+ const { picture } = props;
108
+ return (
109
+ <button
110
+ type="button"
111
+ className={cx("sc-picture-thumb", props.className)}
112
+ aria-label={words.open(picture.name)}
113
+ onClick={(event) => props.onOpen(picture, event.currentTarget)}
114
+ >
115
+ <img src={picture.src} alt={picture.alt} loading="lazy" decoding="async" draggable={false} />
116
+ </button>
117
+ );
118
+ }
119
+
120
+ export interface PictureOverlayProps {
121
+ readonly picture: Picture;
122
+ readonly onClose: () => void;
123
+ }
124
+
125
+ interface Point {
126
+ readonly x: number;
127
+ readonly y: number;
128
+ }
129
+
130
+ const distance = (a: Point, b: Point): number => Math.hypot(a.x - b.x, a.y - b.y);
131
+ const midpoint = (a: Point, b: Point): Point => ({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 });
132
+
133
+ /**
134
+ * The picture large, over the page: a dialog that holds the focus, with the ways to zoom, to move
135
+ * and to close. Rendered where it is given; `Lightbox` puts it over the document.
136
+ */
137
+ export function PictureOverlay(props: PictureOverlayProps) {
138
+ const words = useWords().pictures;
139
+ const { picture, onClose } = props;
140
+ const [view, setView] = useState<PictureView>(FITTED);
141
+ const [dragging, setDragging] = useState(false);
142
+ const dialog = useRef<HTMLDivElement>(null);
143
+ const stage = useRef<HTMLDivElement>(null);
144
+ const closeButton = useRef<HTMLButtonElement>(null);
145
+ /** The pointers on the stage, by id, where each was last seen. */
146
+ const pointers = useRef(new Map<number, Point>());
147
+ /** Whether the press under way has travelled: then its release is no click. */
148
+ const moved = useRef(false);
149
+
150
+ // The dialog takes the focus when it opens, and the page under it stops scrolling.
151
+ useEffect(() => {
152
+ closeButton.current?.focus();
153
+ const { body } = document;
154
+ const before = body.style.overflow;
155
+ body.style.overflow = "hidden";
156
+ return () => {
157
+ body.style.overflow = before;
158
+ };
159
+ }, []);
160
+
161
+ /** A point of the stage, in pixels from its centre. */
162
+ const pointOf = useCallback((event: { clientX: number; clientY: number }): Point => {
163
+ const rect = stage.current?.getBoundingClientRect();
164
+ if (rect === undefined) {
165
+ return { x: 0, y: 0 };
166
+ }
167
+ return {
168
+ x: event.clientX - (rect.left + rect.width / 2),
169
+ y: event.clientY - (rect.top + rect.height / 2),
170
+ };
171
+ }, []);
172
+
173
+ const zoom = (factor: number, point: Point = { x: 0, y: 0 }) =>
174
+ setView((current) => zoomAround(current, factor, point));
175
+
176
+ const onWheel = (event: WheelEvent<HTMLDivElement>) => {
177
+ zoom(wheelFactor(event.deltaY), pointOf(event));
178
+ };
179
+
180
+ const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {
181
+ event.currentTarget.setPointerCapture(event.pointerId);
182
+ pointers.current.set(event.pointerId, pointOf(event));
183
+ moved.current = false;
184
+ setDragging(true);
185
+ };
186
+
187
+ const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {
188
+ const before = pointers.current.get(event.pointerId);
189
+ if (before === undefined) {
190
+ return;
191
+ }
192
+ const now = pointOf(event);
193
+ pointers.current.set(event.pointerId, now);
194
+ if (!moved.current && distance(before, now) > DRAG_SLACK) {
195
+ moved.current = true;
196
+ }
197
+ const others = [...pointers.current.entries()].filter(([id]) => id !== event.pointerId);
198
+ const other = others[0]?.[1];
199
+ if (other !== undefined) {
200
+ // Two fingers: the picture zooms by how much they moved apart, around the point between them,
201
+ // and moves with that point.
202
+ const factor =
203
+ distance(before, other) === 0 ? 1 : distance(now, other) / distance(before, other);
204
+ const from = midpoint(before, other);
205
+ const to = midpoint(now, other);
206
+ setView((current) => zoomAround(panBy(current, to.x - from.x, to.y - from.y), factor, to));
207
+ return;
208
+ }
209
+ setView((current) => panBy(current, now.x - before.x, now.y - before.y));
210
+ };
211
+
212
+ const onPointerUp = (event: PointerEvent<HTMLDivElement>) => {
213
+ pointers.current.delete(event.pointerId);
214
+ if (pointers.current.size === 0) {
215
+ setDragging(false);
216
+ }
217
+ };
218
+
219
+ // A click beside the picture closes the overlay; a drag that ends there does not.
220
+ const onBackdropClick = (event: MouseEvent<HTMLDivElement>) => {
221
+ const beside = event.target === event.currentTarget || event.target === stage.current;
222
+ if (beside && !moved.current) {
223
+ onClose();
224
+ }
225
+ };
226
+
227
+ const onDoubleClick = (event: MouseEvent<HTMLDivElement>) => {
228
+ const point = pointOf(event);
229
+ setView((current) => (current.scale > MIN_SCALE ? FITTED : zoomAround(current, 2.5, point)));
230
+ };
231
+
232
+ // Escape closes; the keys zoom; Tab stays within the dialog.
233
+ const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
234
+ if (event.key === "Escape") {
235
+ event.preventDefault();
236
+ onClose();
237
+ return;
238
+ }
239
+ if (event.key === "+" || event.key === "=") {
240
+ zoom(ZOOM_STEP);
241
+ } else if (event.key === "-") {
242
+ zoom(1 / ZOOM_STEP);
243
+ } else if (event.key === "0") {
244
+ setView(FITTED);
245
+ } else if (event.key === "Tab" && dialog.current !== null) {
246
+ const focusable = [
247
+ ...dialog.current.querySelectorAll<HTMLElement>("a[href], button:not(:disabled)"),
248
+ ];
249
+ const first = focusable[0];
250
+ const last = focusable[focusable.length - 1];
251
+ if (first === undefined || last === undefined) {
252
+ return;
253
+ }
254
+ if (event.shiftKey && document.activeElement === first) {
255
+ event.preventDefault();
256
+ last.focus();
257
+ } else if (!event.shiftKey && document.activeElement === last) {
258
+ event.preventDefault();
259
+ first.focus();
260
+ }
261
+ }
262
+ };
263
+
264
+ const transform = `translate(${view.x}px, ${view.y}px) scale(${view.scale})`;
265
+ return (
266
+ <div
267
+ ref={dialog}
268
+ className="sc-overlay"
269
+ role="dialog"
270
+ aria-modal="true"
271
+ aria-label={words.label(picture.name)}
272
+ onKeyDown={onKeyDown}
273
+ onClick={onBackdropClick}
274
+ >
275
+ <div className="sc-overlay-bar">
276
+ <a
277
+ className="sc-overlay-name"
278
+ href={picture.src}
279
+ target="_blank"
280
+ rel="noopener noreferrer nofollow ugc"
281
+ title={words.file}
282
+ >
283
+ {picture.name}
284
+ {picture.size !== undefined && (
285
+ <span className="sc-muted">, {formatBytes(picture.size)}</span>
286
+ )}
287
+ </a>
288
+ <div className="sc-overlay-actions">
289
+ <Button
290
+ variant="ghost"
291
+ size="sm"
292
+ aria-label={words.zoomOut}
293
+ onClick={() => zoom(1 / ZOOM_STEP)}
294
+ >
295
+ −
296
+ </Button>
297
+ <Button
298
+ variant="ghost"
299
+ size="sm"
300
+ aria-label={words.zoomIn}
301
+ onClick={() => zoom(ZOOM_STEP)}
302
+ >
303
+ +
304
+ </Button>
305
+ <Button variant="ghost" size="sm" aria-label={words.fit} onClick={() => setView(FITTED)}>
306
+ {Math.round(view.scale * 100)}%
307
+ </Button>
308
+ <button
309
+ ref={closeButton}
310
+ type="button"
311
+ className="sc-button sc-button-ghost sc-button-sm"
312
+ aria-label={words.close}
313
+ onClick={onClose}
314
+ >
315
+ ×
316
+ </button>
317
+ </div>
318
+ </div>
319
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: the stage is where the picture is dragged and pinched; the keys are read on the dialog around it */}
320
+ <div
321
+ ref={stage}
322
+ className="sc-overlay-stage"
323
+ role="presentation"
324
+ data-dragging={dragging ? "true" : undefined}
325
+ onWheel={onWheel}
326
+ onPointerDown={onPointerDown}
327
+ onPointerMove={onPointerMove}
328
+ onPointerUp={onPointerUp}
329
+ onPointerCancel={onPointerUp}
330
+ onDoubleClick={onDoubleClick}
331
+ >
332
+ <img
333
+ className="sc-overlay-picture"
334
+ src={picture.src}
335
+ alt={picture.alt}
336
+ decoding="async"
337
+ draggable={false}
338
+ style={{ transform }}
339
+ />
340
+ </div>
341
+ <p className="sc-overlay-hint">{words.hint}</p>
342
+ </div>
343
+ );
344
+ }
345
+
346
+ export interface LightboxProps {
347
+ /** The picture shown large, or nothing. */
348
+ readonly picture: Picture | null;
349
+ readonly onClose: () => void;
350
+ }
351
+
352
+ /** The overlay over the whole document, while a picture is shown; nothing otherwise, and nothing where there is no document. */
353
+ export function Lightbox(props: LightboxProps): ReactNode {
354
+ if (props.picture === null || typeof document === "undefined") {
355
+ return null;
356
+ }
357
+ return createPortal(
358
+ <PictureOverlay picture={props.picture} onClose={props.onClose} />,
359
+ document.body,
360
+ );
361
+ }
362
+
363
+ export interface PictureState {
364
+ /** The picture shown large, or nothing. */
365
+ readonly shown: Picture | null;
366
+ readonly open: (picture: Picture, opener: HTMLElement) => void;
367
+ /** Closes the overlay and gives the focus back to what opened it. */
368
+ readonly close: () => void;
369
+ }
370
+
371
+ /** Which picture is shown large, and the ways to show one and to close it. */
372
+ export function usePicture(): PictureState {
373
+ const [shown, setShown] = useState<Picture | null>(null);
374
+ const opener = useRef<HTMLElement | null>(null);
375
+ const open = useCallback((picture: Picture, element: HTMLElement) => {
376
+ opener.current = element;
377
+ setShown(picture);
378
+ }, []);
379
+ const close = useCallback(() => {
380
+ const back = opener.current;
381
+ opener.current = null;
382
+ setShown(null);
383
+ // The focus returns once the overlay is gone, so that it is not taken back by it.
384
+ if (back !== null) {
385
+ setTimeout(() => back.focus(), 0);
386
+ }
387
+ }, []);
388
+ return { shown, open, close };
389
+ }
@@ -518,3 +518,71 @@ export function MemberList(props: MemberListProps) {
518
518
  </div>
519
519
  );
520
520
  }
521
+
522
+ export interface PackTokenFormProps {
523
+ /** Whether the project holds a pack token now, so that it can be taken away. */
524
+ readonly set: boolean;
525
+ readonly busy?: boolean | undefined;
526
+ readonly error?: string | undefined;
527
+ /** Called with the token as typed or pasted; it travels to the console once and is shown nowhere after. */
528
+ readonly onSubmit: (token: string) => void;
529
+ readonly onRemove?: (() => void) | undefined;
530
+ readonly onCancel?: (() => void) | undefined;
531
+ }
532
+
533
+ /** Where an owner gives the project the pack token it reads a private pack with (ADR-0028), or takes it away. */
534
+ export function PackTokenForm(props: PackTokenFormProps) {
535
+ const words = useWords();
536
+ const pack = words.projects.packToken;
537
+ const [token, setToken] = useState("");
538
+ const typed = token.trim();
539
+ const ready = typed.length >= 8 && !/\s/.test(typed);
540
+ return (
541
+ <form
542
+ className="sc-form"
543
+ aria-label={pack.aria}
544
+ onSubmit={(event: FormEvent) => {
545
+ event.preventDefault();
546
+ if (ready && props.busy !== true) {
547
+ props.onSubmit(typed);
548
+ setToken("");
549
+ }
550
+ }}
551
+ >
552
+ <p className="sc-field-hint">{pack.hint}</p>
553
+ <label className="sc-field">
554
+ <span className="sc-field-label">{pack.label}</span>
555
+ <input
556
+ className="sc-input"
557
+ type="password"
558
+ autoComplete="off"
559
+ spellCheck={false}
560
+ value={token}
561
+ placeholder={pack.placeholder}
562
+ disabled={props.busy === true}
563
+ onChange={(event) => setToken(event.target.value)}
564
+ />
565
+ </label>
566
+ {props.error !== undefined && (
567
+ <p className="sc-form-error" role="alert">
568
+ {props.error}
569
+ </p>
570
+ )}
571
+ <div className="sc-form-actions">
572
+ <Button type="submit" variant="primary" disabled={!ready || props.busy === true}>
573
+ {pack.set}
574
+ </Button>
575
+ {props.set && props.onRemove !== undefined && (
576
+ <Button variant="danger" disabled={props.busy === true} onClick={props.onRemove}>
577
+ {pack.remove}
578
+ </Button>
579
+ )}
580
+ {props.onCancel !== undefined && (
581
+ <Button variant="ghost" onClick={props.onCancel}>
582
+ {words.common.cancel}
583
+ </Button>
584
+ )}
585
+ </div>
586
+ </form>
587
+ );
588
+ }
@@ -0,0 +1,242 @@
1
+ import type { RestCategory, RestGoal } from "../api.js";
2
+ import { addMonths, daysBetween, formatMonth, monthOf, today as todayOf } from "../dates.js";
3
+ import { useLanguage, useWords } from "../i18n/index.js";
4
+ import { cx, GoalStateBadge } from "./ui.js";
5
+
6
+ // The roadmap as months (ADR-0026): the months as columns, and each goal planned in days a bar
7
+ // from its start to its target in the colour of its category, filled as far as its tasks are
8
+ // done, with today a line down the months. A goal with one day alone sits on it as a point; one
9
+ // with no day is named below the months, since a plan without days is on none of them. Takes
10
+ // its data as props and nothing from the network; the months shown are the address's (ADR-0010).
11
+
12
+ /** How many months the roadmap shows at once when the address names no other span. */
13
+ export const DEFAULT_TIMELINE_MONTHS = 6;
14
+
15
+ export interface TimelineBar {
16
+ readonly goal: RestGoal;
17
+ /** Where the bar begins and ends, as shares of the whole span, 0 to 1. */
18
+ readonly from: number;
19
+ readonly to: number;
20
+ /** Whether the goal begins within the span, and whether it ends within it. */
21
+ readonly begins: boolean;
22
+ readonly ends: boolean;
23
+ /** Whether the goal has one day only: a point on it, not a bar. */
24
+ readonly point: boolean;
25
+ }
26
+
27
+ /** The first day of a month, as a day. */
28
+ const firstDayOf = (month: string): string => `${month}-01`;
29
+
30
+ /**
31
+ * The bars of the goals that touch the span of `months` from `from` on, cut to it: from the
32
+ * goal's start, or the span's first day when it began before, to its target day whole, or the
33
+ * span's end when it ends after. A goal with a start or a target alone sits on that day; a goal
34
+ * with neither is left out.
35
+ */
36
+ export function timelineBars(
37
+ goals: readonly RestGoal[],
38
+ from: string,
39
+ months: number,
40
+ ): TimelineBar[] {
41
+ const first = firstDayOf(from);
42
+ const end = firstDayOf(addMonths(from, months));
43
+ const total = daysBetween(first, end);
44
+ if (total <= 0) {
45
+ return [];
46
+ }
47
+ return goals.flatMap((goal) => {
48
+ const startsOn = goal.startsOn ?? goal.dueOn;
49
+ const dueOn = goal.dueOn ?? goal.startsOn;
50
+ if (startsOn === null || dueOn === null || dueOn < first || startsOn >= end) {
51
+ return [];
52
+ }
53
+ const begins = startsOn >= first;
54
+ const ends = dueOn < end;
55
+ const fromDay = begins ? daysBetween(first, startsOn) : 0;
56
+ const toDay = ends ? daysBetween(first, dueOn) + 1 : total;
57
+ return [
58
+ {
59
+ goal,
60
+ from: fromDay / total,
61
+ to: toDay / total,
62
+ begins,
63
+ ends,
64
+ point: startsOn === dueOn,
65
+ },
66
+ ];
67
+ });
68
+ }
69
+
70
+ export interface GoalTimelineProps {
71
+ readonly goals: readonly RestGoal[];
72
+ /** The first month shown: `2026-10`. */
73
+ readonly from: string;
74
+ /** How many months are shown; `DEFAULT_TIMELINE_MONTHS` when left out. */
75
+ readonly months?: number | undefined;
76
+ /** The day to count from; the browser's today when left out. */
77
+ readonly today?: string | undefined;
78
+ /** The workspace's categories, for the legend to say what the colours mean. */
79
+ readonly categories?: readonly RestCategory[] | undefined;
80
+ readonly goalHref: (goal: RestGoal) => string;
81
+ /** Where the roadmap is from another month on. */
82
+ readonly fromHref: (month: string) => string;
83
+ }
84
+
85
+ const percent = (share: number): string => `${(share * 100).toFixed(3)}%`;
86
+
87
+ export function GoalTimeline(props: GoalTimelineProps) {
88
+ const words = useWords().roadmap;
89
+ const { tag } = useLanguage();
90
+ const months = Math.max(1, props.months ?? DEFAULT_TIMELINE_MONTHS);
91
+ const now = props.today ?? todayOf();
92
+ const first = firstDayOf(props.from);
93
+ const end = firstDayOf(addMonths(props.from, months));
94
+ const total = daysBetween(first, end);
95
+ const columns = Array.from({ length: months }, (_, index) => addMonths(props.from, index));
96
+ const shares = columns.map(
97
+ (month) => daysBetween(firstDayOf(month), firstDayOf(addMonths(month, 1))) / total,
98
+ );
99
+ const bars = timelineBars(props.goals, props.from, months);
100
+ const todayAt = now >= first && now < end ? daysBetween(first, now) / total : undefined;
101
+ const undated = props.goals.filter((goal) => goal.startsOn === null && goal.dueOn === null);
102
+ const doneShare = (goal: RestGoal): number => {
103
+ const counts = goal.taskCounts;
104
+ const all = counts.idea + counts.ready + counts.in_progress + counts.in_review + counts.done;
105
+ return all === 0 ? 0 : counts.done / all;
106
+ };
107
+ return (
108
+ <div className="sc-timeline">
109
+ <div className="sc-timeline-head">
110
+ <h2 className="sc-timeline-title">{words.timeline.title}</h2>
111
+ <nav className="sc-calendar-nav" aria-label={words.timeline.title}>
112
+ <a
113
+ className="sc-button sc-button-ghost sc-button-sm"
114
+ href={props.fromHref(addMonths(props.from, -1))}
115
+ aria-label={words.timeline.earlier}
116
+ >
117
+ {"<"}
118
+ </a>
119
+ <a className="sc-button sc-button-ghost sc-button-sm" href={props.fromHref(monthOf(now))}>
120
+ {words.timeline.thisMonth}
121
+ </a>
122
+ <a
123
+ className="sc-button sc-button-ghost sc-button-sm"
124
+ href={props.fromHref(addMonths(props.from, 1))}
125
+ aria-label={words.timeline.later}
126
+ >
127
+ {">"}
128
+ </a>
129
+ </nav>
130
+ </div>
131
+ <div className="sc-timeline-grid">
132
+ <div className="sc-timeline-row sc-timeline-months">
133
+ <div className="sc-timeline-label" aria-hidden="true" />
134
+ <div className="sc-timeline-track">
135
+ {columns.map((month, index) => (
136
+ <span
137
+ key={month}
138
+ className="sc-timeline-month"
139
+ style={{ width: percent(shares[index] ?? 0) }}
140
+ >
141
+ {formatMonth(month, tag)}
142
+ </span>
143
+ ))}
144
+ {todayAt !== undefined && (
145
+ <span
146
+ className="sc-timeline-today sc-timeline-today-mark"
147
+ style={{ left: percent(todayAt) }}
148
+ title={words.timeline.today}
149
+ />
150
+ )}
151
+ </div>
152
+ </div>
153
+ {bars.map(({ goal, from, to, begins, ends, point }) => {
154
+ const counts = goal.taskCounts;
155
+ const all =
156
+ counts.idea + counts.ready + counts.in_progress + counts.in_review + counts.done;
157
+ return (
158
+ <div key={goal.id} className={cx("sc-timeline-row", `sc-goal-${goal.state}`)}>
159
+ <div className="sc-timeline-label">
160
+ <a className="sc-timeline-goal" href={props.goalHref(goal)}>
161
+ {goal.title}
162
+ </a>
163
+ <GoalStateBadge state={goal.state} />
164
+ </div>
165
+ <div className="sc-timeline-track">
166
+ {columns.map((month, index) => (
167
+ <span
168
+ key={month}
169
+ className="sc-timeline-column"
170
+ style={{ width: percent(shares[index] ?? 0) }}
171
+ aria-hidden="true"
172
+ />
173
+ ))}
174
+ {todayAt !== undefined && (
175
+ <span
176
+ className="sc-timeline-today"
177
+ style={{ left: percent(todayAt) }}
178
+ aria-hidden="true"
179
+ />
180
+ )}
181
+ <a
182
+ className={cx(
183
+ point ? "sc-timeline-point" : "sc-timeline-bar",
184
+ `sc-category-${goal.category?.color ?? "gray"}`,
185
+ begins && "sc-timeline-bar-begins",
186
+ ends && "sc-timeline-bar-ends",
187
+ )}
188
+ style={
189
+ point
190
+ ? { left: percent(from) }
191
+ : { left: percent(from), width: percent(to - from) }
192
+ }
193
+ href={props.goalHref(goal)}
194
+ aria-label={`${goal.title}: ${words.progress(counts.done, all)}`}
195
+ >
196
+ {!point && (
197
+ <>
198
+ <span
199
+ className="sc-timeline-fill"
200
+ style={{ width: percent(doneShare(goal)) }}
201
+ aria-hidden="true"
202
+ />
203
+ <span className="sc-timeline-words">
204
+ {counts.done}/{all}
205
+ </span>
206
+ </>
207
+ )}
208
+ </a>
209
+ </div>
210
+ </div>
211
+ );
212
+ })}
213
+ </div>
214
+ {bars.length === 0 && <p className="sc-muted">{words.timeline.nothing}</p>}
215
+ {(props.categories ?? []).length > 0 && (
216
+ <dl className="sc-calendar-legend" aria-label={words.timeline.legend}>
217
+ {(props.categories ?? []).map((category) => (
218
+ <div key={category.key}>
219
+ <dt>
220
+ <span className={cx("sc-calendar-dot", `sc-category-${category.color}`)} />
221
+ </dt>
222
+ <dd>{category.name}</dd>
223
+ </div>
224
+ ))}
225
+ </dl>
226
+ )}
227
+ {undated.length > 0 && (
228
+ <p className="sc-timeline-undated">
229
+ <span className="sc-muted">{words.timeline.noDays}: </span>
230
+ {undated.map((goal, index) => (
231
+ <span key={goal.id}>
232
+ {index > 0 && ", "}
233
+ <a className="sc-timeline-goal" href={props.goalHref(goal)}>
234
+ {goal.title}
235
+ </a>
236
+ </span>
237
+ ))}
238
+ </p>
239
+ )}
240
+ </div>
241
+ );
242
+ }