portable-agent-layer 0.70.0 → 0.71.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 +5 -1
- package/assets/schema/pal-settings.schema.json +4 -0
- package/assets/skills/onboarding/SKILL.md +109 -0
- package/assets/skills/projects/SKILL.md +11 -2
- package/assets/templates/pal-settings.json +1 -0
- package/package.json +5 -1
- package/src/cli/index.ts +39 -12
- package/src/cli/personal-context.ts +67 -0
- package/src/cli/server.ts +13 -7
- package/src/cli/setup-identity.ts +13 -1
- package/src/hooks/handlers/agenda.ts +223 -0
- package/src/hooks/handlers/inject-retrieval.ts +6 -2
- package/src/hooks/lib/agenda-store.ts +41 -0
- package/src/hooks/lib/paths.ts +0 -1
- package/src/hooks/lib/projects.ts +16 -1
- package/src/hooks/lib/serves.ts +60 -0
- package/src/hooks/lib/stop.ts +14 -0
- package/src/hooks/lib/telos-goals.ts +144 -0
- package/src/hooks/lib/telos-topics.ts +68 -0
- package/src/hooks/lib/token-usage.ts +3 -1
- package/src/hooks/lib/wall-clock.ts +58 -0
- package/src/tools/agent/handoff-note.ts +38 -20
- package/src/tools/agent/project.ts +36 -4
- package/src/tools/control-room/data.ts +332 -0
- package/src/tools/control-room/matrix.ts +182 -0
- package/src/tools/control-room/server.ts +150 -0
- package/src/tools/control-room/ui/agenda.tsx +43 -0
- package/src/tools/control-room/ui/agents.tsx +67 -0
- package/src/tools/control-room/ui/app.css +857 -0
- package/src/tools/control-room/ui/app.tsx +74 -0
- package/src/tools/control-room/ui/board.tsx +82 -0
- package/src/tools/control-room/ui/format.ts +31 -0
- package/src/tools/control-room/ui/handoffs.tsx +37 -0
- package/src/tools/control-room/ui/index.html +19 -0
- package/src/tools/control-room/ui/ledger.tsx +136 -0
- package/src/tools/control-room/ui/matrix.tsx +117 -0
- package/src/tools/control-room/ui/panel.tsx +60 -0
- package/src/tools/control-room/ui/signal.tsx +161 -0
- package/assets/templates/ledger-page.html +0 -213
- package/src/cli/setup-telos.ts +0 -52
- package/src/hooks/lib/setup.ts +0 -60
- package/src/tools/ledger/server.ts +0 -111
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { StrictMode, useEffect, useState } from "react";
|
|
2
|
+
import { createRoot } from "react-dom/client";
|
|
3
|
+
import type { ServerStatus } from "../server";
|
|
4
|
+
import { Agenda } from "./agenda";
|
|
5
|
+
import { Agents } from "./agents";
|
|
6
|
+
import { Board } from "./board";
|
|
7
|
+
import { Handoffs } from "./handoffs";
|
|
8
|
+
import { Ledger } from "./ledger";
|
|
9
|
+
import { MatrixGrid } from "./matrix";
|
|
10
|
+
import { getJson } from "./panel";
|
|
11
|
+
import { Signal } from "./signal";
|
|
12
|
+
|
|
13
|
+
function Masthead({ status }: { status: ServerStatus | null }) {
|
|
14
|
+
return (
|
|
15
|
+
<div className="masthead">
|
|
16
|
+
<h1>
|
|
17
|
+
PAL <em>this morning</em>
|
|
18
|
+
</h1>
|
|
19
|
+
<div className="meta">
|
|
20
|
+
<span>
|
|
21
|
+
machine <b>{status?.machine ?? "…"}</b>
|
|
22
|
+
</span>
|
|
23
|
+
<span>
|
|
24
|
+
ledger <b>{status ? `${status.ledgerFiles} file(s)` : "…"}</b>
|
|
25
|
+
</span>
|
|
26
|
+
<span>
|
|
27
|
+
port <b>{status?.port ?? "…"}</b>
|
|
28
|
+
</span>
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function App() {
|
|
35
|
+
const [status, setStatus] = useState<ServerStatus | null>(null);
|
|
36
|
+
const [error, setError] = useState<string | null>(null);
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
getJson<ServerStatus>("/api/status")
|
|
39
|
+
.then(setStatus)
|
|
40
|
+
.catch((e: Error) => setError(e.message));
|
|
41
|
+
}, []);
|
|
42
|
+
return (
|
|
43
|
+
<div className="room">
|
|
44
|
+
<Masthead status={status} />
|
|
45
|
+
{error && <div className="error">{error}</div>}
|
|
46
|
+
<Agenda />
|
|
47
|
+
<MatrixGrid />
|
|
48
|
+
<div className="grid">
|
|
49
|
+
<Handoffs />
|
|
50
|
+
</div>
|
|
51
|
+
<details className="drawer">
|
|
52
|
+
<summary>When something breaks — projects, agents, signal, ledger</summary>
|
|
53
|
+
<div className="grid">
|
|
54
|
+
<Board />
|
|
55
|
+
<Signal />
|
|
56
|
+
<Agents />
|
|
57
|
+
<Ledger />
|
|
58
|
+
</div>
|
|
59
|
+
</details>
|
|
60
|
+
<div className="foot">
|
|
61
|
+
<span>loopback only · every number from ~/.pal · no model runs on this page</span>
|
|
62
|
+
<span>{status ? `up since ${status.startedAt}` : ""}</span>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const root = document.getElementById("root");
|
|
69
|
+
if (root)
|
|
70
|
+
createRoot(root).render(
|
|
71
|
+
<StrictMode>
|
|
72
|
+
<App />
|
|
73
|
+
</StrictMode>
|
|
74
|
+
);
|
|
@@ -0,0 +1,82 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export function age(days: number): string {
|
|
2
|
+
if (days === 0) return "today";
|
|
3
|
+
if (days === 1) return "1d";
|
|
4
|
+
if (days < 30) return `${days}d`;
|
|
5
|
+
if (days < 60) return "1mo";
|
|
6
|
+
return `${Math.floor(days / 30)}mo`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function plural(n: number, one: string, many = `${one}s`): string {
|
|
10
|
+
return `${n} ${n === 1 ? one : many}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function clock(iso: string): string {
|
|
14
|
+
const at = new Date(iso);
|
|
15
|
+
if (Number.isNaN(at.getTime())) return iso;
|
|
16
|
+
const day = at.toISOString().slice(0, 10);
|
|
17
|
+
const time = at.toTimeString().slice(0, 5);
|
|
18
|
+
return `${day} ${time}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function isoDay(at: Date): string {
|
|
22
|
+
return at.toISOString().slice(0, 10);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function tenths(n: number): string {
|
|
26
|
+
return n.toFixed(1);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function percent(alreadyPercent: number): string {
|
|
30
|
+
return `${Math.round(alreadyPercent)}%`;
|
|
31
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>PAL control room</title>
|
|
7
|
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
8
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
9
|
+
<link
|
|
10
|
+
rel="stylesheet"
|
|
11
|
+
href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;1,6..72,400;1,6..72,500&family=Martian+Mono:wght@300;400;600&display=swap"
|
|
12
|
+
/>
|
|
13
|
+
<link rel="stylesheet" href="./app.css" />
|
|
14
|
+
</head>
|
|
15
|
+
<body>
|
|
16
|
+
<div id="root"></div>
|
|
17
|
+
<script type="module" src="./app.tsx"></script>
|
|
18
|
+
</body>
|
|
19
|
+
</html>
|
|
@@ -0,0 +1,136 @@
|
|
|
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.target}>
|
|
32
|
+
<b>{slug}</b> {rest.join(" ")}
|
|
33
|
+
</td>
|
|
34
|
+
<td>{r.change}</td>
|
|
35
|
+
<td>
|
|
36
|
+
<span className={`outcome ${r.outcome}`}>{r.outcome}</span>
|
|
37
|
+
{r.reason && <span className="reason">{r.reason}</span>}
|
|
38
|
+
</td>
|
|
39
|
+
</tr>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function Strip({ view }: { view: LedgerView }) {
|
|
44
|
+
return (
|
|
45
|
+
<div className="strip">
|
|
46
|
+
<span>
|
|
47
|
+
<b>{view.stats.total}</b>actions
|
|
48
|
+
</span>
|
|
49
|
+
<span className="refusals">
|
|
50
|
+
<b>{view.stats.refusals}</b>refusals
|
|
51
|
+
</span>
|
|
52
|
+
{PAGE_OUTCOMES.map((o: PageOutcome) => (
|
|
53
|
+
<span key={o}>
|
|
54
|
+
<b>{view.stats.outcomes[o].total}</b>
|
|
55
|
+
{o}
|
|
56
|
+
</span>
|
|
57
|
+
))}
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function Ledger() {
|
|
63
|
+
const [project, setProject] = useState("");
|
|
64
|
+
const [since, setSince] = useState(
|
|
65
|
+
isoDay(new Date(Date.now() - DEFAULT_WINDOW_DAYS * 86_400_000))
|
|
66
|
+
);
|
|
67
|
+
const [until, setUntil] = useState("");
|
|
68
|
+
const projects = useLoaded<{ slug: string }[]>("/api/projects");
|
|
69
|
+
const view = useLoaded<LedgerView>(ledgerQuery(project, since, until));
|
|
70
|
+
return (
|
|
71
|
+
<Panel
|
|
72
|
+
index="05 · ledger"
|
|
73
|
+
title="What the agents did"
|
|
74
|
+
span={12}
|
|
75
|
+
order={4}
|
|
76
|
+
aside={
|
|
77
|
+
<span className="filters">
|
|
78
|
+
<label>
|
|
79
|
+
project
|
|
80
|
+
<select value={project} onChange={(e) => setProject(e.target.value)}>
|
|
81
|
+
<option value="">all</option>
|
|
82
|
+
{projects.state === "ready" &&
|
|
83
|
+
projects.data.map((p) => (
|
|
84
|
+
<option key={p.slug} value={p.slug}>
|
|
85
|
+
{p.slug}
|
|
86
|
+
</option>
|
|
87
|
+
))}
|
|
88
|
+
</select>
|
|
89
|
+
</label>
|
|
90
|
+
<label>
|
|
91
|
+
from
|
|
92
|
+
<input type="date" value={since} onChange={(e) => setSince(e.target.value)} />
|
|
93
|
+
</label>
|
|
94
|
+
<label>
|
|
95
|
+
to
|
|
96
|
+
<input type="date" value={until} onChange={(e) => setUntil(e.target.value)} />
|
|
97
|
+
</label>
|
|
98
|
+
</span>
|
|
99
|
+
}
|
|
100
|
+
>
|
|
101
|
+
<Pending value={view} />
|
|
102
|
+
{view.state === "ready" && (
|
|
103
|
+
<>
|
|
104
|
+
<Strip view={view.data} />
|
|
105
|
+
{view.data.rows.length === 0 ? (
|
|
106
|
+
<div className="empty">No actions in this window.</div>
|
|
107
|
+
) : (
|
|
108
|
+
<table className="ledger">
|
|
109
|
+
<thead>
|
|
110
|
+
<tr>
|
|
111
|
+
<th>when</th>
|
|
112
|
+
<th>actor</th>
|
|
113
|
+
<th>action</th>
|
|
114
|
+
<th>target</th>
|
|
115
|
+
<th>change</th>
|
|
116
|
+
<th>outcome</th>
|
|
117
|
+
</tr>
|
|
118
|
+
</thead>
|
|
119
|
+
<tbody>
|
|
120
|
+
{view.data.rows.slice(0, MAX_ROWS).map((r) => (
|
|
121
|
+
<Row key={r.id} r={r} />
|
|
122
|
+
))}
|
|
123
|
+
</tbody>
|
|
124
|
+
</table>
|
|
125
|
+
)}
|
|
126
|
+
{view.data.rows.length > MAX_ROWS && (
|
|
127
|
+
<div className="empty">
|
|
128
|
+
Newest {MAX_ROWS} of {view.data.rows.length}. Narrow the window for the
|
|
129
|
+
rest.
|
|
130
|
+
</div>
|
|
131
|
+
)}
|
|
132
|
+
</>
|
|
133
|
+
)}
|
|
134
|
+
</Panel>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
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
|
+
}
|