@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
|
@@ -440,12 +440,21 @@ export function DocumentFiles(props: DocumentFilesProps) {
|
|
|
440
440
|
}
|
|
441
441
|
}}
|
|
442
442
|
>
|
|
443
|
-
<
|
|
444
|
-
className="sc-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
443
|
+
<div className="sc-doc-attach-file">
|
|
444
|
+
<label className="sc-button sc-button-secondary sc-file-choice">
|
|
445
|
+
{words.chooseFile}
|
|
446
|
+
<input
|
|
447
|
+
key={chosen === undefined ? "none" : "some"}
|
|
448
|
+
className="sc-file-input"
|
|
449
|
+
type="file"
|
|
450
|
+
aria-label={words.fileToAttach}
|
|
451
|
+
onChange={(event) => setChosen(event.target.files?.[0] ?? undefined)}
|
|
452
|
+
/>
|
|
453
|
+
</label>
|
|
454
|
+
<span className={cx("sc-doc-attach-chosen", chosen === undefined && "sc-muted")}>
|
|
455
|
+
{chosen === undefined ? words.noFileChosen : chosen.name}
|
|
456
|
+
</span>
|
|
457
|
+
</div>
|
|
449
458
|
<input
|
|
450
459
|
className="sc-input"
|
|
451
460
|
placeholder={words.labelPlaceholder}
|
|
@@ -453,7 +462,11 @@ export function DocumentFiles(props: DocumentFilesProps) {
|
|
|
453
462
|
value={label}
|
|
454
463
|
onChange={(event) => setLabel(event.target.value)}
|
|
455
464
|
/>
|
|
456
|
-
<Button
|
|
465
|
+
<Button
|
|
466
|
+
type="submit"
|
|
467
|
+
variant="primary"
|
|
468
|
+
disabled={chosen === undefined || props.busy === true}
|
|
469
|
+
>
|
|
457
470
|
{words.attach}
|
|
458
471
|
</Button>
|
|
459
472
|
</form>
|
|
@@ -555,7 +568,7 @@ export function DocumentView(props: DocumentViewProps) {
|
|
|
555
568
|
</dd>
|
|
556
569
|
</div>
|
|
557
570
|
<div>
|
|
558
|
-
<dt>{docs.
|
|
571
|
+
<dt>{docs.author}</dt>
|
|
559
572
|
<dd>
|
|
560
573
|
<Writer by={shown.author} agent={shown.agent} /> <Time iso={shown.createdAt} />
|
|
561
574
|
</dd>
|
|
@@ -641,9 +654,12 @@ export function DocumentView(props: DocumentViewProps) {
|
|
|
641
654
|
>
|
|
642
655
|
{docs.version(entry.number)}
|
|
643
656
|
</a>
|
|
644
|
-
)}
|
|
645
|
-
|
|
646
|
-
<
|
|
657
|
+
)}
|
|
658
|
+
<span className="sc-version-title">{entry.title}</span>
|
|
659
|
+
<span className="sc-version-meta">
|
|
660
|
+
<Writer by={entry.author} agent={entry.agent} />{" "}
|
|
661
|
+
<Time iso={entry.createdAt} />
|
|
662
|
+
</span>
|
|
647
663
|
</li>
|
|
648
664
|
))}
|
|
649
665
|
</ol>
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
2
|
import { agentLabel, type RestEvent } from "../api.js";
|
|
3
|
+
import { dayOf } from "../dates.js";
|
|
3
4
|
import { en, type Messages } from "../i18n/en.js";
|
|
4
|
-
import { useWords } from "../i18n/index.js";
|
|
5
|
+
import { useLanguage, useWords } from "../i18n/index.js";
|
|
5
6
|
import { ShowMore, usePaged } from "./paged.js";
|
|
6
|
-
import {
|
|
7
|
+
import { formatInstant, PersonChip } from "./ui.js";
|
|
7
8
|
|
|
8
9
|
// The feed: one line per event, newest first, as a sentence a person reads at a glance: who,
|
|
9
10
|
// as which agent when through one, and what. It takes its data as props and nothing from the
|
|
@@ -103,7 +104,9 @@ export function describeEvent(event: RestEvent, words: Messages = en): string {
|
|
|
103
104
|
? feed.event.runFinished(task)
|
|
104
105
|
: data.status === "failed"
|
|
105
106
|
? feed.event.runFailed(task)
|
|
106
|
-
:
|
|
107
|
+
: data.reason === "unheard"
|
|
108
|
+
? feed.event.runUnheard(task, data.agent ?? feed.someone)
|
|
109
|
+
: feed.event.runAbandoned(task);
|
|
107
110
|
case "document.written":
|
|
108
111
|
return feed.event.documentWritten(page, data.version);
|
|
109
112
|
case "document.archived":
|
|
@@ -123,8 +126,65 @@ export interface EventFeedProps {
|
|
|
123
126
|
readonly empty?: ReactNode;
|
|
124
127
|
}
|
|
125
128
|
|
|
129
|
+
/** The day an event fell on, by this machine's clock, as the calendar spells a day. */
|
|
130
|
+
function dayOfEvent(event: RestEvent): string {
|
|
131
|
+
const date = new Date(event.createdAt);
|
|
132
|
+
return Number.isNaN(date.getTime()) ? event.createdAt : dayOf(date);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** A day as the feed heads it: the weekday and the date, with the year where it is not this one. */
|
|
136
|
+
function formatFeedDay(day: string, language: string, now: Date = new Date()): string {
|
|
137
|
+
const date = new Date(`${day}T00:00:00`);
|
|
138
|
+
if (Number.isNaN(date.getTime())) {
|
|
139
|
+
return day;
|
|
140
|
+
}
|
|
141
|
+
return date.toLocaleDateString(language, {
|
|
142
|
+
weekday: "short",
|
|
143
|
+
month: "long",
|
|
144
|
+
day: "numeric",
|
|
145
|
+
...(date.getFullYear() === now.getFullYear() ? {} : { year: "numeric" }),
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Whether two events were done by the same person as the same agent, so the name is said once. */
|
|
150
|
+
function sameHands(a: RestEvent, b: RestEvent): boolean {
|
|
151
|
+
return (a.actor?.id ?? null) === (b.actor?.id ?? null) && a.agent === b.agent;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The hour of an event, for a row whose day is in the heading above it. */
|
|
155
|
+
function formatClock(iso: string, language: string): string {
|
|
156
|
+
const date = new Date(iso);
|
|
157
|
+
return Number.isNaN(date.getTime())
|
|
158
|
+
? iso
|
|
159
|
+
: date.toLocaleTimeString(language, { hour: "2-digit", minute: "2-digit", hourCycle: "h23" });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
type FeedRun = { readonly actor: RestEvent; readonly events: RestEvent[] };
|
|
163
|
+
type FeedDay = { readonly day: string; readonly runs: FeedRun[] };
|
|
164
|
+
|
|
165
|
+
/** The events by the day they fell on, and within a day by runs of the same hands; newest first. */
|
|
166
|
+
function groupFeed(events: readonly RestEvent[]): FeedDay[] {
|
|
167
|
+
const days: FeedDay[] = [];
|
|
168
|
+
for (const event of events) {
|
|
169
|
+
const day = dayOfEvent(event);
|
|
170
|
+
let last = days[days.length - 1];
|
|
171
|
+
if (last === undefined || last.day !== day) {
|
|
172
|
+
last = { day, runs: [] };
|
|
173
|
+
days.push(last);
|
|
174
|
+
}
|
|
175
|
+
const run = last.runs[last.runs.length - 1];
|
|
176
|
+
if (run !== undefined && sameHands(run.actor, event)) {
|
|
177
|
+
run.events.push(event);
|
|
178
|
+
} else {
|
|
179
|
+
last.runs.push({ actor: event, events: [event] });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return days;
|
|
183
|
+
}
|
|
184
|
+
|
|
126
185
|
export function EventFeed(props: EventFeedProps) {
|
|
127
186
|
const words = useWords();
|
|
187
|
+
const { tag } = useLanguage();
|
|
128
188
|
const newestFirst = [...props.events].reverse();
|
|
129
189
|
const { shown, rest, more } = usePaged(newestFirst);
|
|
130
190
|
if (props.events.length === 0) {
|
|
@@ -132,37 +192,60 @@ export function EventFeed(props: EventFeedProps) {
|
|
|
132
192
|
}
|
|
133
193
|
return (
|
|
134
194
|
<>
|
|
135
|
-
<
|
|
136
|
-
{shown.map((
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
195
|
+
<section className="sc-feed" aria-label={words.feed.list}>
|
|
196
|
+
{groupFeed(shown).map((group) => (
|
|
197
|
+
<section key={group.day} className="sc-feed-day">
|
|
198
|
+
<h3 className="sc-feed-day-title">
|
|
199
|
+
<time dateTime={group.day}>{formatFeedDay(group.day, tag)}</time>
|
|
200
|
+
</h3>
|
|
201
|
+
<ol className="sc-feed-day-list">
|
|
202
|
+
{group.runs.map((run) => (
|
|
203
|
+
<li key={run.actor.id} className="sc-feed-run">
|
|
204
|
+
<span className="sc-feed-actor">
|
|
205
|
+
{run.actor.actor === null ? (
|
|
206
|
+
<span className="sc-person-login">{words.feed.console}</span>
|
|
207
|
+
) : (
|
|
208
|
+
<PersonChip person={run.actor.actor} />
|
|
209
|
+
)}
|
|
210
|
+
{run.actor.agent !== null && (
|
|
211
|
+
<span
|
|
212
|
+
className="sc-feed-agent"
|
|
213
|
+
title={
|
|
214
|
+
agentLabel(run.actor.agent) === run.actor.agent
|
|
215
|
+
? words.feed.asTitle
|
|
216
|
+
: run.actor.agent
|
|
217
|
+
}
|
|
218
|
+
>
|
|
219
|
+
{words.feed.as(agentLabel(run.actor.agent))}
|
|
220
|
+
</span>
|
|
221
|
+
)}
|
|
155
222
|
</span>
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
223
|
+
<ol className="sc-feed-run-list">
|
|
224
|
+
{run.events.map((event) => {
|
|
225
|
+
const href = props.href?.(event);
|
|
226
|
+
const said = describeEvent(event, words);
|
|
227
|
+
return (
|
|
228
|
+
<li key={event.id} className="sc-feed-item">
|
|
229
|
+
<time
|
|
230
|
+
className="sc-time sc-feed-clock"
|
|
231
|
+
dateTime={event.createdAt}
|
|
232
|
+
title={formatInstant(event.createdAt, tag)}
|
|
233
|
+
>
|
|
234
|
+
{formatClock(event.createdAt, tag)}
|
|
235
|
+
</time>
|
|
236
|
+
<span className="sc-feed-words">
|
|
237
|
+
{href === undefined ? said : <a href={href}>{said}</a>}
|
|
238
|
+
</span>
|
|
239
|
+
</li>
|
|
240
|
+
);
|
|
241
|
+
})}
|
|
242
|
+
</ol>
|
|
243
|
+
</li>
|
|
244
|
+
))}
|
|
245
|
+
</ol>
|
|
246
|
+
</section>
|
|
247
|
+
))}
|
|
248
|
+
</section>
|
|
166
249
|
<ShowMore rest={rest} onMore={more} />
|
|
167
250
|
</>
|
|
168
251
|
);
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { RestAnswerInput, RestDecision, RestMyDecisions } from "../api.js";
|
|
3
|
+
import { useWords } from "../i18n/index.js";
|
|
4
|
+
import { DecisionCard, DecisionLog } from "./decision-list.js";
|
|
5
|
+
import { ProjectMark } from "./projects.js";
|
|
6
|
+
import { Button } from "./ui.js";
|
|
7
|
+
|
|
8
|
+
// A person's decisions (ADR-0042), across the projects they may see, laid out as a project's
|
|
9
|
+
// decisions page is: in the first column the decisions that wait for their answer, as cards
|
|
10
|
+
// answered here, and under them the decisions they asked of someone else, as cards that say
|
|
11
|
+
// whom they wait for; in the second the record, the answered ones of both kinds, newest first.
|
|
12
|
+
// Every card and row names the project, and who asked and who answers, as the cards do
|
|
13
|
+
// everywhere. Takes its data as props and nothing from the network; the page reads more when
|
|
14
|
+
// asked.
|
|
15
|
+
|
|
16
|
+
/** One kind of a person's decisions, a page at a time. */
|
|
17
|
+
export interface DecisionsPage {
|
|
18
|
+
readonly items: RestMyDecisions["items"];
|
|
19
|
+
/** Whether there is more after what is shown; `onMore` reads it. */
|
|
20
|
+
readonly more?: boolean | undefined;
|
|
21
|
+
readonly onMore?: (() => void) | undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface MyDecisionsProps {
|
|
25
|
+
/** The decisions addressed to the person: waiting and answered. */
|
|
26
|
+
readonly addressed: DecisionsPage;
|
|
27
|
+
/** The decisions the person asked of someone else: waiting and answered. */
|
|
28
|
+
readonly raised: DecisionsPage;
|
|
29
|
+
readonly decisionHref: (projectKey: string, decisionId: string) => string;
|
|
30
|
+
/** Called with the person's answer on a decision of theirs, in its project. Left out, a waiting decision is answered on its own page. */
|
|
31
|
+
readonly onAnswer?:
|
|
32
|
+
| ((projectKey: string, decision: RestDecision, input: RestAnswerInput) => void)
|
|
33
|
+
| undefined;
|
|
34
|
+
readonly busy?: boolean | undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type Item = RestMyDecisions["items"][number];
|
|
38
|
+
|
|
39
|
+
/** The project a decision is in, as the plate names one. */
|
|
40
|
+
function InProject(props: { readonly project: Item["project"] }) {
|
|
41
|
+
const words = useWords();
|
|
42
|
+
return (
|
|
43
|
+
<span className="sc-task-row-project">
|
|
44
|
+
<ProjectMark project={props.project} size="sm" /> {words.work.inProject(props.project.name)}
|
|
45
|
+
</span>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function More(props: {
|
|
50
|
+
readonly page: DecisionsPage;
|
|
51
|
+
readonly label: string;
|
|
52
|
+
readonly busy?: boolean | undefined;
|
|
53
|
+
}) {
|
|
54
|
+
const { page } = props;
|
|
55
|
+
if (page.more !== true || page.onMore === undefined) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
return (
|
|
59
|
+
<div className="sc-paged-more">
|
|
60
|
+
<Button variant="secondary" size="sm" disabled={props.busy} onClick={page.onMore}>
|
|
61
|
+
{props.label}
|
|
62
|
+
</Button>
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function MyDecisions(props: MyDecisionsProps) {
|
|
68
|
+
const words = useWords();
|
|
69
|
+
const own = words.myDecisions;
|
|
70
|
+
const { onAnswer } = props;
|
|
71
|
+
const waiting = props.addressed.items.filter(({ decision }) => decision.answer === null);
|
|
72
|
+
const asked = props.raised.items.filter(({ decision }) => decision.answer === null);
|
|
73
|
+
const record = [...props.addressed.items, ...props.raised.items]
|
|
74
|
+
.filter(({ decision }) => decision.answer !== null)
|
|
75
|
+
.sort((a, b) => (b.decision.answer?.at ?? "").localeCompare(a.decision.answer?.at ?? ""));
|
|
76
|
+
const projectOf = new Map(record.map((item) => [item.decision.id, item.project]));
|
|
77
|
+
const card = (item: Item, answerable: boolean): ReactNode => (
|
|
78
|
+
<DecisionCard
|
|
79
|
+
key={item.decision.id}
|
|
80
|
+
decision={item.decision}
|
|
81
|
+
href={props.decisionHref(item.project.key, item.decision.id)}
|
|
82
|
+
project={<InProject project={item.project} />}
|
|
83
|
+
onAnswer={
|
|
84
|
+
answerable && onAnswer !== undefined
|
|
85
|
+
? (decision, input) => onAnswer(item.project.key, decision, input)
|
|
86
|
+
: undefined
|
|
87
|
+
}
|
|
88
|
+
busy={props.busy}
|
|
89
|
+
/>
|
|
90
|
+
);
|
|
91
|
+
return (
|
|
92
|
+
<div className="sc-decisions">
|
|
93
|
+
<div className="sc-decisions-waiting">
|
|
94
|
+
<section aria-label={own.waiting}>
|
|
95
|
+
<h2 className="sc-section-title">
|
|
96
|
+
{own.waiting} <span className="sc-column-count">{waiting.length}</span>
|
|
97
|
+
</h2>
|
|
98
|
+
{waiting.length === 0 ? (
|
|
99
|
+
<p className="sc-muted">{own.noneWaiting}</p>
|
|
100
|
+
) : (
|
|
101
|
+
waiting.map((item) => card(item, true))
|
|
102
|
+
)}
|
|
103
|
+
<More page={props.addressed} label={own.more} busy={props.busy} />
|
|
104
|
+
</section>
|
|
105
|
+
<section aria-label={own.raised}>
|
|
106
|
+
<h2 className="sc-section-title">
|
|
107
|
+
{own.raised} <span className="sc-column-count">{asked.length}</span>
|
|
108
|
+
</h2>
|
|
109
|
+
{asked.length === 0 ? (
|
|
110
|
+
<p className="sc-muted">{own.noneRaised}</p>
|
|
111
|
+
) : (
|
|
112
|
+
asked.map((item) => card(item, false))
|
|
113
|
+
)}
|
|
114
|
+
<More page={props.raised} label={own.more} busy={props.busy} />
|
|
115
|
+
</section>
|
|
116
|
+
</div>
|
|
117
|
+
<section aria-label={words.decisions.record}>
|
|
118
|
+
<h2 className="sc-section-title">
|
|
119
|
+
{words.decisions.record} <span className="sc-column-count">{record.length}</span>
|
|
120
|
+
</h2>
|
|
121
|
+
{record.length === 0 ? (
|
|
122
|
+
<p className="sc-muted">{own.noneAnswered}</p>
|
|
123
|
+
) : (
|
|
124
|
+
<DecisionLog
|
|
125
|
+
decisions={record.map((item) => item.decision)}
|
|
126
|
+
decisionHref={(decision) =>
|
|
127
|
+
props.decisionHref(projectOf.get(decision.id)?.key ?? "", decision.id)
|
|
128
|
+
}
|
|
129
|
+
projectOf={(decision) => {
|
|
130
|
+
const project = projectOf.get(decision.id);
|
|
131
|
+
return project === undefined ? undefined : <InProject project={project} />;
|
|
132
|
+
}}
|
|
133
|
+
/>
|
|
134
|
+
)}
|
|
135
|
+
</section>
|
|
136
|
+
</div>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
@@ -75,9 +75,11 @@ export function PeopleList(props: PeopleListProps) {
|
|
|
75
75
|
{active.map((person) => (
|
|
76
76
|
<li key={person.id} className="sc-person-row">
|
|
77
77
|
<PersonChip person={person} size="md" />
|
|
78
|
+
{props.me?.id === person.id && (
|
|
79
|
+
<span className="sc-person-you">{words.common.you}</span>
|
|
80
|
+
)}
|
|
78
81
|
<span className="sc-person-name sc-muted">
|
|
79
82
|
{person.name === null ? "" : person.login}
|
|
80
|
-
{props.me?.id === person.id && <span> {words.common.you}</span>}
|
|
81
83
|
<span className="sc-person-note"> {people.source[person.source]}</span>
|
|
82
84
|
</span>
|
|
83
85
|
{props.onChangeRole === undefined ? (
|
|
@@ -93,7 +93,7 @@ export const VISIBILITY_LABELS: Readonly<Record<ProjectVisibility, string>> = {
|
|
|
93
93
|
export function ProjectRoleBadge(props: { readonly role: ProjectRole }) {
|
|
94
94
|
const words = useWords();
|
|
95
95
|
return (
|
|
96
|
-
<Badge tone={props.role === "owner" ? "
|
|
96
|
+
<Badge tone={props.role === "owner" ? "accent" : "neutral"}>
|
|
97
97
|
{words.vocabulary.projectRole[props.role]}
|
|
98
98
|
</Badge>
|
|
99
99
|
);
|
|
@@ -143,7 +143,9 @@ export function ProjectList(props: ProjectListProps) {
|
|
|
143
143
|
)}
|
|
144
144
|
{project.openDecisions > 0 && (
|
|
145
145
|
<p className="sc-project-meta">
|
|
146
|
-
<Badge tone="warning">
|
|
146
|
+
<Badge tone="warning" kind="count">
|
|
147
|
+
{words.decisionsWaiting(project.openDecisions)}
|
|
148
|
+
</Badge>
|
|
147
149
|
</p>
|
|
148
150
|
)}
|
|
149
151
|
</li>
|
|
@@ -444,11 +446,11 @@ export function MemberList(props: MemberListProps) {
|
|
|
444
446
|
{props.members.map((member) => (
|
|
445
447
|
<li key={member.person.id} className="sc-person-row">
|
|
446
448
|
<PersonChip person={member.person} size="md" />
|
|
449
|
+
{props.me?.id === member.person.id && (
|
|
450
|
+
<span className="sc-person-you">{words.common.you}</span>
|
|
451
|
+
)}
|
|
447
452
|
<span className="sc-person-name">
|
|
448
453
|
{member.person.name ?? ""}
|
|
449
|
-
{props.me?.id === member.person.id && (
|
|
450
|
-
<span className="sc-muted"> {words.common.you}</span>
|
|
451
|
-
)}
|
|
452
454
|
<span className="sc-muted">
|
|
453
455
|
{" "}
|
|
454
456
|
{members.since} <Time iso={member.addedAt} />
|
|
@@ -157,7 +157,7 @@ export function GoalTimeline(props: GoalTimelineProps) {
|
|
|
157
157
|
return (
|
|
158
158
|
<div key={goal.id} className={cx("sc-timeline-row", `sc-goal-${goal.state}`)}>
|
|
159
159
|
<div className="sc-timeline-label">
|
|
160
|
-
<a className="sc-timeline-goal" href={props.goalHref(goal)}>
|
|
160
|
+
<a className="sc-timeline-goal" href={props.goalHref(goal)} title={goal.title}>
|
|
161
161
|
{goal.title}
|
|
162
162
|
</a>
|
|
163
163
|
<GoalStateBadge state={goal.state} />
|
package/src/components/runs.tsx
CHANGED
|
@@ -32,7 +32,11 @@ export function RunStatusBadge(props: { readonly status: RunStatus }) {
|
|
|
32
32
|
: props.status === "failed"
|
|
33
33
|
? "danger"
|
|
34
34
|
: "neutral";
|
|
35
|
-
return
|
|
35
|
+
return (
|
|
36
|
+
<Badge tone={tone} kind="state">
|
|
37
|
+
{words.vocabulary.runStatus[props.status]}
|
|
38
|
+
</Badge>
|
|
39
|
+
);
|
|
36
40
|
}
|
|
37
41
|
|
|
38
42
|
export interface RunCardProps {
|
package/src/components/shell.tsx
CHANGED
|
@@ -186,6 +186,41 @@ function PersonMenu(props: {
|
|
|
186
186
|
|
|
187
187
|
export function Shell(props: ShellProps) {
|
|
188
188
|
const words = useWords();
|
|
189
|
+
// Where the tabs scroll sideways, the current one is brought into the bar's view.
|
|
190
|
+
const tabs = useRef<HTMLElement>(null);
|
|
191
|
+
const current = props.nav.find((item) => item.current)?.href;
|
|
192
|
+
useEffect(() => {
|
|
193
|
+
const bar = tabs.current;
|
|
194
|
+
if (current === undefined || bar === null) {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
const bring = () => {
|
|
198
|
+
const tab = bar.querySelector<HTMLElement>(".sc-tab-current");
|
|
199
|
+
if (tab === null) {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const left = tab.offsetLeft - bar.offsetLeft;
|
|
203
|
+
// The last part of the bar fades where it scrolls (the stylesheet's mask): a tab there
|
|
204
|
+
// counts as out of view.
|
|
205
|
+
const seen = bar.scrollWidth > bar.clientWidth ? bar.clientWidth * 0.85 : bar.clientWidth;
|
|
206
|
+
if (left < bar.scrollLeft || left + tab.offsetWidth > bar.scrollLeft + seen) {
|
|
207
|
+
bar.scrollLeft = left - (bar.clientWidth - tab.offsetWidth) / 2;
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
bring();
|
|
211
|
+
// The tabs grow once the page's face has loaded, and the bar narrows with the window: either
|
|
212
|
+
// may put the current tab out of view again.
|
|
213
|
+
if (typeof ResizeObserver === "undefined") {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
const watch = new ResizeObserver(bring);
|
|
217
|
+
watch.observe(bar);
|
|
218
|
+
const inner = bar.firstElementChild;
|
|
219
|
+
if (inner !== null) {
|
|
220
|
+
watch.observe(inner);
|
|
221
|
+
}
|
|
222
|
+
return () => watch.disconnect();
|
|
223
|
+
}, [current]);
|
|
189
224
|
const follow = (href: string) => (event: React.MouseEvent) => {
|
|
190
225
|
if (isPlainClick(event)) {
|
|
191
226
|
event.preventDefault();
|
|
@@ -259,7 +294,7 @@ export function Shell(props: ShellProps) {
|
|
|
259
294
|
</div>
|
|
260
295
|
</div>
|
|
261
296
|
{props.nav.length > 0 && (
|
|
262
|
-
<nav className="sc-tabs" aria-label={words.shell.pages}>
|
|
297
|
+
<nav ref={tabs} className="sc-tabs" aria-label={words.shell.pages}>
|
|
263
298
|
<div className="sc-tabs-inner">
|
|
264
299
|
{props.nav.map((item) => (
|
|
265
300
|
<a
|
|
@@ -45,7 +45,7 @@ export function TaskList(props: TaskListProps) {
|
|
|
45
45
|
{task.category !== null && <CategoryBadge category={task.category} />}
|
|
46
46
|
{props.showState === true && <StateBadge state={task.state} />}
|
|
47
47
|
{task.openDecisions > 0 && (
|
|
48
|
-
<Badge tone="warning" title={words.board.decisionsTitle}>
|
|
48
|
+
<Badge tone="warning" kind="count" title={words.board.decisionsTitle}>
|
|
49
49
|
{words.board.decisions(task.openDecisions)}
|
|
50
50
|
</Badge>
|
|
51
51
|
)}
|
|
@@ -142,26 +142,56 @@ export function TaskView(props: TaskViewProps) {
|
|
|
142
142
|
return (
|
|
143
143
|
<div className="sc-task">
|
|
144
144
|
<header className="sc-task-header">
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
<h1 className="sc-task-title">
|
|
154
|
-
<span className="sc-card-number">#{task.number}</span> {task.title}
|
|
155
|
-
</h1>
|
|
156
|
-
<div className="sc-task-badges">
|
|
157
|
-
<StateBadge state={task.state} />
|
|
158
|
-
{task.archivedAt !== null && <Badge tone="neutral">{words.task.archived}</Badge>}
|
|
159
|
-
{task.category !== null && <CategoryBadge category={task.category} />}
|
|
160
|
-
<PriorityBadge priority={task.priority} />
|
|
161
|
-
{task.dueOn !== null && (
|
|
162
|
-
<DueBadge dueOn={task.dueOn} state={task.state} today={props.today} />
|
|
145
|
+
<div className="sc-task-heading">
|
|
146
|
+
{parent !== undefined && (
|
|
147
|
+
<p className="sc-task-parent">
|
|
148
|
+
{words.task.partOf}{" "}
|
|
149
|
+
<a href={props.taskHref(parent)}>
|
|
150
|
+
#{parent.number} {parent.title}
|
|
151
|
+
</a>
|
|
152
|
+
</p>
|
|
163
153
|
)}
|
|
154
|
+
<h1 className="sc-task-title">
|
|
155
|
+
<span className="sc-card-number">#{task.number}</span> {task.title}
|
|
156
|
+
</h1>
|
|
157
|
+
<div className="sc-task-badges">
|
|
158
|
+
<StateBadge state={task.state} />
|
|
159
|
+
{task.archivedAt !== null && <Badge tone="neutral">{words.task.archived}</Badge>}
|
|
160
|
+
{task.category !== null && <CategoryBadge category={task.category} />}
|
|
161
|
+
<PriorityBadge priority={task.priority} />
|
|
162
|
+
{task.dueOn !== null && (
|
|
163
|
+
<DueBadge dueOn={task.dueOn} state={task.state} today={props.today} />
|
|
164
|
+
)}
|
|
165
|
+
</div>
|
|
164
166
|
</div>
|
|
167
|
+
{props.formHref !== undefined && (
|
|
168
|
+
<div className="sc-task-actions">
|
|
169
|
+
<a className="sc-button sc-button-secondary" href={props.formHref("edit")}>
|
|
170
|
+
{words.common.edit}
|
|
171
|
+
</a>
|
|
172
|
+
<a className="sc-button sc-button-secondary" href={props.formHref("ask")}>
|
|
173
|
+
{words.task.raiseDecision}
|
|
174
|
+
</a>
|
|
175
|
+
{props.onArchive !== undefined && task.archivedAt === null && (
|
|
176
|
+
<Button
|
|
177
|
+
variant="secondary"
|
|
178
|
+
disabled={props.busy === true}
|
|
179
|
+
onClick={() => props.onArchive?.(task)}
|
|
180
|
+
>
|
|
181
|
+
{words.task.archive}
|
|
182
|
+
</Button>
|
|
183
|
+
)}
|
|
184
|
+
{props.onRestore !== undefined && task.archivedAt !== null && (
|
|
185
|
+
<Button
|
|
186
|
+
variant="secondary"
|
|
187
|
+
disabled={props.busy === true}
|
|
188
|
+
onClick={() => props.onRestore?.(task)}
|
|
189
|
+
>
|
|
190
|
+
{words.task.restore}
|
|
191
|
+
</Button>
|
|
192
|
+
)}
|
|
193
|
+
</div>
|
|
194
|
+
)}
|
|
165
195
|
</header>
|
|
166
196
|
<div className="sc-task-body">
|
|
167
197
|
<div className="sc-task-main">
|
|
@@ -193,7 +223,6 @@ export function TaskView(props: TaskViewProps) {
|
|
|
193
223
|
<section className="sc-task-section" aria-label={words.task.raiseDecision}>
|
|
194
224
|
<h2 className="sc-section-title">{words.task.raiseDecision}</h2>
|
|
195
225
|
<DecisionForm
|
|
196
|
-
tasks={props.tasks}
|
|
197
226
|
taskId={task.id}
|
|
198
227
|
busy={props.busy}
|
|
199
228
|
onSubmit={props.onRaiseDecision}
|
|
@@ -360,34 +389,6 @@ export function TaskView(props: TaskViewProps) {
|
|
|
360
389
|
</dd>
|
|
361
390
|
</div>
|
|
362
391
|
</dl>
|
|
363
|
-
{props.formHref !== undefined && (
|
|
364
|
-
<div className="sc-task-actions">
|
|
365
|
-
<a className="sc-button sc-button-secondary" href={props.formHref("edit")}>
|
|
366
|
-
{words.common.edit}
|
|
367
|
-
</a>
|
|
368
|
-
<a className="sc-button sc-button-secondary" href={props.formHref("ask")}>
|
|
369
|
-
{words.task.raiseDecision}
|
|
370
|
-
</a>
|
|
371
|
-
{props.onArchive !== undefined && task.archivedAt === null && (
|
|
372
|
-
<Button
|
|
373
|
-
variant="secondary"
|
|
374
|
-
disabled={props.busy === true}
|
|
375
|
-
onClick={() => props.onArchive?.(task)}
|
|
376
|
-
>
|
|
377
|
-
{words.task.archive}
|
|
378
|
-
</Button>
|
|
379
|
-
)}
|
|
380
|
-
{props.onRestore !== undefined && task.archivedAt !== null && (
|
|
381
|
-
<Button
|
|
382
|
-
variant="secondary"
|
|
383
|
-
disabled={props.busy === true}
|
|
384
|
-
onClick={() => props.onRestore?.(task)}
|
|
385
|
-
>
|
|
386
|
-
{words.task.restore}
|
|
387
|
-
</Button>
|
|
388
|
-
)}
|
|
389
|
-
</div>
|
|
390
|
-
)}
|
|
391
392
|
</aside>
|
|
392
393
|
</div>
|
|
393
394
|
</div>
|