@skillcdn/console 0.3.2 → 0.4.0
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 +1 -1
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +1 -1
- package/dist/api.js.map +1 -1
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +96 -7
- package/dist/cli/cli.js.map +1 -1
- package/dist/cli/format.d.ts.map +1 -1
- package/dist/cli/format.js +6 -4
- package/dist/cli/format.js.map +1 -1
- package/dist/cli/help.d.ts.map +1 -1
- package/dist/cli/help.js +48 -17
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/pack.d.ts +7 -2
- package/dist/cli/pack.d.ts.map +1 -1
- package/dist/cli/pack.js +16 -2
- package/dist/cli/pack.js.map +1 -1
- package/dist/client.d.ts +7 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +7 -1
- package/dist/client.js.map +1 -1
- package/dist/compat.d.ts +1 -1
- package/dist/compat.js +2 -2
- package/dist/components/board.d.ts +12 -1
- package/dist/components/board.d.ts.map +1 -1
- package/dist/components/board.js +52 -4
- package/dist/components/board.js.map +1 -1
- package/dist/components/calendar.d.ts +4 -0
- package/dist/components/calendar.d.ts.map +1 -1
- package/dist/components/calendar.js +61 -22
- package/dist/components/calendar.js.map +1 -1
- package/dist/components/decision-form.d.ts +12 -4
- package/dist/components/decision-form.d.ts.map +1 -1
- package/dist/components/decision-form.js +24 -6
- package/dist/components/decision-form.js.map +1 -1
- package/dist/components/decision-list.d.ts +4 -0
- package/dist/components/decision-list.d.ts.map +1 -1
- package/dist/components/decision-list.js +9 -6
- package/dist/components/decision-list.js.map +1 -1
- package/dist/components/documents.d.ts.map +1 -1
- package/dist/components/documents.js +3 -3
- package/dist/components/documents.js.map +1 -1
- package/dist/components/event-feed.d.ts.map +1 -1
- package/dist/components/event-feed.js +63 -8
- package/dist/components/event-feed.js.map +1 -1
- package/dist/components/my-decisions.d.ts +20 -0
- package/dist/components/my-decisions.d.ts.map +1 -0
- package/dist/components/my-decisions.js +36 -0
- package/dist/components/my-decisions.js.map +1 -0
- package/dist/components/people.d.ts.map +1 -1
- package/dist/components/people.js +1 -1
- package/dist/components/people.js.map +1 -1
- package/dist/components/projects.d.ts.map +1 -1
- package/dist/components/projects.js +3 -3
- package/dist/components/projects.js.map +1 -1
- package/dist/components/roadmap.js +1 -1
- package/dist/components/roadmap.js.map +1 -1
- package/dist/components/runs.d.ts.map +1 -1
- package/dist/components/runs.js +1 -1
- package/dist/components/runs.js.map +1 -1
- package/dist/components/shell.d.ts.map +1 -1
- package/dist/components/shell.js +36 -1
- package/dist/components/shell.js.map +1 -1
- package/dist/components/task-list.js +1 -1
- package/dist/components/task-list.js.map +1 -1
- package/dist/components/task-view.d.ts.map +1 -1
- package/dist/components/task-view.js +2 -2
- package/dist/components/task-view.js.map +1 -1
- package/dist/components/ui.d.ts +10 -0
- package/dist/components/ui.d.ts.map +1 -1
- package/dist/components/ui.js +15 -11
- package/dist/components/ui.js.map +1 -1
- package/dist/console.css +1642 -433
- package/dist/console.d.ts.map +1 -1
- package/dist/console.js +70 -7
- package/dist/console.js.map +1 -1
- package/dist/data.d.ts +11 -1
- package/dist/data.d.ts.map +1 -1
- package/dist/data.js +2 -0
- package/dist/data.js.map +1 -1
- package/dist/i18n/en.d.ts +34 -0
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +36 -2
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/ko.d.ts.map +1 -1
- package/dist/i18n/ko.js +37 -3
- package/dist/i18n/ko.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/router.d.ts +9 -1
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +12 -0
- package/dist/router.js.map +1 -1
- package/dist/routes.d.ts +3 -1
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +3 -1
- package/dist/routes.js.map +1 -1
- package/dist/rules.js +1 -1
- package/dist/rules.js.map +1 -1
- package/dist/schemas.d.ts +242 -3
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +18 -4
- package/dist/schemas.js.map +1 -1
- package/docs/specs/cli.md +6 -5
- package/docs/specs/rest.md +11 -6
- package/package.json +2 -2
- package/skills/building-a-console/SKILL.md +3 -3
- package/skills/building-a-console/references/the-default-console.md +4 -4
- package/skills/building-a-console/references/the-look-from-a-website.md +5 -5
- package/skills/working-the-board/SKILL.md +6 -4
- package/skills/working-the-board/references/terms.md +1 -1
- package/src/api.ts +2 -0
- package/src/cli/cli.ts +123 -7
- package/src/cli/format.ts +11 -4
- package/src/cli/help.ts +49 -16
- package/src/cli/pack.ts +17 -1
- package/src/client.ts +21 -1
- package/src/compat.ts +2 -2
- package/src/components/board.tsx +114 -21
- package/src/components/calendar.tsx +207 -102
- package/src/components/decision-form.tsx +71 -23
- package/src/components/decision-list.tsx +30 -3
- package/src/components/documents.tsx +27 -11
- package/src/components/event-feed.tsx +116 -33
- package/src/components/my-decisions.tsx +138 -0
- package/src/components/people.tsx +3 -1
- package/src/components/projects.tsx +7 -5
- package/src/components/roadmap.tsx +1 -1
- package/src/components/runs.tsx +5 -1
- package/src/components/shell.tsx +36 -1
- package/src/components/task-list.tsx +1 -1
- package/src/components/task-view.tsx +48 -47
- package/src/components/ui.tsx +64 -11
- package/src/console.tsx +129 -27
- package/src/data.ts +17 -0
- package/src/i18n/en.ts +38 -2
- package/src/i18n/ko.ts +39 -3
- package/src/index.ts +3 -0
- package/src/router.ts +18 -1
- package/src/routes.ts +3 -1
- package/src/rules.ts +1 -1
- package/src/schemas.ts +20 -4
- package/src/styles/console.css +1642 -433
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
weekOf,
|
|
26
26
|
} from "../dates.js";
|
|
27
27
|
import { useLanguage, useWords } from "../i18n/index.js";
|
|
28
|
+
import { TaskList } from "./task-list.js";
|
|
28
29
|
import { CategoryBadge, cx, PersonChip, personName, StateBadge, Time, Workers } from "./ui.js";
|
|
29
30
|
|
|
30
31
|
// The calendar of a project (ADR-0019): the month as a grid, or one week on its own. A task
|
|
@@ -140,6 +141,17 @@ function onDays(tasks: readonly RestTask[]): ReadonlyMap<string, readonly RestTa
|
|
|
140
141
|
return on;
|
|
141
142
|
}
|
|
142
143
|
|
|
144
|
+
/** The tasks of one day: those on it alone and those lying across it, the open ones first. */
|
|
145
|
+
export function tasksOn(day: string, tasks: readonly RestTask[]): RestTask[] {
|
|
146
|
+
return tasks
|
|
147
|
+
.filter((task) =>
|
|
148
|
+
spans(task)
|
|
149
|
+
? (task.startsOn ?? day) <= day && day <= (task.dueOn ?? day)
|
|
150
|
+
: (task.dueOn ?? task.startsOn) === day,
|
|
151
|
+
)
|
|
152
|
+
.sort((a, b) => Number(isOpen(b)) - Number(isOpen(a)) || a.number - b.number);
|
|
153
|
+
}
|
|
154
|
+
|
|
143
155
|
/** How a task reads: struck through once it is over, and marked once its due day is past. */
|
|
144
156
|
const tone = (task: RestTask, now: string): string =>
|
|
145
157
|
cx(
|
|
@@ -343,6 +355,10 @@ interface WeekRowProps {
|
|
|
343
355
|
readonly onPoint: (task: RestTask, day: string, item: HTMLElement) => void;
|
|
344
356
|
/** It left. */
|
|
345
357
|
readonly onLeave: () => void;
|
|
358
|
+
/** The day whose tasks are listed under the month, where a day can be picked. */
|
|
359
|
+
readonly picked?: string | null | undefined;
|
|
360
|
+
/** A day's number was pressed: given, each number is a button. */
|
|
361
|
+
readonly onPick?: ((day: string) => void) | undefined;
|
|
346
362
|
}
|
|
347
363
|
|
|
348
364
|
/** The goals that aim at each day, by day: those with a target day, still planned or worked towards. */
|
|
@@ -365,9 +381,26 @@ function goalsOnDays(goals: readonly RestGoal[]): ReadonlyMap<string, readonly R
|
|
|
365
381
|
/** How many days a bar has in the week, for its title to run along: a custom property the stylesheet reads. */
|
|
366
382
|
const spanStyle = (span: number): CSSProperties => ({ "--sc-span": span }) as CSSProperties;
|
|
367
383
|
|
|
384
|
+
/** How many of a day's own tasks a cell shows before the way to the rest. */
|
|
385
|
+
export const DAY_SHOWN = 3;
|
|
386
|
+
|
|
368
387
|
/** One week as a row of cells: the bars across the days in their lanes, then the tasks on each day. */
|
|
369
388
|
function WeekRow(props: WeekRowProps) {
|
|
370
389
|
const { week, segments, now } = props;
|
|
390
|
+
const words = useWords().calendar;
|
|
391
|
+
const { tag } = useLanguage();
|
|
392
|
+
// The days a person opened in this week, each showing all of its tasks.
|
|
393
|
+
const [opened, setOpened] = useState<ReadonlySet<string>>(() => new Set());
|
|
394
|
+
const toggle = (day: string) =>
|
|
395
|
+
setOpened((before) => {
|
|
396
|
+
const after = new Set(before);
|
|
397
|
+
if (after.has(day)) {
|
|
398
|
+
after.delete(day);
|
|
399
|
+
} else {
|
|
400
|
+
after.add(day);
|
|
401
|
+
}
|
|
402
|
+
return after;
|
|
403
|
+
});
|
|
371
404
|
const pointing = (task: RestTask, day: string) => ({
|
|
372
405
|
onMouseEnter: (event: SyntheticEvent<HTMLElement>) =>
|
|
373
406
|
props.onPoint(task, day, event.currentTarget),
|
|
@@ -384,110 +417,154 @@ function WeekRow(props: WeekRowProps) {
|
|
|
384
417
|
const lanes = segments.reduce((most, segment) => Math.max(most, segment.lane + 1), 0);
|
|
385
418
|
return (
|
|
386
419
|
<tr>
|
|
387
|
-
{week.map((cell, column) =>
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
{Array.from({ length: lanes }, (_, lane) => {
|
|
413
|
-
const segment = segments.find(
|
|
414
|
-
(candidate) =>
|
|
415
|
-
candidate.lane === lane && candidate.from <= column && column <= candidate.to,
|
|
416
|
-
);
|
|
417
|
-
if (segment === undefined) {
|
|
418
|
-
// The lane is someone else's on this day: the bars below keep their height.
|
|
419
|
-
// biome-ignore lint/suspicious/noArrayIndexKey: an empty lane is nothing but its position, and the lanes of a week never reorder
|
|
420
|
-
return <span key={lane} className="sc-calendar-lane" aria-hidden="true" />;
|
|
421
|
-
}
|
|
422
|
-
const { task } = segment;
|
|
423
|
-
// The first piece carries the title; with more days in the week, the title runs along
|
|
424
|
-
// the bar as far as they reach, over the pieces after it, instead of stopping at the
|
|
425
|
-
// first day's edge.
|
|
426
|
-
const span = segment.to - segment.from + 1;
|
|
427
|
-
const spanning = segment.from === column && span > 1;
|
|
428
|
-
// Every piece of the task under the pointer lights together, in every week it crosses.
|
|
429
|
-
const pointed = props.pointed !== null && props.pointed.task.id === task.id;
|
|
430
|
-
return (
|
|
431
|
-
<a
|
|
432
|
-
key={task.id}
|
|
433
|
-
className={cx(
|
|
434
|
-
"sc-calendar-bar",
|
|
435
|
-
`sc-category-${task.category?.color ?? "gray"}`,
|
|
436
|
-
segment.from === column && segment.begins && "sc-calendar-bar-begins",
|
|
437
|
-
segment.to === column && segment.ends && "sc-calendar-bar-ends",
|
|
438
|
-
spanning && "sc-calendar-bar-spanning",
|
|
439
|
-
pointed && "sc-calendar-bar-pointed",
|
|
440
|
-
tone(task, now),
|
|
441
|
-
)}
|
|
442
|
-
style={spanning ? spanStyle(span) : undefined}
|
|
443
|
-
href={props.taskHref(task)}
|
|
444
|
-
{...pointing(task, cell.day)}
|
|
420
|
+
{week.map((cell, column) => {
|
|
421
|
+
const number = Number(cell.day.slice(8));
|
|
422
|
+
// Everything on the day: its own tasks and the bars crossing it, for a phone's count.
|
|
423
|
+
const total =
|
|
424
|
+
(props.on.get(cell.day)?.length ?? 0) +
|
|
425
|
+
segments.filter((segment) => segment.from <= column && column <= segment.to).length;
|
|
426
|
+
return (
|
|
427
|
+
<td
|
|
428
|
+
key={cell.day}
|
|
429
|
+
className={cx(
|
|
430
|
+
"sc-calendar-cell",
|
|
431
|
+
!cell.inMonth && "sc-calendar-cell-outside",
|
|
432
|
+
cell.day === now && "sc-calendar-cell-today",
|
|
433
|
+
props.picked === cell.day && "sc-calendar-cell-picked",
|
|
434
|
+
)}
|
|
435
|
+
>
|
|
436
|
+
{props.onPick === undefined ? (
|
|
437
|
+
<span className="sc-calendar-day">{number}</span>
|
|
438
|
+
) : (
|
|
439
|
+
<button
|
|
440
|
+
type="button"
|
|
441
|
+
className="sc-calendar-day"
|
|
442
|
+
aria-pressed={props.picked === cell.day}
|
|
443
|
+
aria-label={words.onDay(formatDay(cell.day, tag))}
|
|
444
|
+
onClick={() => props.onPick?.(cell.day)}
|
|
445
445
|
>
|
|
446
|
-
{
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
446
|
+
{number}
|
|
447
|
+
</button>
|
|
448
|
+
)}
|
|
449
|
+
{props.goalHref !== undefined &&
|
|
450
|
+
(props.goalsOn?.get(cell.day) ?? []).map((goal) => (
|
|
451
|
+
<a
|
|
452
|
+
key={goal.id}
|
|
453
|
+
className={cx(
|
|
454
|
+
"sc-calendar-goal",
|
|
455
|
+
`sc-category-${goal.category?.color ?? "gray"}`,
|
|
456
|
+
goal.state === "done" && "sc-calendar-over",
|
|
457
|
+
)}
|
|
458
|
+
href={props.goalHref?.(goal)}
|
|
459
|
+
>
|
|
460
|
+
<span className="sc-calendar-flag" aria-hidden="true" />
|
|
461
|
+
<span className="sc-calendar-task-title">{goal.title}</span>
|
|
462
|
+
</a>
|
|
463
|
+
))}
|
|
464
|
+
{Array.from({ length: lanes }, (_, lane) => {
|
|
465
|
+
const segment = segments.find(
|
|
466
|
+
(candidate) =>
|
|
467
|
+
candidate.lane === lane && candidate.from <= column && column <= candidate.to,
|
|
468
|
+
);
|
|
469
|
+
if (segment === undefined) {
|
|
470
|
+
// The lane is someone else's on this day: the bars below keep their height.
|
|
471
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: an empty lane is nothing but its position, and the lanes of a week never reorder
|
|
472
|
+
return <span key={lane} className="sc-calendar-lane" aria-hidden="true" />;
|
|
473
|
+
}
|
|
474
|
+
const { task } = segment;
|
|
475
|
+
// The first piece carries the title; with more days in the week, the title runs along
|
|
476
|
+
// the bar as far as they reach, over the pieces after it, instead of stopping at the
|
|
477
|
+
// first day's edge.
|
|
478
|
+
const span = segment.to - segment.from + 1;
|
|
479
|
+
const spanning = segment.from === column && span > 1;
|
|
480
|
+
// Every piece of the task under the pointer lights together, in every week it crosses.
|
|
481
|
+
const pointed = props.pointed !== null && props.pointed.task.id === task.id;
|
|
482
|
+
return (
|
|
483
|
+
<a
|
|
484
|
+
key={task.id}
|
|
485
|
+
className={cx(
|
|
486
|
+
"sc-calendar-bar",
|
|
487
|
+
`sc-category-${task.category?.color ?? "gray"}`,
|
|
488
|
+
segment.from === column && segment.begins && "sc-calendar-bar-begins",
|
|
489
|
+
segment.to === column && segment.ends && "sc-calendar-bar-ends",
|
|
490
|
+
spanning && "sc-calendar-bar-spanning",
|
|
491
|
+
pointed && "sc-calendar-bar-pointed",
|
|
492
|
+
tone(task, now),
|
|
493
|
+
)}
|
|
494
|
+
style={spanning ? spanStyle(span) : undefined}
|
|
495
|
+
href={props.taskHref(task)}
|
|
496
|
+
{...pointing(task, cell.day)}
|
|
497
|
+
>
|
|
498
|
+
{segment.from === column && (
|
|
499
|
+
<>
|
|
500
|
+
{props.markOf?.(task)}
|
|
501
|
+
{who(task)}
|
|
502
|
+
<span
|
|
503
|
+
className={cx(
|
|
504
|
+
"sc-calendar-task-title",
|
|
505
|
+
spanning && "sc-calendar-task-title-spanning",
|
|
506
|
+
)}
|
|
507
|
+
>
|
|
508
|
+
{task.title}
|
|
509
|
+
</span>
|
|
510
|
+
</>
|
|
511
|
+
)}
|
|
512
|
+
{tipIn(task, cell.day)}
|
|
513
|
+
</a>
|
|
514
|
+
);
|
|
515
|
+
})}
|
|
516
|
+
{(() => {
|
|
517
|
+
const all = props.on.get(cell.day) ?? [];
|
|
518
|
+
const open = opened.has(cell.day);
|
|
519
|
+
const shown = open || all.length <= DAY_SHOWN + 1 ? all : all.slice(0, DAY_SHOWN);
|
|
520
|
+
return (
|
|
521
|
+
<>
|
|
522
|
+
{shown.map((task) => {
|
|
523
|
+
const mark = props.markOf?.(task);
|
|
524
|
+
return (
|
|
525
|
+
<a
|
|
526
|
+
key={task.id}
|
|
527
|
+
className={cx("sc-calendar-task", tone(task, now))}
|
|
528
|
+
href={props.taskHref(task)}
|
|
529
|
+
{...pointing(task, cell.day)}
|
|
530
|
+
>
|
|
531
|
+
{mark === undefined || mark === null ? (
|
|
532
|
+
<span
|
|
533
|
+
className={cx(
|
|
534
|
+
"sc-calendar-dot",
|
|
535
|
+
`sc-category-${task.category?.color ?? "gray"}`,
|
|
536
|
+
)}
|
|
537
|
+
/>
|
|
538
|
+
) : (
|
|
539
|
+
mark
|
|
540
|
+
)}
|
|
541
|
+
{who(task)}
|
|
542
|
+
<span className="sc-calendar-task-title">{task.title}</span>
|
|
543
|
+
{tipIn(task, cell.day)}
|
|
544
|
+
</a>
|
|
545
|
+
);
|
|
546
|
+
})}
|
|
547
|
+
{all.length > DAY_SHOWN + 1 && (
|
|
548
|
+
<button
|
|
549
|
+
type="button"
|
|
550
|
+
className="sc-calendar-more"
|
|
551
|
+
aria-expanded={open}
|
|
552
|
+
onClick={() => toggle(cell.day)}
|
|
455
553
|
>
|
|
456
|
-
{
|
|
457
|
-
</
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
href={props.taskHref(task)}
|
|
471
|
-
{...pointing(task, cell.day)}
|
|
472
|
-
>
|
|
473
|
-
{mark === undefined || mark === null ? (
|
|
474
|
-
<span
|
|
475
|
-
className={cx(
|
|
476
|
-
"sc-calendar-dot",
|
|
477
|
-
`sc-category-${task.category?.color ?? "gray"}`,
|
|
478
|
-
)}
|
|
479
|
-
/>
|
|
480
|
-
) : (
|
|
481
|
-
mark
|
|
482
|
-
)}
|
|
483
|
-
{who(task)}
|
|
484
|
-
<span className="sc-calendar-task-title">{task.title}</span>
|
|
485
|
-
{tipIn(task, cell.day)}
|
|
486
|
-
</a>
|
|
487
|
-
);
|
|
488
|
-
})}
|
|
489
|
-
</td>
|
|
490
|
-
))}
|
|
554
|
+
{open ? words.fewer : words.more(all.length - DAY_SHOWN)}
|
|
555
|
+
</button>
|
|
556
|
+
)}
|
|
557
|
+
</>
|
|
558
|
+
);
|
|
559
|
+
})()}
|
|
560
|
+
{total > DAY_SHOWN && (
|
|
561
|
+
<span className="sc-calendar-count" aria-hidden="true">
|
|
562
|
+
{total}
|
|
563
|
+
</span>
|
|
564
|
+
)}
|
|
565
|
+
</td>
|
|
566
|
+
);
|
|
567
|
+
})}
|
|
491
568
|
</tr>
|
|
492
569
|
);
|
|
493
570
|
}
|
|
@@ -496,9 +573,12 @@ export function Calendar(props: CalendarProps) {
|
|
|
496
573
|
const words = useWords().calendar;
|
|
497
574
|
const { tag } = useLanguage();
|
|
498
575
|
const [pointed, point, leave] = usePointed();
|
|
576
|
+
// The day whose tasks are listed under the month, once its number is pressed.
|
|
577
|
+
const [picked, setPicked] = useState<string | null>(null);
|
|
499
578
|
const now = props.today ?? todayOf();
|
|
500
579
|
const firstDay = firstDayOfWeek(deviceLanguage(tag), props.weekStart);
|
|
501
580
|
const weeks = monthGrid(props.month, firstDay);
|
|
581
|
+
const onPicked = picked === null ? [] : tasksOn(picked, props.tasks);
|
|
502
582
|
const on = onDays(props.tasks);
|
|
503
583
|
const goalsOn = props.goalHref === undefined ? undefined : goalsOnDays(props.goals ?? []);
|
|
504
584
|
const inMonth = (day: string) => monthOf(day) === props.month;
|
|
@@ -563,10 +643,35 @@ export function Calendar(props: CalendarProps) {
|
|
|
563
643
|
onPoint={point}
|
|
564
644
|
onLeave={leave}
|
|
565
645
|
people
|
|
646
|
+
picked={picked}
|
|
647
|
+
onPick={(day) => setPicked((before) => (before === day ? null : day))}
|
|
566
648
|
/>
|
|
567
649
|
))}
|
|
568
650
|
</tbody>
|
|
569
651
|
</table>
|
|
652
|
+
{picked !== null && (
|
|
653
|
+
<section className="sc-calendar-day-list" aria-label={words.onDay(formatDay(picked, tag))}>
|
|
654
|
+
<div className="sc-calendar-day-list-head">
|
|
655
|
+
<h3 className="sc-calendar-day-list-title">
|
|
656
|
+
{formatDay(picked, tag)} <span className="sc-column-count">{onPicked.length}</span>
|
|
657
|
+
</h3>
|
|
658
|
+
<button
|
|
659
|
+
type="button"
|
|
660
|
+
className="sc-button sc-button-ghost sc-button-sm"
|
|
661
|
+
onClick={() => setPicked(null)}
|
|
662
|
+
>
|
|
663
|
+
{words.close}
|
|
664
|
+
</button>
|
|
665
|
+
</div>
|
|
666
|
+
<TaskList
|
|
667
|
+
tasks={onPicked}
|
|
668
|
+
taskHref={props.taskHref}
|
|
669
|
+
today={now}
|
|
670
|
+
showState
|
|
671
|
+
empty={<p className="sc-muted">{words.nothingOnDay}</p>}
|
|
672
|
+
/>
|
|
673
|
+
</section>
|
|
674
|
+
)}
|
|
570
675
|
{(props.categories ?? []).length > 0 && (
|
|
571
676
|
<dl className="sc-calendar-legend" aria-label={words.legend.title}>
|
|
572
677
|
{(props.categories ?? []).map((category) => (
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type FormEvent, useState } from "react";
|
|
1
|
+
import { type FormEvent, useMemo, useState } from "react";
|
|
2
2
|
import {
|
|
3
3
|
MAX_BODY_LENGTH,
|
|
4
4
|
MAX_OPTION_LABEL_LENGTH,
|
|
@@ -6,31 +6,60 @@ import {
|
|
|
6
6
|
MAX_QUESTION_LENGTH,
|
|
7
7
|
MIN_OPTIONS,
|
|
8
8
|
type RestDecisionInput,
|
|
9
|
+
type RestPerson,
|
|
9
10
|
type RestTask,
|
|
10
11
|
} from "../api.js";
|
|
11
12
|
import { useWords } from "../i18n/index.js";
|
|
12
|
-
import {
|
|
13
|
+
import { Picker, type PickerSearch } from "./picker.js";
|
|
14
|
+
import { type TaskSearch, taskLabel } from "./task-form.js";
|
|
15
|
+
import { Button, PersonChip, personName } from "./ui.js";
|
|
13
16
|
|
|
14
|
-
// Raising a decision: the question, what a person needs to know,
|
|
17
|
+
// Raising a decision: the question, what a person needs to know, the options to choose from,
|
|
18
|
+
// and who is to answer (ADR-0042): the person raising it, unless they name another of the
|
|
19
|
+
// project. The task it is about is the page's, when raised from a task; otherwise it is found
|
|
20
|
+
// by searching the board a page at a time (ADR-0037), never picked from the board whole.
|
|
15
21
|
|
|
16
22
|
export interface DecisionFormProps {
|
|
17
|
-
/** The
|
|
18
|
-
readonly tasks: readonly RestTask[];
|
|
19
|
-
/** The task it is about from the start, when raised from a task's page. */
|
|
23
|
+
/** The task it is about from the start, when raised from a task's page; the field is not shown then. */
|
|
20
24
|
readonly taskId?: string | undefined;
|
|
25
|
+
/** What words find among the tasks of the project, a page at a time; left out, the decision is about no task in particular. */
|
|
26
|
+
readonly findTasks?: TaskSearch | undefined;
|
|
27
|
+
/** Who may be named to answer: the people of the project; left out, the field is not shown and the raiser answers. */
|
|
28
|
+
readonly deciders?: readonly RestPerson[] | undefined;
|
|
29
|
+
/** Whoever raises it: who answers unless another is named. */
|
|
30
|
+
readonly me?: RestPerson | undefined;
|
|
21
31
|
readonly busy?: boolean | undefined;
|
|
22
32
|
readonly error?: string | undefined;
|
|
23
33
|
readonly onSubmit: (input: RestDecisionInput) => void;
|
|
24
34
|
readonly onCancel?: (() => void) | undefined;
|
|
25
35
|
}
|
|
26
36
|
|
|
37
|
+
/** The people of the project found by the words typed, a page at a time, as a picker searches. */
|
|
38
|
+
export function peopleSearch(people: readonly RestPerson[]): PickerSearch<RestPerson> {
|
|
39
|
+
return async (query, page) => {
|
|
40
|
+
const words = query.trim().toLowerCase();
|
|
41
|
+
const found = people.filter(
|
|
42
|
+
(person) =>
|
|
43
|
+
words.length === 0 ||
|
|
44
|
+
personName(person).toLowerCase().includes(words) ||
|
|
45
|
+
person.login.toLowerCase().includes(words),
|
|
46
|
+
);
|
|
47
|
+
return {
|
|
48
|
+
items: found.slice(page.offset, page.offset + page.limit),
|
|
49
|
+
more: found.length > page.offset + page.limit,
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
27
54
|
export function DecisionForm(props: DecisionFormProps) {
|
|
28
55
|
const words = useWords();
|
|
29
56
|
const form = words.decisions.form;
|
|
30
57
|
const [question, setQuestion] = useState("");
|
|
31
58
|
const [body, setBody] = useState("");
|
|
32
|
-
const [
|
|
59
|
+
const [about, setAbout] = useState<RestTask | undefined>(undefined);
|
|
33
60
|
const [options, setOptions] = useState<readonly string[]>(["", ""]);
|
|
61
|
+
const [decider, setDecider] = useState<RestPerson | undefined>(props.me);
|
|
62
|
+
const findDeciders = useMemo(() => peopleSearch(props.deciders ?? []), [props.deciders]);
|
|
34
63
|
|
|
35
64
|
const labels = options.map((option) => option.trim()).filter((option) => option.length > 0);
|
|
36
65
|
const ready = question.trim().length > 0 && labels.length >= MIN_OPTIONS;
|
|
@@ -41,7 +70,8 @@ export function DecisionForm(props: DecisionFormProps) {
|
|
|
41
70
|
question: question.trim(),
|
|
42
71
|
body,
|
|
43
72
|
options: labels.map((label) => ({ label })),
|
|
44
|
-
taskId: taskId
|
|
73
|
+
taskId: props.taskId ?? about?.id ?? null,
|
|
74
|
+
...(decider === undefined || decider.id === props.me?.id ? {} : { deciderId: decider.id }),
|
|
45
75
|
});
|
|
46
76
|
};
|
|
47
77
|
|
|
@@ -68,22 +98,40 @@ export function DecisionForm(props: DecisionFormProps) {
|
|
|
68
98
|
onChange={(event) => setBody(event.target.value)}
|
|
69
99
|
/>
|
|
70
100
|
</label>
|
|
71
|
-
{props.taskId === undefined && props.
|
|
72
|
-
<
|
|
101
|
+
{props.taskId === undefined && props.findTasks !== undefined && (
|
|
102
|
+
<div className="sc-field">
|
|
73
103
|
<span className="sc-field-label">{form.about}</span>
|
|
74
|
-
<
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
{
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
104
|
+
<Picker
|
|
105
|
+
value={about}
|
|
106
|
+
search={props.findTasks}
|
|
107
|
+
keyOf={(task) => task.id}
|
|
108
|
+
render={taskLabel}
|
|
109
|
+
label={form.about}
|
|
110
|
+
placeholder={form.noTask}
|
|
111
|
+
disabled={props.busy}
|
|
112
|
+
onChange={setAbout}
|
|
113
|
+
/>
|
|
114
|
+
</div>
|
|
115
|
+
)}
|
|
116
|
+
{props.deciders !== undefined && (
|
|
117
|
+
<div className="sc-field">
|
|
118
|
+
<span className="sc-field-label">{form.decider}</span>
|
|
119
|
+
<Picker
|
|
120
|
+
value={decider}
|
|
121
|
+
search={findDeciders}
|
|
122
|
+
keyOf={(person) => person.id}
|
|
123
|
+
render={(person) => (
|
|
124
|
+
<>
|
|
125
|
+
<PersonChip person={person} size="sm" />
|
|
126
|
+
{person.id === props.me?.id && <span className="sc-muted"> {form.me}</span>}
|
|
127
|
+
</>
|
|
128
|
+
)}
|
|
129
|
+
label={form.decider}
|
|
130
|
+
placeholder={form.deciderPlaceholder}
|
|
131
|
+
disabled={props.busy}
|
|
132
|
+
onChange={(person) => setDecider(person ?? props.me)}
|
|
133
|
+
/>
|
|
134
|
+
</div>
|
|
87
135
|
)}
|
|
88
136
|
<fieldset className="sc-field sc-options">
|
|
89
137
|
<legend className="sc-field-label">{form.options}</legend>
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
import { useWords } from "../i18n/index.js";
|
|
13
13
|
import { Markdown } from "./markdown.js";
|
|
14
14
|
import { Pager, pageOf, RECORD_PAGE_SIZE } from "./paged.js";
|
|
15
|
-
import { Badge, Button, cx, PersonChip, Time } from "./ui.js";
|
|
15
|
+
import { Badge, Button, cx, PersonChip, personName, Time } from "./ui.js";
|
|
16
16
|
|
|
17
17
|
// The decisions: the ones that wait, each a card with the way to answer it, since they are what
|
|
18
18
|
// a person came for; and the ones answered, the record, a row each, since they are read back
|
|
@@ -29,6 +29,8 @@ export interface DecisionCardProps {
|
|
|
29
29
|
readonly docHref?: ((path: string) => string) | undefined;
|
|
30
30
|
/** Where the decision's own page is; given, the question leads to it. */
|
|
31
31
|
readonly href?: string | undefined;
|
|
32
|
+
/** The project the decision is in, named where a page shows the decisions of several. */
|
|
33
|
+
readonly project?: React.ReactNode;
|
|
32
34
|
/** Called with the person's answer. Left out, a waiting decision cannot be answered here. */
|
|
33
35
|
readonly onAnswer?: ((decision: RestDecision, input: RestAnswerInput) => void) | undefined;
|
|
34
36
|
/** Called to grow the record, with what followed. Left out, it cannot be written here. */
|
|
@@ -36,7 +38,10 @@ export interface DecisionCardProps {
|
|
|
36
38
|
readonly busy?: boolean | undefined;
|
|
37
39
|
}
|
|
38
40
|
|
|
39
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* Who asked, and when: the person, through their agent when a run asked; and whom the decision
|
|
43
|
+
* is for, where that is someone else (ADR-0042).
|
|
44
|
+
*/
|
|
40
45
|
function Asked(props: { readonly decision: RestDecision; readonly day?: boolean }) {
|
|
41
46
|
const words = useWords();
|
|
42
47
|
const { decision } = props;
|
|
@@ -47,6 +52,20 @@ function Asked(props: { readonly decision: RestDecision; readonly day?: boolean
|
|
|
47
52
|
? words.decisions.asked
|
|
48
53
|
: words.decisions.askedThrough(agentLabel(decision.run.agent))}{" "}
|
|
49
54
|
<Time iso={decision.createdAt} precision={props.day === true ? "day" : "minute"} />
|
|
55
|
+
{decision.decider.id !== decision.raisedBy.id && (
|
|
56
|
+
<span className="sc-decision-for">
|
|
57
|
+
<span className="sc-visually-hidden">
|
|
58
|
+
{words.decisions.peopleLabel(
|
|
59
|
+
personName(decision.raisedBy),
|
|
60
|
+
personName(decision.decider),
|
|
61
|
+
)}
|
|
62
|
+
</span>
|
|
63
|
+
<span className="sc-decision-arrow" aria-hidden="true">
|
|
64
|
+
→
|
|
65
|
+
</span>{" "}
|
|
66
|
+
<PersonChip person={decision.decider} />
|
|
67
|
+
</span>
|
|
68
|
+
)}
|
|
50
69
|
</>
|
|
51
70
|
);
|
|
52
71
|
}
|
|
@@ -264,6 +283,7 @@ export function DecisionCard(props: DecisionCardProps) {
|
|
|
264
283
|
)}
|
|
265
284
|
</>
|
|
266
285
|
)}
|
|
286
|
+
{props.project}
|
|
267
287
|
</p>
|
|
268
288
|
</header>
|
|
269
289
|
{decision.body.length > 0 && (
|
|
@@ -288,7 +308,9 @@ export function DecisionCard(props: DecisionCardProps) {
|
|
|
288
308
|
</p>
|
|
289
309
|
</div>
|
|
290
310
|
) : props.onAnswer === undefined ? (
|
|
291
|
-
<p className="sc-decision-waiting">
|
|
311
|
+
<p className="sc-decision-waiting">
|
|
312
|
+
{words.decisions.waitingFor(personName(decision.decider))}
|
|
313
|
+
</p>
|
|
292
314
|
) : (
|
|
293
315
|
<AnswerForm decision={decision} onAnswer={props.onAnswer} busy={props.busy} />
|
|
294
316
|
)}
|
|
@@ -334,6 +356,8 @@ export interface DecisionLogProps {
|
|
|
334
356
|
readonly page?: number | undefined;
|
|
335
357
|
/** Where each page of the record is, so that a page has an address; left out, the pages turn in place. */
|
|
336
358
|
readonly pageHref?: ((page: number) => string) | undefined;
|
|
359
|
+
/** The project a decision is in, named where the record holds the decisions of several. */
|
|
360
|
+
readonly projectOf?: ((decision: RestDecision) => React.ReactNode) | undefined;
|
|
337
361
|
}
|
|
338
362
|
|
|
339
363
|
/** One row of the record: what was asked, by whom and when; what was answered, with the word given, by whom and when. */
|
|
@@ -343,6 +367,7 @@ function RecordRow(props: {
|
|
|
343
367
|
readonly taskHref: ((task: RestTask) => string) | undefined;
|
|
344
368
|
readonly href: string | undefined;
|
|
345
369
|
readonly docHref: ((path: string) => string) | undefined;
|
|
370
|
+
readonly project?: React.ReactNode;
|
|
346
371
|
}) {
|
|
347
372
|
const words = useWords();
|
|
348
373
|
const { decision, task } = props;
|
|
@@ -367,6 +392,7 @@ function RecordRow(props: {
|
|
|
367
392
|
</p>
|
|
368
393
|
<p className="sc-decision-record-meta">
|
|
369
394
|
<Asked decision={decision} day />
|
|
395
|
+
{props.project}
|
|
370
396
|
</p>
|
|
371
397
|
</div>
|
|
372
398
|
{answer !== null && (
|
|
@@ -412,6 +438,7 @@ export function DecisionLog(props: DecisionLogProps) {
|
|
|
412
438
|
taskHref={props.taskHref}
|
|
413
439
|
href={props.decisionHref?.(decision)}
|
|
414
440
|
docHref={props.docHref}
|
|
441
|
+
project={props.projectOf?.(decision)}
|
|
415
442
|
/>
|
|
416
443
|
))}
|
|
417
444
|
</ol>
|