portable-agent-layer 0.72.0 → 0.74.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/package.json +10 -7
- package/src/cli/index.ts +21 -2
- package/src/cli/server.ts +33 -0
- package/src/hooks/handlers/agenda.ts +195 -7
- package/src/hooks/handlers/update-check.ts +1 -1
- package/src/hooks/lib/agenda-store.ts +2 -0
- package/src/hooks/lib/goal-links.ts +83 -0
- package/src/hooks/lib/models.ts +1 -1
- package/src/hooks/lib/projects.ts +5 -0
- package/src/hooks/lib/settings.ts +2 -0
- package/src/hooks/lib/telos-goals.ts +8 -2
- package/src/hooks/lib/token-usage.ts +2 -0
- package/src/tools/agent/project.ts +22 -20
- package/src/tools/control-room/attention.ts +146 -0
- package/src/tools/control-room/data.ts +76 -5
- package/src/tools/control-room/detail.ts +158 -0
- package/src/tools/control-room/matrix.ts +91 -19
- package/src/tools/control-room/prefs.ts +96 -0
- package/src/tools/control-room/server-config.ts +8 -0
- package/src/tools/control-room/server.ts +196 -11
- package/src/tools/control-room/snooze.ts +65 -0
- package/src/tools/control-room/static.ts +68 -0
- package/src/tools/control-room/ui/app.tsx +196 -50
- package/src/tools/control-room/ui/attention-bell.tsx +118 -0
- package/src/tools/control-room/ui/components/README.md +18 -0
- package/src/tools/control-room/ui/components/badge.tsx +38 -0
- package/src/tools/control-room/ui/components/button.tsx +43 -0
- package/src/tools/control-room/ui/components/card.tsx +45 -0
- package/src/tools/control-room/ui/components/input.tsx +24 -0
- package/src/tools/control-room/ui/components/label.tsx +18 -0
- package/src/tools/control-room/ui/components/popover.tsx +37 -0
- package/src/tools/control-room/ui/components/separator.tsx +25 -0
- package/src/tools/control-room/ui/components/skeleton.tsx +14 -0
- package/src/tools/control-room/ui/components/switch.tsx +27 -0
- package/src/tools/control-room/ui/components/table.tsx +60 -0
- package/src/tools/control-room/ui/components/toggle-group.tsx +38 -0
- package/src/tools/control-room/ui/dist/assets/index-BoQjFpzV.js +49 -0
- package/src/tools/control-room/ui/dist/assets/index-Dncp2bYg.css +2 -0
- package/src/tools/control-room/ui/dist/index.html +19 -0
- package/src/tools/control-room/ui/format.ts +0 -12
- package/src/tools/control-room/ui/frame.tsx +125 -0
- package/src/tools/control-room/ui/index.html +2 -2
- package/src/tools/control-room/ui/lib/api.ts +27 -0
- package/src/tools/control-room/ui/lib/cn.ts +6 -0
- package/src/tools/control-room/ui/lib/write.ts +43 -0
- package/src/tools/control-room/ui/package.json +28 -0
- package/src/tools/control-room/ui/parts.tsx +235 -0
- package/src/tools/control-room/ui/screens/log.tsx +274 -0
- package/src/tools/control-room/ui/screens/project-detail.tsx +302 -0
- package/src/tools/control-room/ui/screens/projects.tsx +128 -0
- package/src/tools/control-room/ui/screens/settings.tsx +205 -0
- package/src/tools/control-room/ui/screens/today.tsx +391 -0
- package/src/tools/control-room/ui/theme.css +127 -0
- package/src/tools/control-room/ui/vite.config.ts +36 -0
- package/src/tools/control-room/writes.ts +106 -0
- package/src/tools/ledger/outcomes.ts +9 -0
- package/src/tools/ledger/query.ts +2 -0
- package/src/tools/ledger/view.ts +31 -10
- package/src/tools/lib/handoff-note.ts +14 -0
- package/src/tools/lib/project-isc.ts +61 -0
- package/src/tools/control-room/ui/agenda.tsx +0 -43
- package/src/tools/control-room/ui/agents.tsx +0 -67
- package/src/tools/control-room/ui/app.css +0 -857
- package/src/tools/control-room/ui/board.tsx +0 -82
- package/src/tools/control-room/ui/handoffs.tsx +0 -37
- package/src/tools/control-room/ui/ledger.tsx +0 -137
- package/src/tools/control-room/ui/matrix.tsx +0 -117
- package/src/tools/control-room/ui/panel.tsx +0 -60
- package/src/tools/control-room/ui/signal.tsx +0 -161
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
import type { ProjectCard } from "../data";
|
|
2
|
-
import { age, plural } from "./format";
|
|
3
|
-
import { Panel, Pending, useLoaded } from "./panel";
|
|
4
|
-
|
|
5
|
-
function reasonTone(reason: string): string {
|
|
6
|
-
if (reason.startsWith("handoff")) return "amber";
|
|
7
|
-
if (reason.endsWith("blocker") || reason.endsWith("blockers")) return "bad";
|
|
8
|
-
return "ghost";
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
function projectTone(p: ProjectCard): string {
|
|
12
|
-
if (p.asking.length > 0) return "asking";
|
|
13
|
-
if (p.stale) return "quiet";
|
|
14
|
-
return "";
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function Project({ p }: { p: ProjectCard }) {
|
|
18
|
-
return (
|
|
19
|
-
<div className={`project ${projectTone(p)}`}>
|
|
20
|
-
<div className="name" title={p.path ?? "not checked out here"}>
|
|
21
|
-
{p.slug}
|
|
22
|
-
<small>
|
|
23
|
-
{p.status} · {age(p.ageDays)}
|
|
24
|
-
{p.path ? "" : " · elsewhere"}
|
|
25
|
-
</small>
|
|
26
|
-
</div>
|
|
27
|
-
<div className="reasons">
|
|
28
|
-
{p.asking.map((r) => (
|
|
29
|
-
<span key={r} className={`tag ${reasonTone(r)}`}>
|
|
30
|
-
{r}
|
|
31
|
-
</span>
|
|
32
|
-
))}
|
|
33
|
-
{p.blockers.map((b) => (
|
|
34
|
-
<span key={b} className="tag bad" title={b}>
|
|
35
|
-
⛔ {b.length > 48 ? `${b.slice(0, 48)}…` : b}
|
|
36
|
-
</span>
|
|
37
|
-
))}
|
|
38
|
-
</div>
|
|
39
|
-
<div className="counts">
|
|
40
|
-
<b>{p.openIscs}</b> open · <b>{p.sessions30d}</b> sessions/30d
|
|
41
|
-
{p.lastSession && (
|
|
42
|
-
<>
|
|
43
|
-
{" "}
|
|
44
|
-
· last <b>{p.lastSession.date}</b>
|
|
45
|
-
</>
|
|
46
|
-
)}
|
|
47
|
-
</div>
|
|
48
|
-
{p.next[0] && <div className="next">{p.next[0]}</div>}
|
|
49
|
-
</div>
|
|
50
|
-
);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export function Board() {
|
|
54
|
-
const cards = useLoaded<ProjectCard[]>("/api/board");
|
|
55
|
-
const asking =
|
|
56
|
-
cards.state === "ready" ? cards.data.filter((c) => c.asking.length > 0) : [];
|
|
57
|
-
return (
|
|
58
|
-
<Panel
|
|
59
|
-
index="01 · projects"
|
|
60
|
-
title="Asking for you"
|
|
61
|
-
span={8}
|
|
62
|
-
order={0}
|
|
63
|
-
aside={
|
|
64
|
-
cards.state === "ready"
|
|
65
|
-
? `${plural(asking.length, "project")} of ${cards.data.length}`
|
|
66
|
-
: ""
|
|
67
|
-
}
|
|
68
|
-
>
|
|
69
|
-
<Pending value={cards} />
|
|
70
|
-
{cards.state === "ready" && cards.data.length === 0 && (
|
|
71
|
-
<div className="empty">No project is registered yet.</div>
|
|
72
|
-
)}
|
|
73
|
-
{cards.state === "ready" && (
|
|
74
|
-
<div className="board">
|
|
75
|
-
{cards.data.map((p) => (
|
|
76
|
-
<Project key={p.slug} p={p} />
|
|
77
|
-
))}
|
|
78
|
-
</div>
|
|
79
|
-
)}
|
|
80
|
-
</Panel>
|
|
81
|
-
);
|
|
82
|
-
}
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
import type { HandoffCard } from "../data";
|
|
2
|
-
import { age } from "./format";
|
|
3
|
-
import { Panel, Pending, useLoaded } from "./panel";
|
|
4
|
-
|
|
5
|
-
function titleRepeatsSentence(h: HandoffCard): boolean {
|
|
6
|
-
return h.title.startsWith(h.sentence.slice(0, 24));
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
function Handoff({ h }: { h: HandoffCard }) {
|
|
10
|
-
return (
|
|
11
|
-
<div className="handoff">
|
|
12
|
-
<div className="where">
|
|
13
|
-
<span>
|
|
14
|
-
<b title={h.cwd}>{h.label}</b> · {h.source}
|
|
15
|
-
</span>
|
|
16
|
-
<span>{age(h.ageDays)}</span>
|
|
17
|
-
</div>
|
|
18
|
-
<div className="sentence" title={h.handoff}>
|
|
19
|
-
{h.sentence}
|
|
20
|
-
</div>
|
|
21
|
-
{!titleRepeatsSentence(h) && <div className="title">{h.title}</div>}
|
|
22
|
-
</div>
|
|
23
|
-
);
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export function Handoffs() {
|
|
27
|
-
const cards = useLoaded<HandoffCard[]>("/api/handoffs");
|
|
28
|
-
return (
|
|
29
|
-
<Panel index="03 · unfinished" title="Where you left off" span={5} order={2}>
|
|
30
|
-
<Pending value={cards} />
|
|
31
|
-
{cards.state === "ready" && cards.data.length === 0 && (
|
|
32
|
-
<div className="empty">Nothing in progress. Every handoff is closed.</div>
|
|
33
|
-
)}
|
|
34
|
-
{cards.state === "ready" && cards.data.map((h) => <Handoff key={h.cwd} h={h} />)}
|
|
35
|
-
</Panel>
|
|
36
|
-
);
|
|
37
|
-
}
|
|
@@ -1,137 +0,0 @@
|
|
|
1
|
-
import { useState } from "react";
|
|
2
|
-
import type { LedgerView, LedgerViewRow, PageOutcome } from "../../ledger/view";
|
|
3
|
-
import { PAGE_OUTCOMES } from "../../ledger/view";
|
|
4
|
-
import { clock, isoDay } from "./format";
|
|
5
|
-
import { Panel, Pending, useLoaded } from "./panel";
|
|
6
|
-
|
|
7
|
-
const DEFAULT_WINDOW_DAYS = 14;
|
|
8
|
-
const MAX_ROWS = 200;
|
|
9
|
-
|
|
10
|
-
function ledgerQuery(project: string, since: string, until: string): string {
|
|
11
|
-
const params = new URLSearchParams();
|
|
12
|
-
if (project) params.set("project", project);
|
|
13
|
-
if (since) params.set("since", since);
|
|
14
|
-
if (until) params.set("until", until);
|
|
15
|
-
const query = params.toString();
|
|
16
|
-
return query ? `/api/ledger?${query}` : "/api/ledger";
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function Row({ r }: { r: LedgerViewRow }) {
|
|
20
|
-
const [slug, ...rest] = r.target.split(" ");
|
|
21
|
-
return (
|
|
22
|
-
<tr>
|
|
23
|
-
<td className="when">{clock(r.ts)}</td>
|
|
24
|
-
<td>
|
|
25
|
-
{r.actor}
|
|
26
|
-
<span className="reason">
|
|
27
|
-
{r.authority} · {r.runtime}
|
|
28
|
-
</span>
|
|
29
|
-
</td>
|
|
30
|
-
<td>{r.tool}</td>
|
|
31
|
-
<td className="target" title={r.command ?? r.target}>
|
|
32
|
-
<b>{slug}</b> {rest.join(" ")}
|
|
33
|
-
{r.command && <span className="reason">{r.command}</span>}
|
|
34
|
-
</td>
|
|
35
|
-
<td>{r.change}</td>
|
|
36
|
-
<td>
|
|
37
|
-
<span className={`outcome ${r.outcome}`}>{r.outcome}</span>
|
|
38
|
-
{r.reason && <span className="reason">{r.reason}</span>}
|
|
39
|
-
</td>
|
|
40
|
-
</tr>
|
|
41
|
-
);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function Strip({ view }: { view: LedgerView }) {
|
|
45
|
-
return (
|
|
46
|
-
<div className="strip">
|
|
47
|
-
<span>
|
|
48
|
-
<b>{view.stats.total}</b>actions
|
|
49
|
-
</span>
|
|
50
|
-
<span className="refusals">
|
|
51
|
-
<b>{view.stats.refusals}</b>refusals
|
|
52
|
-
</span>
|
|
53
|
-
{PAGE_OUTCOMES.map((o: PageOutcome) => (
|
|
54
|
-
<span key={o}>
|
|
55
|
-
<b>{view.stats.outcomes[o].total}</b>
|
|
56
|
-
{o}
|
|
57
|
-
</span>
|
|
58
|
-
))}
|
|
59
|
-
</div>
|
|
60
|
-
);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export function Ledger() {
|
|
64
|
-
const [project, setProject] = useState("");
|
|
65
|
-
const [since, setSince] = useState(
|
|
66
|
-
isoDay(new Date(Date.now() - DEFAULT_WINDOW_DAYS * 86_400_000))
|
|
67
|
-
);
|
|
68
|
-
const [until, setUntil] = useState("");
|
|
69
|
-
const projects = useLoaded<{ slug: string }[]>("/api/projects");
|
|
70
|
-
const view = useLoaded<LedgerView>(ledgerQuery(project, since, until));
|
|
71
|
-
return (
|
|
72
|
-
<Panel
|
|
73
|
-
index="05 · ledger"
|
|
74
|
-
title="What the agents did"
|
|
75
|
-
span={12}
|
|
76
|
-
order={4}
|
|
77
|
-
aside={
|
|
78
|
-
<span className="filters">
|
|
79
|
-
<label>
|
|
80
|
-
project
|
|
81
|
-
<select value={project} onChange={(e) => setProject(e.target.value)}>
|
|
82
|
-
<option value="">all</option>
|
|
83
|
-
{projects.state === "ready" &&
|
|
84
|
-
projects.data.map((p) => (
|
|
85
|
-
<option key={p.slug} value={p.slug}>
|
|
86
|
-
{p.slug}
|
|
87
|
-
</option>
|
|
88
|
-
))}
|
|
89
|
-
</select>
|
|
90
|
-
</label>
|
|
91
|
-
<label>
|
|
92
|
-
from
|
|
93
|
-
<input type="date" value={since} onChange={(e) => setSince(e.target.value)} />
|
|
94
|
-
</label>
|
|
95
|
-
<label>
|
|
96
|
-
to
|
|
97
|
-
<input type="date" value={until} onChange={(e) => setUntil(e.target.value)} />
|
|
98
|
-
</label>
|
|
99
|
-
</span>
|
|
100
|
-
}
|
|
101
|
-
>
|
|
102
|
-
<Pending value={view} />
|
|
103
|
-
{view.state === "ready" && (
|
|
104
|
-
<>
|
|
105
|
-
<Strip view={view.data} />
|
|
106
|
-
{view.data.rows.length === 0 ? (
|
|
107
|
-
<div className="empty">No actions in this window.</div>
|
|
108
|
-
) : (
|
|
109
|
-
<table className="ledger">
|
|
110
|
-
<thead>
|
|
111
|
-
<tr>
|
|
112
|
-
<th>when</th>
|
|
113
|
-
<th>actor</th>
|
|
114
|
-
<th>action</th>
|
|
115
|
-
<th>target</th>
|
|
116
|
-
<th>change</th>
|
|
117
|
-
<th>outcome</th>
|
|
118
|
-
</tr>
|
|
119
|
-
</thead>
|
|
120
|
-
<tbody>
|
|
121
|
-
{view.data.rows.slice(0, MAX_ROWS).map((r) => (
|
|
122
|
-
<Row key={r.id} r={r} />
|
|
123
|
-
))}
|
|
124
|
-
</tbody>
|
|
125
|
-
</table>
|
|
126
|
-
)}
|
|
127
|
-
{view.data.rows.length > MAX_ROWS && (
|
|
128
|
-
<div className="empty">
|
|
129
|
-
Newest {MAX_ROWS} of {view.data.rows.length}. Narrow the window for the
|
|
130
|
-
rest.
|
|
131
|
-
</div>
|
|
132
|
-
)}
|
|
133
|
-
</>
|
|
134
|
-
)}
|
|
135
|
-
</Panel>
|
|
136
|
-
);
|
|
137
|
-
}
|
|
@@ -1,117 +0,0 @@
|
|
|
1
|
-
import { useState } from "react";
|
|
2
|
-
import type { Matrix, MatrixItem } from "../matrix";
|
|
3
|
-
import { Pending, useLoaded } from "./panel";
|
|
4
|
-
|
|
5
|
-
const QUADRANTS: { key: keyof Matrix; title: string; note: string }[] = [
|
|
6
|
-
{ key: "now", title: "Do now", note: "matters and cannot wait" },
|
|
7
|
-
{ key: "plan", title: "Give it a slot", note: "matters, nothing forcing it" },
|
|
8
|
-
{ key: "noise", title: "Loud, not load-bearing", note: "pressing but serves nothing" },
|
|
9
|
-
{ key: "later", title: "Let it sit", note: "neither" },
|
|
10
|
-
];
|
|
11
|
-
|
|
12
|
-
const KINDS = ["goal", "revenue", "fun"] as const;
|
|
13
|
-
|
|
14
|
-
async function saveServes(project: string, serves: string): Promise<void> {
|
|
15
|
-
const res = await fetch("/api/serves", {
|
|
16
|
-
method: "POST",
|
|
17
|
-
headers: { "content-type": "application/json" },
|
|
18
|
-
body: JSON.stringify({ project, serves }),
|
|
19
|
-
});
|
|
20
|
-
if (!res.ok) throw new Error(`override answered ${res.status}`);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function Serves({ item, onSaved }: { item: MatrixItem; onSaved: () => void }) {
|
|
24
|
-
if (item.kind !== "project") return null;
|
|
25
|
-
return (
|
|
26
|
-
<select
|
|
27
|
-
className={item.servesBy === "user" ? "serves mine" : "serves"}
|
|
28
|
-
value={item.serves ?? ""}
|
|
29
|
-
title={
|
|
30
|
-
item.servesBy === "user" ? "your answer" : "PAL's guess — change it to correct it"
|
|
31
|
-
}
|
|
32
|
-
onChange={(e) => {
|
|
33
|
-
saveServes(item.id, e.target.value)
|
|
34
|
-
.then(onSaved)
|
|
35
|
-
.catch(() => onSaved());
|
|
36
|
-
}}
|
|
37
|
-
>
|
|
38
|
-
<option value="" disabled>
|
|
39
|
-
unranked
|
|
40
|
-
</option>
|
|
41
|
-
{KINDS.map((kind) => (
|
|
42
|
-
<option key={kind} value={kind}>
|
|
43
|
-
{kind}
|
|
44
|
-
</option>
|
|
45
|
-
))}
|
|
46
|
-
</select>
|
|
47
|
-
);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function Item({ item, onSaved }: { item: MatrixItem; onSaved: () => void }) {
|
|
51
|
-
return (
|
|
52
|
-
<li className={`m-item ${item.kind}`}>
|
|
53
|
-
<div className="m-head">
|
|
54
|
-
<span className="m-label">{item.label}</span>
|
|
55
|
-
<Serves item={item} onSaved={onSaved} />
|
|
56
|
-
</div>
|
|
57
|
-
{item.waitingOn && <div className="waiting">waiting on you: {item.waitingOn}</div>}
|
|
58
|
-
{item.detail && <div className="m-detail">{item.detail}</div>}
|
|
59
|
-
<div className="m-reasons">
|
|
60
|
-
<span className="why-important">{item.importantBecause}</span>
|
|
61
|
-
{item.urgentBecause.map((reason) => (
|
|
62
|
-
<span className="why-urgent" key={reason}>
|
|
63
|
-
{reason}
|
|
64
|
-
</span>
|
|
65
|
-
))}
|
|
66
|
-
</div>
|
|
67
|
-
</li>
|
|
68
|
-
);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
export function MatrixGrid() {
|
|
72
|
-
const [nonce, setNonce] = useState(0);
|
|
73
|
-
const loaded = useLoaded<Matrix>(`/api/matrix?v=${nonce}`);
|
|
74
|
-
const reload = () => setNonce((n) => n + 1);
|
|
75
|
-
|
|
76
|
-
return (
|
|
77
|
-
<section className="matrix">
|
|
78
|
-
<header>
|
|
79
|
-
<h2>What is worth your morning</h2>
|
|
80
|
-
{loaded.state === "ready" && loaded.data.unranked > 0 && (
|
|
81
|
-
<span className="agenda-age stale">
|
|
82
|
-
{loaded.data.unranked} project(s) with no purpose on record
|
|
83
|
-
</span>
|
|
84
|
-
)}
|
|
85
|
-
</header>
|
|
86
|
-
<Pending value={loaded} />
|
|
87
|
-
{loaded.state === "ready" && (
|
|
88
|
-
<div className="quadrants">
|
|
89
|
-
{QUADRANTS.map(({ key, title, note }) => {
|
|
90
|
-
const items = loaded.data[key] as MatrixItem[];
|
|
91
|
-
return (
|
|
92
|
-
<div className={`quadrant q-${key}`} key={key}>
|
|
93
|
-
<div className="q-head">
|
|
94
|
-
<h3>{title}</h3>
|
|
95
|
-
<span>{note}</span>
|
|
96
|
-
</div>
|
|
97
|
-
{items.length === 0 ? (
|
|
98
|
-
<div className="empty">nothing here</div>
|
|
99
|
-
) : (
|
|
100
|
-
<ul>
|
|
101
|
-
{items.map((item) => (
|
|
102
|
-
<Item
|
|
103
|
-
key={`${item.kind}:${item.id}`}
|
|
104
|
-
item={item}
|
|
105
|
-
onSaved={reload}
|
|
106
|
-
/>
|
|
107
|
-
))}
|
|
108
|
-
</ul>
|
|
109
|
-
)}
|
|
110
|
-
</div>
|
|
111
|
-
);
|
|
112
|
-
})}
|
|
113
|
-
</div>
|
|
114
|
-
)}
|
|
115
|
-
</section>
|
|
116
|
-
);
|
|
117
|
-
}
|
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
import { type ReactNode, useEffect, useState } from "react";
|
|
2
|
-
|
|
3
|
-
interface PanelProps {
|
|
4
|
-
index: string;
|
|
5
|
-
title: string;
|
|
6
|
-
span: number;
|
|
7
|
-
order: number;
|
|
8
|
-
aside?: ReactNode;
|
|
9
|
-
children: ReactNode;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export function Panel({ index, title, span, order, aside, children }: PanelProps) {
|
|
13
|
-
return (
|
|
14
|
-
<section
|
|
15
|
-
className="panel"
|
|
16
|
-
style={{ "--span": span, "--i": order } as React.CSSProperties}
|
|
17
|
-
>
|
|
18
|
-
<header>
|
|
19
|
-
<div>
|
|
20
|
-
<span className="index">{index}</span>
|
|
21
|
-
<h2>{title}</h2>
|
|
22
|
-
</div>
|
|
23
|
-
{aside && <span className="aside">{aside}</span>}
|
|
24
|
-
</header>
|
|
25
|
-
{children}
|
|
26
|
-
</section>
|
|
27
|
-
);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export type Loaded<T> =
|
|
31
|
-
| { state: "loading" }
|
|
32
|
-
| { state: "error"; message: string }
|
|
33
|
-
| { state: "ready"; data: T };
|
|
34
|
-
|
|
35
|
-
export async function getJson<T>(path: string): Promise<T> {
|
|
36
|
-
const res = await fetch(path);
|
|
37
|
-
if (!res.ok) throw new Error(`${path} answered ${res.status}`);
|
|
38
|
-
return (await res.json()) as T;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export function useLoaded<T>(url: string): Loaded<T> {
|
|
42
|
-
const [value, setValue] = useState<Loaded<T>>({ state: "loading" });
|
|
43
|
-
useEffect(() => {
|
|
44
|
-
let live = true;
|
|
45
|
-
setValue({ state: "loading" });
|
|
46
|
-
getJson<T>(url)
|
|
47
|
-
.then((data) => live && setValue({ state: "ready", data }))
|
|
48
|
-
.catch((e: Error) => live && setValue({ state: "error", message: e.message }));
|
|
49
|
-
return () => {
|
|
50
|
-
live = false;
|
|
51
|
-
};
|
|
52
|
-
}, [url]);
|
|
53
|
-
return value;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export function Pending({ value }: { value: Loaded<unknown> }) {
|
|
57
|
-
if (value.state === "loading") return <div className="empty">reading…</div>;
|
|
58
|
-
if (value.state === "error") return <div className="error">{value.message}</div>;
|
|
59
|
-
return null;
|
|
60
|
-
}
|
|
@@ -1,161 +0,0 @@
|
|
|
1
|
-
import type { DueBadge, RatingPoint, SignalView } from "../data";
|
|
2
|
-
import { percent, tenths } from "./format";
|
|
3
|
-
import { Panel, Pending, useLoaded } from "./panel";
|
|
4
|
-
|
|
5
|
-
const LOW_RATING = 3;
|
|
6
|
-
const W = 320;
|
|
7
|
-
const H = 72;
|
|
8
|
-
const PAD = 4;
|
|
9
|
-
|
|
10
|
-
function sparkPath(points: RatingPoint[]): {
|
|
11
|
-
line: string;
|
|
12
|
-
area: string;
|
|
13
|
-
xy: [number, number][];
|
|
14
|
-
} {
|
|
15
|
-
if (points.length === 0) return { line: "", area: "", xy: [] };
|
|
16
|
-
const step = points.length > 1 ? (W - PAD * 2) / (points.length - 1) : 0;
|
|
17
|
-
const xy = points.map<[number, number]>((p, i) => [
|
|
18
|
-
PAD + i * step,
|
|
19
|
-
H - PAD - ((p.rating - 1) / 9) * (H - PAD * 2),
|
|
20
|
-
]);
|
|
21
|
-
const line = xy
|
|
22
|
-
.map(([x, y], i) => `${i === 0 ? "M" : "L"}${x.toFixed(1)} ${y.toFixed(1)}`)
|
|
23
|
-
.join(" ");
|
|
24
|
-
const area = `${line} L${xy.at(-1)?.[0].toFixed(1)} ${H} L${xy[0][0].toFixed(1)} ${H} Z`;
|
|
25
|
-
return { line, area, xy };
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function Sparkline({ points }: { points: RatingPoint[] }) {
|
|
29
|
-
const { line, area, xy } = sparkPath(points);
|
|
30
|
-
const last = xy.at(-1);
|
|
31
|
-
const midY = H - PAD - (4 / 9) * (H - PAD * 2);
|
|
32
|
-
return (
|
|
33
|
-
<svg
|
|
34
|
-
className="spark"
|
|
35
|
-
viewBox={`0 0 ${W} ${H}`}
|
|
36
|
-
preserveAspectRatio="none"
|
|
37
|
-
role="img"
|
|
38
|
-
aria-label="ratings"
|
|
39
|
-
>
|
|
40
|
-
<title>ratings, oldest to newest</title>
|
|
41
|
-
<defs>
|
|
42
|
-
<linearGradient id="sparkfill" x1="0" x2="0" y1="0" y2="1">
|
|
43
|
-
<stop offset="0" stopColor="#f0b14a" stopOpacity="0.28" />
|
|
44
|
-
<stop offset="1" stopColor="#f0b14a" stopOpacity="0" />
|
|
45
|
-
</linearGradient>
|
|
46
|
-
</defs>
|
|
47
|
-
<line className="rule" x1={PAD} x2={W - PAD} y1={midY} y2={midY} />
|
|
48
|
-
<path className="area" d={area} />
|
|
49
|
-
<path className="line" d={line} />
|
|
50
|
-
{points.map((p, i) =>
|
|
51
|
-
p.rating <= LOW_RATING ? (
|
|
52
|
-
<circle key={p.ts} className="low" cx={xy[i][0]} cy={xy[i][1]} r="2" />
|
|
53
|
-
) : null
|
|
54
|
-
)}
|
|
55
|
-
{last && <circle className="last" cx={last[0]} cy={last[1]} r="3" />}
|
|
56
|
-
</svg>
|
|
57
|
-
);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function Figure({ value, label, tone }: { value: string; label: string; tone?: string }) {
|
|
61
|
-
return (
|
|
62
|
-
<div className="figure">
|
|
63
|
-
<div className={`value ${tone ?? ""}`}>{value}</div>
|
|
64
|
-
<div className="label">{label}</div>
|
|
65
|
-
</div>
|
|
66
|
-
);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const BADGE_LOOK: Record<DueBadge["state"], { row: string; tag: string }> = {
|
|
70
|
-
due: { row: "due", tag: "amber" },
|
|
71
|
-
clear: { row: "clear", tag: "good" },
|
|
72
|
-
"n/a": { row: "na", tag: "ghost" },
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
function Due({ name, badge }: { name: string; badge: DueBadge }) {
|
|
76
|
-
const look = BADGE_LOOK[badge.state];
|
|
77
|
-
return (
|
|
78
|
-
<div className={`due-row ${look.row}`}>
|
|
79
|
-
<span className={`tag ${look.tag}`}>{badge.state}</span>
|
|
80
|
-
<span className="detail">
|
|
81
|
-
{name}
|
|
82
|
-
{badge.detail ? ` — ${badge.detail}` : ""}
|
|
83
|
-
</span>
|
|
84
|
-
</div>
|
|
85
|
-
);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function ratingTone(avg: number): string {
|
|
89
|
-
if (avg < 5) return "bad";
|
|
90
|
-
if (avg >= 7) return "good";
|
|
91
|
-
return "";
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
export function Signal() {
|
|
95
|
-
const view = useLoaded<SignalView>("/api/signal");
|
|
96
|
-
return (
|
|
97
|
-
<Panel
|
|
98
|
-
index="02 · feedback"
|
|
99
|
-
title="Signal"
|
|
100
|
-
span={4}
|
|
101
|
-
order={1}
|
|
102
|
-
aside={
|
|
103
|
-
view.state === "ready" && view.data.synthesizedAt
|
|
104
|
-
? `synthesised ${view.data.synthesizedAt.slice(0, 10)}`
|
|
105
|
-
: ""
|
|
106
|
-
}
|
|
107
|
-
>
|
|
108
|
-
<Pending value={view} />
|
|
109
|
-
{view.state === "ready" && (
|
|
110
|
-
<>
|
|
111
|
-
<div className="figures">
|
|
112
|
-
<Figure
|
|
113
|
-
value={view.data.ratings ? tenths(view.data.ratings.recentAvg) : "–"}
|
|
114
|
-
label="last 10"
|
|
115
|
-
tone={view.data.ratings ? ratingTone(view.data.ratings.recentAvg) : ""}
|
|
116
|
-
/>
|
|
117
|
-
<Figure
|
|
118
|
-
value={view.data.ratings ? tenths(view.data.ratings.avg) : "–"}
|
|
119
|
-
label={`avg of ${view.data.ratings?.count ?? 0}`}
|
|
120
|
-
/>
|
|
121
|
-
<Figure
|
|
122
|
-
value={view.data.ratings ? String(view.data.ratings.lowCount) : "–"}
|
|
123
|
-
label="low (≤3)"
|
|
124
|
-
tone={view.data.ratings && view.data.ratings.lowCount > 5 ? "bad" : ""}
|
|
125
|
-
/>
|
|
126
|
-
</div>
|
|
127
|
-
{view.data.series.length > 0 ? (
|
|
128
|
-
<Sparkline points={view.data.series} />
|
|
129
|
-
) : (
|
|
130
|
-
<div className="empty">No ratings yet.</div>
|
|
131
|
-
)}
|
|
132
|
-
<div className="spark-caption">
|
|
133
|
-
<span>last {view.data.series.length} ratings</span>
|
|
134
|
-
<span>{view.data.ratings?.trend ?? ""}</span>
|
|
135
|
-
</div>
|
|
136
|
-
<div className="figures" style={{ marginTop: 16 }}>
|
|
137
|
-
<Figure
|
|
138
|
-
value={
|
|
139
|
-
view.data.algorithm ? String(view.data.algorithm.reflectionCount) : "–"
|
|
140
|
-
}
|
|
141
|
-
label="reflections"
|
|
142
|
-
/>
|
|
143
|
-
<Figure
|
|
144
|
-
value={view.data.algorithm ? percent(view.data.algorithm.passRate) : "–"}
|
|
145
|
-
label="criteria pass"
|
|
146
|
-
/>
|
|
147
|
-
<Figure
|
|
148
|
-
value={view.data.algorithm ? tenths(view.data.algorithm.avgSentiment) : "–"}
|
|
149
|
-
label="sentiment"
|
|
150
|
-
/>
|
|
151
|
-
</div>
|
|
152
|
-
<div className="due">
|
|
153
|
-
<Due name="learning analysis" badge={view.data.due.analysis} />
|
|
154
|
-
<Due name="algorithm review" badge={view.data.due.algorithmReview} />
|
|
155
|
-
<Due name="relationship reflect" badge={view.data.due.relationshipReflect} />
|
|
156
|
-
</div>
|
|
157
|
-
</>
|
|
158
|
-
)}
|
|
159
|
-
</Panel>
|
|
160
|
-
);
|
|
161
|
-
}
|