@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.
- package/README.md +30 -0
- package/app/accountability/page.tsx +2 -2
- package/app/api/ceremonies/[id]/route.ts +24 -0
- package/app/api/ceremonies/route.ts +14 -1
- package/app/api/edges/route.ts +20 -2
- package/app/api/nodes/[id]/web/route.ts +287 -0
- package/app/api/nodes/route.ts +44 -11
- package/app/episodes/[id]/page.tsx +223 -0
- package/app/episodes/page.tsx +222 -0
- package/app/graph/page.tsx +328 -30
- package/app/nodes/page.tsx +5 -2
- package/app/relations/page.tsx +1 -1
- package/components/navigation.tsx +1 -0
- package/components/workspaces-panel.tsx +7 -9
- package/lib/api-paging.ts +49 -0
- package/lib/ceremony-response.ts +21 -0
- package/package.json +24 -24
|
@@ -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: "chronicle_episode"</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
|
+
}
|