@skillcdn/console 0.3.1 → 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 +4 -4
- package/dist/api.d.ts +4 -4
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +4 -4
- package/dist/api.js.map +1 -1
- package/dist/cli/cli.d.ts +4 -0
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +201 -25
- package/dist/cli/cli.js.map +1 -1
- package/dist/cli/format.d.ts.map +1 -1
- package/dist/cli/format.js +15 -11
- package/dist/cli/format.js.map +1 -1
- package/dist/cli/help.d.ts.map +1 -1
- package/dist/cli/help.js +59 -26
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/main.js +1 -0
- package/dist/cli/main.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 +20 -10
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +17 -3
- package/dist/client.js.map +1 -1
- package/dist/compat.d.ts +28 -6
- package/dist/compat.d.ts.map +1 -1
- package/dist/compat.js +55 -14
- package/dist/compat.js.map +1 -1
- package/dist/components/board.d.ts +12 -1
- package/dist/components/board.d.ts.map +1 -1
- package/dist/components/board.js +57 -7
- package/dist/components/board.js.map +1 -1
- package/dist/components/calendar.d.ts +17 -7
- package/dist/components/calendar.d.ts.map +1 -1
- package/dist/components/calendar.js +89 -38
- 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/picker.d.ts +39 -0
- package/dist/components/picker.d.ts.map +1 -0
- package/dist/components/picker.js +155 -0
- package/dist/components/picker.js.map +1 -0
- 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-form.d.ts +18 -4
- package/dist/components/task-form.d.ts.map +1 -1
- package/dist/components/task-form.js +56 -13
- package/dist/components/task-form.js.map +1 -1
- package/dist/components/task-list.js +2 -2
- package/dist/components/task-list.js.map +1 -1
- package/dist/components/task-view.d.ts +7 -1
- package/dist/components/task-view.d.ts.map +1 -1
- package/dist/components/task-view.js +7 -6
- package/dist/components/task-view.js.map +1 -1
- package/dist/components/ui.d.ts +17 -0
- package/dist/components/ui.d.ts.map +1 -1
- package/dist/components/ui.js +26 -11
- package/dist/components/ui.js.map +1 -1
- package/dist/components/work.d.ts.map +1 -1
- package/dist/components/work.js +5 -3
- package/dist/components/work.js.map +1 -1
- package/dist/console.css +1794 -433
- package/dist/console.d.ts.map +1 -1
- package/dist/console.js +93 -10
- package/dist/console.js.map +1 -1
- package/dist/data.d.ts +21 -1
- package/dist/data.d.ts.map +1 -1
- package/dist/data.js +24 -1
- package/dist/data.js.map +1 -1
- package/dist/i18n/en.d.ts +45 -3
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +52 -10
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/ko.d.ts.map +1 -1
- package/dist/i18n/ko.js +54 -12
- package/dist/i18n/ko.js.map +1 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -3
- package/dist/index.js.map +1 -1
- package/dist/limits.d.ts +2 -0
- package/dist/limits.d.ts.map +1 -1
- package/dist/limits.js +2 -0
- package/dist/limits.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 +252 -12
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +27 -10
- package/dist/schemas.js.map +1 -1
- package/dist/vocabulary.d.ts +2 -0
- package/dist/vocabulary.d.ts.map +1 -1
- package/dist/vocabulary.js +2 -0
- package/dist/vocabulary.js.map +1 -1
- package/docs/specs/cli.md +14 -13
- package/docs/specs/rest.md +24 -19
- package/package.json +2 -2
- package/skills/building-a-console/SKILL.md +4 -4
- 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/deploying-a-console/SKILL.md +2 -2
- package/skills/deploying-a-console/references/resources.md +1 -1
- package/skills/working-the-board/SKILL.md +8 -6
- package/skills/working-the-board/references/terms.md +3 -3
- package/src/api.ts +7 -0
- package/src/cli/cli.ts +254 -25
- package/src/cli/format.ts +22 -11
- package/src/cli/help.ts +63 -26
- package/src/cli/main.ts +1 -0
- package/src/cli/pack.ts +17 -1
- package/src/client.ts +44 -11
- package/src/compat.ts +63 -14
- package/src/components/board.tsx +124 -25
- package/src/components/calendar.tsx +258 -117
- 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/picker.tsx +297 -0
- 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-form.tsx +149 -63
- package/src/components/task-list.tsx +3 -3
- package/src/components/task-view.tsx +71 -56
- package/src/components/ui.tsx +89 -11
- package/src/components/work.tsx +6 -4
- package/src/console.tsx +157 -29
- package/src/data.ts +61 -1
- package/src/i18n/en.ts +54 -10
- package/src/i18n/ko.ts +56 -12
- package/src/index.ts +19 -1
- package/src/limits.ts +2 -0
- package/src/router.ts +18 -1
- package/src/routes.ts +3 -1
- package/src/rules.ts +1 -1
- package/src/schemas.ts +29 -9
- package/src/styles/console.css +1794 -433
- package/src/vocabulary.ts +3 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type ReactNode, type SyntheticEvent, useState } from "react";
|
|
1
|
+
import { type CSSProperties, type ReactNode, type SyntheticEvent, useState } from "react";
|
|
2
2
|
import {
|
|
3
3
|
OPEN_TASK_STATES,
|
|
4
4
|
type RestCategory,
|
|
@@ -25,7 +25,8 @@ import {
|
|
|
25
25
|
weekOf,
|
|
26
26
|
} from "../dates.js";
|
|
27
27
|
import { useLanguage, useWords } from "../i18n/index.js";
|
|
28
|
-
import {
|
|
28
|
+
import { TaskList } from "./task-list.js";
|
|
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
|
|
31
32
|
// planned from a start to a due day lies across its days as a bar, in a lane of its own within
|
|
@@ -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(
|
|
@@ -262,12 +274,18 @@ export function TaskTip(props: TaskTipProps) {
|
|
|
262
274
|
</dd>
|
|
263
275
|
</div>
|
|
264
276
|
<div>
|
|
265
|
-
<dt>{words.task.
|
|
277
|
+
<dt>{words.task.workers}</dt>
|
|
266
278
|
<dd>
|
|
267
|
-
{task.
|
|
279
|
+
{task.workers.length === 0 ? (
|
|
268
280
|
<span className="sc-muted">{words.common.nobody}</span>
|
|
269
281
|
) : (
|
|
270
|
-
<
|
|
282
|
+
<ul className="sc-worker-list">
|
|
283
|
+
{task.workers.map((worker) => (
|
|
284
|
+
<li key={worker.id}>
|
|
285
|
+
<PersonChip person={worker} />
|
|
286
|
+
</li>
|
|
287
|
+
))}
|
|
288
|
+
</ul>
|
|
271
289
|
)}
|
|
272
290
|
</dd>
|
|
273
291
|
</div>
|
|
@@ -337,6 +355,10 @@ interface WeekRowProps {
|
|
|
337
355
|
readonly onPoint: (task: RestTask, day: string, item: HTMLElement) => void;
|
|
338
356
|
/** It left. */
|
|
339
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;
|
|
340
362
|
}
|
|
341
363
|
|
|
342
364
|
/** The goals that aim at each day, by day: those with a target day, still planned or worked towards. */
|
|
@@ -356,9 +378,29 @@ function goalsOnDays(goals: readonly RestGoal[]): ReadonlyMap<string, readonly R
|
|
|
356
378
|
return on;
|
|
357
379
|
}
|
|
358
380
|
|
|
381
|
+
/** How many days a bar has in the week, for its title to run along: a custom property the stylesheet reads. */
|
|
382
|
+
const spanStyle = (span: number): CSSProperties => ({ "--sc-span": span }) as CSSProperties;
|
|
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
|
+
|
|
359
387
|
/** One week as a row of cells: the bars across the days in their lanes, then the tasks on each day. */
|
|
360
388
|
function WeekRow(props: WeekRowProps) {
|
|
361
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
|
+
});
|
|
362
404
|
const pointing = (task: RestTask, day: string) => ({
|
|
363
405
|
onMouseEnter: (event: SyntheticEvent<HTMLElement>) =>
|
|
364
406
|
props.onPoint(task, day, event.currentTarget),
|
|
@@ -371,99 +413,158 @@ function WeekRow(props: WeekRowProps) {
|
|
|
371
413
|
<TaskTip task={task} goals={props.goals} place={props.pointed.place} />
|
|
372
414
|
) : null;
|
|
373
415
|
const who = (task: RestTask): ReactNode =>
|
|
374
|
-
props.people === true
|
|
375
|
-
<Avatar person={task.assignee} size="xs" />
|
|
376
|
-
) : null;
|
|
416
|
+
props.people === true ? <Workers workers={task.workers} size="xs" shown={2} /> : null;
|
|
377
417
|
const lanes = segments.reduce((most, segment) => Math.max(most, segment.lane + 1), 0);
|
|
378
418
|
return (
|
|
379
419
|
<tr>
|
|
380
|
-
{week.map((cell, column) =>
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
{Array.from({ length: lanes }, (_, lane) => {
|
|
406
|
-
const segment = segments.find(
|
|
407
|
-
(candidate) =>
|
|
408
|
-
candidate.lane === lane && candidate.from <= column && column <= candidate.to,
|
|
409
|
-
);
|
|
410
|
-
if (segment === undefined) {
|
|
411
|
-
// The lane is someone else's on this day: the bars below keep their height.
|
|
412
|
-
// biome-ignore lint/suspicious/noArrayIndexKey: an empty lane is nothing but its position, and the lanes of a week never reorder
|
|
413
|
-
return <span key={lane} className="sc-calendar-lane" aria-hidden="true" />;
|
|
414
|
-
}
|
|
415
|
-
const { task } = segment;
|
|
416
|
-
return (
|
|
417
|
-
<a
|
|
418
|
-
key={task.id}
|
|
419
|
-
className={cx(
|
|
420
|
-
"sc-calendar-bar",
|
|
421
|
-
`sc-category-${task.category?.color ?? "gray"}`,
|
|
422
|
-
segment.from === column && segment.begins && "sc-calendar-bar-begins",
|
|
423
|
-
segment.to === column && segment.ends && "sc-calendar-bar-ends",
|
|
424
|
-
tone(task, now),
|
|
425
|
-
)}
|
|
426
|
-
href={props.taskHref(task)}
|
|
427
|
-
{...pointing(task, cell.day)}
|
|
428
|
-
>
|
|
429
|
-
{segment.from === column && (
|
|
430
|
-
<>
|
|
431
|
-
{props.markOf?.(task)}
|
|
432
|
-
{who(task)}
|
|
433
|
-
<span className="sc-calendar-task-title">{task.title}</span>
|
|
434
|
-
</>
|
|
435
|
-
)}
|
|
436
|
-
{tipIn(task, cell.day)}
|
|
437
|
-
</a>
|
|
438
|
-
);
|
|
439
|
-
})}
|
|
440
|
-
{(props.on.get(cell.day) ?? []).map((task) => {
|
|
441
|
-
const mark = props.markOf?.(task);
|
|
442
|
-
return (
|
|
443
|
-
<a
|
|
444
|
-
key={task.id}
|
|
445
|
-
className={cx("sc-calendar-task", tone(task, now))}
|
|
446
|
-
href={props.taskHref(task)}
|
|
447
|
-
{...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)}
|
|
448
445
|
>
|
|
449
|
-
{
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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)}
|
|
553
|
+
>
|
|
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
|
+
})}
|
|
467
568
|
</tr>
|
|
468
569
|
);
|
|
469
570
|
}
|
|
@@ -472,9 +573,12 @@ export function Calendar(props: CalendarProps) {
|
|
|
472
573
|
const words = useWords().calendar;
|
|
473
574
|
const { tag } = useLanguage();
|
|
474
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);
|
|
475
578
|
const now = props.today ?? todayOf();
|
|
476
579
|
const firstDay = firstDayOfWeek(deviceLanguage(tag), props.weekStart);
|
|
477
580
|
const weeks = monthGrid(props.month, firstDay);
|
|
581
|
+
const onPicked = picked === null ? [] : tasksOn(picked, props.tasks);
|
|
478
582
|
const on = onDays(props.tasks);
|
|
479
583
|
const goalsOn = props.goalHref === undefined ? undefined : goalsOnDays(props.goals ?? []);
|
|
480
584
|
const inMonth = (day: string) => monthOf(day) === props.month;
|
|
@@ -539,10 +643,35 @@ export function Calendar(props: CalendarProps) {
|
|
|
539
643
|
onPoint={point}
|
|
540
644
|
onLeave={leave}
|
|
541
645
|
people
|
|
646
|
+
picked={picked}
|
|
647
|
+
onPick={(day) => setPicked((before) => (before === day ? null : day))}
|
|
542
648
|
/>
|
|
543
649
|
))}
|
|
544
650
|
</tbody>
|
|
545
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
|
+
)}
|
|
546
675
|
{(props.categories ?? []).length > 0 && (
|
|
547
676
|
<dl className="sc-calendar-legend" aria-label={words.legend.title}>
|
|
548
677
|
{(props.categories ?? []).map((category) => (
|
|
@@ -644,39 +773,44 @@ export function Week(props: WeekProps) {
|
|
|
644
773
|
|
|
645
774
|
/** One person's share of the open work, as the table of who has what says it. */
|
|
646
775
|
export interface WorkloadRow {
|
|
647
|
-
/** The person, or nobody for the tasks
|
|
776
|
+
/** The person, or nobody for the tasks with no worker. */
|
|
648
777
|
readonly person: RestPerson | undefined;
|
|
649
778
|
readonly open: number;
|
|
650
779
|
readonly inProgress: number;
|
|
651
|
-
/** Open, and due
|
|
652
|
-
readonly
|
|
780
|
+
/** Open, and due from today through the last day of this week, as the workspace begins its weeks (ADR-0039). */
|
|
781
|
+
readonly dueThisWeek: number;
|
|
653
782
|
/** Open, and past its day. */
|
|
654
783
|
readonly overdue: number;
|
|
655
784
|
}
|
|
656
785
|
|
|
657
|
-
/**
|
|
658
|
-
|
|
786
|
+
/**
|
|
787
|
+
* Who has what, from the open tasks: a task counted for each of its workers, by name, and the
|
|
788
|
+
* tasks with none last; this week is the days from today through the week's last day, the week
|
|
789
|
+
* beginning on `firstDay` (1 for Monday, 7 for Sunday).
|
|
790
|
+
*/
|
|
791
|
+
export function workloadOf(tasks: readonly RestTask[], today: string, firstDay = 1): WorkloadRow[] {
|
|
792
|
+
const weekEnd = weekOf(today, firstDay).at(-1) ?? today;
|
|
659
793
|
const rows = new Map<string, { person: RestPerson | undefined; tasks: RestTask[] }>();
|
|
660
794
|
for (const task of tasks.filter(isOpen)) {
|
|
661
|
-
const
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
row
|
|
795
|
+
const workers: readonly (RestPerson | undefined)[] =
|
|
796
|
+
task.workers.length === 0 ? [undefined] : task.workers;
|
|
797
|
+
for (const worker of workers) {
|
|
798
|
+
const key = worker?.id ?? "";
|
|
799
|
+
const row = rows.get(key);
|
|
800
|
+
if (row === undefined) {
|
|
801
|
+
rows.set(key, { person: worker, tasks: [task] });
|
|
802
|
+
} else {
|
|
803
|
+
row.tasks.push(task);
|
|
804
|
+
}
|
|
667
805
|
}
|
|
668
806
|
}
|
|
669
807
|
const count = (list: readonly RestTask[]): Omit<WorkloadRow, "person"> => ({
|
|
670
808
|
open: list.length,
|
|
671
809
|
inProgress: list.filter((task) => task.state === "in_progress" || task.state === "in_review")
|
|
672
810
|
.length,
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
}
|
|
677
|
-
const days = daysBetween(today, task.dueOn);
|
|
678
|
-
return days >= 0 && days <= 7;
|
|
679
|
-
}).length,
|
|
811
|
+
dueThisWeek: list.filter(
|
|
812
|
+
(task) => task.dueOn !== null && task.dueOn >= today && task.dueOn <= weekEnd,
|
|
813
|
+
).length,
|
|
680
814
|
overdue: list.filter((task) => task.dueOn !== null && task.dueOn < today).length,
|
|
681
815
|
});
|
|
682
816
|
return [...rows.values()]
|
|
@@ -695,14 +829,21 @@ export function workloadOf(tasks: readonly RestTask[], today: string): WorkloadR
|
|
|
695
829
|
export interface WorkloadProps {
|
|
696
830
|
readonly tasks: readonly RestTask[];
|
|
697
831
|
readonly today?: string | undefined;
|
|
698
|
-
/**
|
|
832
|
+
/** The day the week begins on, as the workspace says (ADR-0035); the language's when left out. */
|
|
833
|
+
readonly weekStart?: WeekStart | undefined;
|
|
834
|
+
/** Where the board is, narrowed to one person's tasks, or to those with no worker. */
|
|
699
835
|
readonly personHref?: ((person: RestPerson | undefined) => string) | undefined;
|
|
700
836
|
}
|
|
701
837
|
|
|
702
|
-
/** Who has what: a row per person, and what of theirs is late or due
|
|
838
|
+
/** Who has what: a row per person, and what of theirs is late or due this week. */
|
|
703
839
|
export function Workload(props: WorkloadProps) {
|
|
704
840
|
const words = useWords().calendar.workload;
|
|
705
|
-
const
|
|
841
|
+
const { tag } = useLanguage();
|
|
842
|
+
const rows = workloadOf(
|
|
843
|
+
props.tasks,
|
|
844
|
+
props.today ?? todayOf(),
|
|
845
|
+
firstDayOfWeek(deviceLanguage(tag), props.weekStart),
|
|
846
|
+
);
|
|
706
847
|
if (rows.length === 0) {
|
|
707
848
|
return null;
|
|
708
849
|
}
|
|
@@ -713,7 +854,7 @@ export function Workload(props: WorkloadProps) {
|
|
|
713
854
|
<th scope="col">{words.person}</th>
|
|
714
855
|
<th scope="col">{words.open}</th>
|
|
715
856
|
<th scope="col">{words.inProgress}</th>
|
|
716
|
-
<th scope="col">{words.
|
|
857
|
+
<th scope="col">{words.dueThisWeek}</th>
|
|
717
858
|
<th scope="col">{words.overdue}</th>
|
|
718
859
|
</tr>
|
|
719
860
|
</thead>
|
|
@@ -731,7 +872,7 @@ export function Workload(props: WorkloadProps) {
|
|
|
731
872
|
<th scope="row">{href === undefined ? who : <a href={href}>{who}</a>}</th>
|
|
732
873
|
<td>{row.open}</td>
|
|
733
874
|
<td>{row.inProgress}</td>
|
|
734
|
-
<td className={cx(row.
|
|
875
|
+
<td className={cx(row.dueThisWeek > 0 && "sc-workload-soon")}>{row.dueThisWeek}</td>
|
|
735
876
|
<td className={cx(row.overdue > 0 && "sc-workload-late")}>{row.overdue}</td>
|
|
736
877
|
</tr>
|
|
737
878
|
);
|
|
@@ -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>
|