@medicine-wheel/app 0.6.3 → 0.7.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.
@@ -0,0 +1,223 @@
1
+ "use client";
2
+
3
+ /**
4
+ * One episode: what it is, what it still holds open, and what it touches.
5
+ *
6
+ * The three panels answer the three things the graph could not. What this is,
7
+ * from the node itself. What is open, from `?kind=attention&parent_id=<id>` —
8
+ * a query the API has always accepted and no page ever sent. What it relates to,
9
+ * from `/api/nodes/[id]/web`, which walks the traversal that shipped in
10
+ * `relational-query` and went unimported.
11
+ *
12
+ * The relations panel is where the wheel's current honesty shows. 101 of 106
13
+ * containment links live only in `metadata.parent_id` and were never written as
14
+ * edges, so most episodes will show a parent here that has no relation behind
15
+ * it. The panel names that rather than rendering an empty box, because an empty
16
+ * box reads as "this episode touches nothing" when the truth is "the edge was
17
+ * never written".
18
+ */
19
+
20
+ import { useCallback, useEffect, useState } from "react";
21
+ import { use } from "react";
22
+ import Link from "next/link";
23
+ import type { RelationalNode, RelationalEdge } from "@/lib/types";
24
+
25
+ // `description` lives on the store's node, not on ontology-core's — the same
26
+ // widening `app/nodes/page.tsx` declares as `NodeRecord`. Named EpisodeNode
27
+ // rather than Node so it cannot be confused with the DOM's global `Node`.
28
+ type EpisodeNode = RelationalNode & {
29
+ description?: string;
30
+ metadata?: Record<string, unknown>;
31
+ };
32
+
33
+ const DIRECTION_ICONS: Record<string, string> = {
34
+ east: "🌅",
35
+ south: "🔥",
36
+ west: "🌊",
37
+ north: "❄️",
38
+ };
39
+
40
+ function metaString(node: EpisodeNode | null, key: string): string | null {
41
+ const value = node?.metadata?.[key];
42
+ return typeof value === "string" && value ? value : null;
43
+ }
44
+
45
+ export default function EpisodePage({ params }: { params: Promise<{ id: string }> }) {
46
+ const { id: rawId } = use(params);
47
+ const id = decodeURIComponent(rawId);
48
+
49
+ const [episode, setEpisode] = useState<EpisodeNode | null>(null);
50
+ const [attention, setAttention] = useState<EpisodeNode[]>([]);
51
+ const [web, setWeb] = useState<{ nodes: EpisodeNode[]; edges: RelationalEdge[] } | null>(null);
52
+ const [loading, setLoading] = useState(true);
53
+ const [error, setError] = useState<string | null>(null);
54
+
55
+ const load = useCallback(async () => {
56
+ setLoading(true);
57
+ try {
58
+ const encoded = encodeURIComponent(id);
59
+ const [nodeRes, attentionRes, webRes] = await Promise.all([
60
+ fetch(`/api/nodes?kind=chronicle_episode&limit=all`),
61
+ fetch(`/api/nodes?kind=attention&parent_id=${encoded}&limit=all`),
62
+ fetch(`/api/nodes/${encoded}/web?depth=1`),
63
+ ]);
64
+
65
+ if (!nodeRes.ok) throw new Error(`Episodes: ${nodeRes.status}`);
66
+ const all: EpisodeNode[] = (await nodeRes.json()).nodes ?? [];
67
+ const found = all.find((n) => n.id === id) ?? null;
68
+ setEpisode(found);
69
+
70
+ setAttention(attentionRes.ok ? ((await attentionRes.json()).nodes ?? []) : []);
71
+
72
+ // A 404 here means the node is not in the wheel at all — distinct from a
73
+ // node with no relations, which is a 200 carrying only its own root.
74
+ setWeb(webRes.ok ? await webRes.json() : null);
75
+ setError(null);
76
+ } catch (e) {
77
+ setError(e instanceof Error ? e.message : String(e));
78
+ } finally {
79
+ setLoading(false);
80
+ }
81
+ }, [id]);
82
+
83
+ useEffect(() => {
84
+ load();
85
+ }, [load]);
86
+
87
+ const parentId = metaString(episode, "parent_id");
88
+ const neighbours = (web?.nodes ?? []).filter((n) => n.id !== id);
89
+ const parentHasEdge = Boolean(parentId && neighbours.some((n) => n.id === parentId));
90
+
91
+ if (loading) {
92
+ return <div className="p-6 max-w-4xl mx-auto text-sm text-muted-foreground">Loading…</div>;
93
+ }
94
+
95
+ if (error) {
96
+ return (
97
+ <div className="p-6 max-w-4xl mx-auto">
98
+ <Link href="/episodes" className="text-sm text-muted-foreground hover:underline">
99
+ ← Episodes
100
+ </Link>
101
+ <div className="mt-4 p-4 border rounded-lg bg-card">
102
+ <p className="text-sm font-medium">The chronicle did not answer.</p>
103
+ <p className="text-sm text-muted-foreground">{error}</p>
104
+ <button onClick={load} className="mw-btn mw-btn--ghost mt-2">
105
+ Try again
106
+ </button>
107
+ </div>
108
+ </div>
109
+ );
110
+ }
111
+
112
+ if (!episode) {
113
+ return (
114
+ <div className="p-6 max-w-4xl mx-auto">
115
+ <Link href="/episodes" className="text-sm text-muted-foreground hover:underline">
116
+ ← Episodes
117
+ </Link>
118
+ <p className="mt-4 text-sm text-muted-foreground">
119
+ No episode node with id <code className="font-mono">{id}</code>. The wheel answered and
120
+ does not hold it — 102 of 185 episode folders on disk have no node yet.
121
+ </p>
122
+ </div>
123
+ );
124
+ }
125
+
126
+ return (
127
+ <div className="p-6 max-w-4xl mx-auto space-y-6">
128
+ <div>
129
+ <Link href="/episodes" className="text-sm text-muted-foreground hover:underline">
130
+ ← Episodes
131
+ </Link>
132
+ <div className="flex items-start justify-between gap-3 flex-wrap mt-2">
133
+ <h1 className="text-2xl font-bold">{episode.name}</h1>
134
+ {episode.direction && (
135
+ <span className={`mw-badge mw-badge--${episode.direction}`}>
136
+ {DIRECTION_ICONS[episode.direction]} {episode.direction}
137
+ </span>
138
+ )}
139
+ </div>
140
+ <p className="font-mono text-xs text-muted-foreground mt-1 break-all">{episode.id}</p>
141
+ {episode.description && <p className="text-sm mt-3">{episode.description}</p>}
142
+ </div>
143
+
144
+ <section>
145
+ <h2 className="text-sm font-semibold mb-2">
146
+ Open attention {attention.length > 0 && `(${attention.length})`}
147
+ </h2>
148
+ {attention.length === 0 ? (
149
+ <p className="text-sm text-muted-foreground">
150
+ Nothing held open against this episode.
151
+ </p>
152
+ ) : (
153
+ <ul className="space-y-2">
154
+ {attention.map((item) => (
155
+ <li key={item.id} className="p-3 border rounded-lg bg-card">
156
+ <p className="text-sm font-medium">{item.name}</p>
157
+ {item.description && (
158
+ <p className="text-sm text-muted-foreground mt-1">{item.description}</p>
159
+ )}
160
+ </li>
161
+ ))}
162
+ </ul>
163
+ )}
164
+ </section>
165
+
166
+ <section>
167
+ <h2 className="text-sm font-semibold mb-2">
168
+ Relations {neighbours.length > 0 && `(${neighbours.length})`}
169
+ </h2>
170
+
171
+ {parentId && !parentHasEdge && (
172
+ <p className="mb-2 text-xs text-muted-foreground border rounded-lg p-3 bg-card">
173
+ This episode records a parent in <code>metadata.parent_id</code> (
174
+ <code className="break-all">{parentId}</code>) that was never written as a relation, so
175
+ it does not appear below and the graph cannot draw it. 101 of 106 containment links in
176
+ this wheel are in that state.
177
+ </p>
178
+ )}
179
+
180
+ {neighbours.length === 0 ? (
181
+ <p className="text-sm text-muted-foreground">
182
+ No relation reaches this episode. {parentId ? "See the note above." : ""}
183
+ </p>
184
+ ) : (
185
+ <ul className="space-y-2">
186
+ {neighbours.map((n) => {
187
+ const edge = (web?.edges ?? []).find(
188
+ (e) =>
189
+ (e.from_id === id && e.to_id === n.id) ||
190
+ (e.to_id === id && e.from_id === n.id),
191
+ );
192
+ return (
193
+ <li
194
+ key={n.id}
195
+ className="p-3 border rounded-lg bg-card flex items-baseline justify-between gap-3 flex-wrap"
196
+ >
197
+ <span className="min-w-0">
198
+ <span className="text-sm font-medium">{n.name}</span>
199
+ <span className="font-mono text-xs text-muted-foreground block break-all">
200
+ {n.id}
201
+ </span>
202
+ </span>
203
+ {edge && (
204
+ <span className="mw-badge shrink-0" title="relationship_type">
205
+ {edge.from_id === id ? "→" : "←"} {edge.relationship_type}
206
+ </span>
207
+ )}
208
+ </li>
209
+ );
210
+ })}
211
+ </ul>
212
+ )}
213
+
214
+ <Link
215
+ href={`/graph?scope=${encodeURIComponent(id)}`}
216
+ className="mw-btn mw-btn--ghost mt-3 inline-block"
217
+ >
218
+ Open in graph
219
+ </Link>
220
+ </section>
221
+ </div>
222
+ );
223
+ }
@@ -0,0 +1,222 @@
1
+ "use client";
2
+
3
+ /**
4
+ * The episodes surface — "what am I working on, and what came before it".
5
+ *
6
+ * `/api/nodes` has accepted `?kind=` and `?parent_id=` for some time, and no
7
+ * page ever passed either. So the only way to see an episode in this app was to
8
+ * find its dot on a wheel of 205, where 37% of the mass has no edge at all and
9
+ * the largest hubs are systemd units. The wheel could answer this question and
10
+ * was never asked it.
11
+ *
12
+ * Sorted by `metadata.occurred_at` when present, falling back to `created_at`.
13
+ * That fallback is not a nicety: `created_at` records when the wheel *learned*
14
+ * of an episode, not when the episode happened — 41 of 83 episodes disagree with
15
+ * their own date, and twelve May episodes all carry the same September timestamp
16
+ * from one import batch. Until `occurred_at` is backfilled this list is ordered
17
+ * by registration for those rows, and it says so rather than implying a history
18
+ * it does not have.
19
+ */
20
+
21
+ import { useCallback, useEffect, useMemo, useState } from "react";
22
+ import Link from "next/link";
23
+ import type { RelationalNode } from "@/lib/types";
24
+
25
+ // `description` lives on the store's node, not on ontology-core's — the same
26
+ // widening `app/nodes/page.tsx` declares as `NodeRecord`.
27
+ type Episode = RelationalNode & {
28
+ description?: string;
29
+ metadata?: Record<string, unknown>;
30
+ };
31
+
32
+ const DIRECTION_ICONS: Record<string, string> = {
33
+ east: "🌅",
34
+ south: "🔥",
35
+ west: "🌊",
36
+ north: "❄️",
37
+ };
38
+
39
+ /** `chronicle:2026-06-19-episode-068-extending-…` → `068`, or null. */
40
+ function episodeNumber(id: string): string | null {
41
+ const match = id.match(/episode-(\d+)/i);
42
+ return match ? match[1] : null;
43
+ }
44
+
45
+ /** The date the episode happened, if the wheel knows it apart from registration. */
46
+ function occurredAt(node: Episode): string | null {
47
+ const value = node.metadata?.occurred_at;
48
+ return typeof value === "string" && value ? value : null;
49
+ }
50
+
51
+ function sortKey(node: Episode): string {
52
+ return occurredAt(node) ?? node.created_at ?? "";
53
+ }
54
+
55
+ function formatDate(iso: string): string {
56
+ if (!iso) return "—";
57
+ return iso.slice(0, 10);
58
+ }
59
+
60
+ export default function EpisodesPage() {
61
+ const [episodes, setEpisodes] = useState<Episode[]>([]);
62
+ const [attentionCounts, setAttentionCounts] = useState<Record<string, number>>({});
63
+ const [loading, setLoading] = useState(true);
64
+ const [error, setError] = useState<string | null>(null);
65
+ const [query, setQuery] = useState("");
66
+
67
+ const load = useCallback(async () => {
68
+ setLoading(true);
69
+ try {
70
+ // limit=all, not the provider's 100-row default. The chronicle holds more
71
+ // episodes than a default page, and a list that silently ends is worse
72
+ // than one that refuses to load.
73
+ const [episodesRes, attentionRes] = await Promise.all([
74
+ fetch("/api/nodes?kind=chronicle_episode&limit=all"),
75
+ fetch("/api/nodes?kind=attention&limit=all"),
76
+ ]);
77
+
78
+ if (!episodesRes.ok) throw new Error(`Episodes: ${episodesRes.status}`);
79
+ const episodesBody = await episodesRes.json();
80
+ const list: Episode[] = episodesBody.nodes ?? [];
81
+
82
+ const counts: Record<string, number> = {};
83
+ if (attentionRes.ok) {
84
+ const attentionBody = await attentionRes.json();
85
+ for (const item of (attentionBody.nodes ?? []) as Episode[]) {
86
+ const parent = item.metadata?.parent_id;
87
+ if (typeof parent === "string") counts[parent] = (counts[parent] ?? 0) + 1;
88
+ }
89
+ }
90
+
91
+ setEpisodes([...list].sort((a, b) => sortKey(b).localeCompare(sortKey(a))));
92
+ setAttentionCounts(counts);
93
+ setError(null);
94
+ } catch (e) {
95
+ setError(e instanceof Error ? e.message : String(e));
96
+ } finally {
97
+ setLoading(false);
98
+ }
99
+ }, []);
100
+
101
+ useEffect(() => {
102
+ load();
103
+ }, [load]);
104
+
105
+ const visible = useMemo(() => {
106
+ const needle = query.trim().toLowerCase();
107
+ if (!needle) return episodes;
108
+ return episodes.filter(
109
+ (e) =>
110
+ e.name.toLowerCase().includes(needle) ||
111
+ e.id.toLowerCase().includes(needle) ||
112
+ (e.description ?? "").toLowerCase().includes(needle),
113
+ );
114
+ }, [episodes, query]);
115
+
116
+ /** How many rows are ordered by registration rather than by when they happened. */
117
+ const undatedCount = useMemo(
118
+ () => episodes.filter((e) => !occurredAt(e)).length,
119
+ [episodes],
120
+ );
121
+
122
+ return (
123
+ <div className="p-6 max-w-5xl mx-auto">
124
+ <div className="flex items-start justify-between mb-6 gap-4 flex-wrap">
125
+ <div>
126
+ <h1 className="text-2xl font-bold">Episodes</h1>
127
+ <p className="text-sm text-muted-foreground">
128
+ {loading ? "Reading the chronicle…" : `${episodes.length} episodes in the wheel`}
129
+ </p>
130
+ </div>
131
+ <input
132
+ value={query}
133
+ onChange={(e) => setQuery(e.target.value)}
134
+ placeholder="Filter by name or id"
135
+ className="mw-input max-w-xs"
136
+ aria-label="Filter episodes"
137
+ />
138
+ </div>
139
+
140
+ {undatedCount > 0 && !loading && (
141
+ <p className="mb-4 text-xs text-muted-foreground border rounded-lg p-3 bg-card">
142
+ {undatedCount} of {episodes.length} episodes have no <code>occurred_at</code>, so they
143
+ are ordered by when the wheel recorded them rather than by when they happened. That is
144
+ registration order, not history.
145
+ </p>
146
+ )}
147
+
148
+ {error && (
149
+ <div className="mb-4 p-4 border rounded-lg bg-card">
150
+ <p className="text-sm font-medium">The chronicle did not answer.</p>
151
+ <p className="text-sm text-muted-foreground">{error}</p>
152
+ <button onClick={load} className="mw-btn mw-btn--ghost mt-2">
153
+ Try again
154
+ </button>
155
+ </div>
156
+ )}
157
+
158
+ {!loading && episodes.length === 0 && !error && (
159
+ <p className="text-sm text-muted-foreground">
160
+ No node carries <code>metadata.kind: &quot;chronicle_episode&quot;</code>. The wheel is
161
+ reachable and holds no episodes — which is a different thing from the wheel being
162
+ unreachable.
163
+ </p>
164
+ )}
165
+
166
+ <ul className="space-y-2">
167
+ {visible.map((episode) => {
168
+ const number = episodeNumber(episode.id);
169
+ const open = attentionCounts[episode.id] ?? 0;
170
+ const dated = occurredAt(episode);
171
+ return (
172
+ <li key={episode.id}>
173
+ <Link
174
+ href={`/episodes/${encodeURIComponent(episode.id)}`}
175
+ className="block p-4 border rounded-lg bg-card hover:bg-secondary/50 transition-colors"
176
+ >
177
+ <div className="flex items-baseline justify-between gap-3 flex-wrap">
178
+ <div className="flex items-baseline gap-2 min-w-0">
179
+ {number && (
180
+ <span className="font-mono text-xs text-muted-foreground shrink-0">
181
+ {number}
182
+ </span>
183
+ )}
184
+ <span className="font-medium truncate">{episode.name}</span>
185
+ </div>
186
+ <div className="flex items-center gap-2 shrink-0">
187
+ {open > 0 && (
188
+ <span className="mw-badge" title={`${open} open attention items`}>
189
+ {open} attention
190
+ </span>
191
+ )}
192
+ {episode.direction && (
193
+ <span className={`mw-badge mw-badge--${episode.direction}`}>
194
+ {DIRECTION_ICONS[episode.direction]} {episode.direction}
195
+ </span>
196
+ )}
197
+ <span
198
+ className="font-mono text-xs text-muted-foreground"
199
+ title={dated ? "occurred_at" : "created_at — when the wheel learned this"}
200
+ >
201
+ {formatDate(dated ?? episode.created_at ?? "")}
202
+ {!dated && "*"}
203
+ </span>
204
+ </div>
205
+ </div>
206
+ {episode.description && (
207
+ <p className="text-sm text-muted-foreground mt-1 line-clamp-2">
208
+ {episode.description}
209
+ </p>
210
+ )}
211
+ </Link>
212
+ </li>
213
+ );
214
+ })}
215
+ </ul>
216
+
217
+ {!loading && visible.length === 0 && episodes.length > 0 && (
218
+ <p className="text-sm text-muted-foreground">Nothing matches “{query}”.</p>
219
+ )}
220
+ </div>
221
+ );
222
+ }