@medicine-wheel/app 0.6.4 → 0.8.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,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
+ }
@@ -98,10 +98,34 @@ export default function GraphPage() {
98
98
  const [graph, setGraph] = useState<MWGraphData>({ nodes: [], links: [] });
99
99
  const [selectedNode, setSelectedNode] = useState<MWGraphNode | null>(null);
100
100
  const [focusedNodeId, setFocusedNodeId] = useState<string | undefined>(undefined);
101
+ /**
102
+ * Scope is not focus.
103
+ *
104
+ * `focusedNodeId` zooms the viewport at a node and leaves all 205 mounted —
105
+ * useful, and not an answer to "I cannot navigate", because the field is still
106
+ * there behind the zoom. `scopeId` replaces the graph with one node's
107
+ * neighbourhood, so the canvas holds only what was asked for. Two names
108
+ * because they are two questions; collapsing them would make the control
109
+ * silently mean different things depending on how the page was entered.
110
+ */
111
+ const [scopeId, setScopeId] = useState<string | null>(null);
112
+ // 1, not 2. With the chronicle root at degree 82, depth 2 from any episode
113
+ // used to return the whole corpus — see the route's DEFAULT_DEPTH.
114
+ const [scopeDepth, setScopeDepth] = useState(1);
115
+ const [scopeMeta, setScopeMeta] = useState<{
116
+ count: number;
117
+ truncated: boolean;
118
+ hubs: { id: string; name: string; degree: number; reason: string; unexpanded: number }[];
119
+ rootName: string | null;
120
+ } | null>(null);
121
+ const [storeTotal, setStoreTotal] = useState<number | null>(null);
101
122
  const [highlightDirection, setHighlightDirection] = useState<DirectionParam | undefined>(undefined);
102
123
  const [radialSnap, setRadialSnap] = useState<"off" | "ring" | "sector">("off");
103
124
  const [loading, setLoading] = useState(true);
104
- const [showLabels, setShowLabels] = useState(true);
125
+ // `null` means "nobody has chosen" — the default then follows the crowding
126
+ // (see labelsAutoSuppressed). Once the user touches the toggle their choice
127
+ // sticks, including across a scope change.
128
+ const [showLabels, setShowLabels] = useState<boolean | null>(null);
105
129
  const [animationsEnabled, setAnimationsEnabled] = useState(
106
130
  DEFAULT_GRAPH_ANIMATION_ENABLED,
107
131
  );
@@ -166,22 +190,54 @@ export default function GraphPage() {
166
190
 
167
191
  const loadData = useCallback(async () => {
168
192
  try {
169
- const [nodesRes, edgesRes] = await Promise.all([fetch("/api/nodes"), fetch("/api/edges")]);
193
+ // Scoped and whole-wheel are different questions, not the same question
194
+ // with a filter. `focusedNodeId` already existed and only zooms the
195
+ // viewport — every one of the 205 nodes stays mounted, so "focus" was
196
+ // never a way to look at less. Scope replaces the data.
197
+ if (scopeId) {
198
+ const res = await fetch(
199
+ `/api/nodes/${encodeURIComponent(scopeId)}/web?depth=${scopeDepth}`,
200
+ );
201
+ if (!res.ok) {
202
+ toast.error(
203
+ res.status === 404
204
+ ? "That node is not in the wheel."
205
+ : "Could not read that neighbourhood.",
206
+ );
207
+ setScopeId(null);
208
+ return;
209
+ }
210
+ const web = await res.json();
211
+ setGraph(buildGraphData(web.nodes ?? [], web.edges ?? []));
212
+ setScopeMeta({
213
+ count: web.count ?? 0,
214
+ truncated: Boolean(web.truncated),
215
+ hubs: web.hubs ?? [],
216
+ rootName: web.root?.name ?? null,
217
+ });
218
+ return;
219
+ }
220
+
221
+ const [nodesRes, edgesRes] = await Promise.all([fetch("/api/nodes?limit=all"), fetch("/api/edges?limit=all")]);
170
222
  const nodesResponse = await nodesRes.json();
171
223
  const edgesData: RelationalEdge[] = await edgesRes.json();
172
224
 
173
- // API returns { nodes: [...], provider: '...', count: N }
225
+ // API returns { nodes: [...], provider: '...', count: N, total, truncated }
174
226
  const nodesData: RelationalNode[] = Array.isArray(nodesResponse)
175
227
  ? nodesResponse
176
228
  : nodesResponse.nodes || [];
177
229
 
178
230
  setGraph(buildGraphData(nodesData, edgesData));
231
+ setScopeMeta(null);
232
+ setStoreTotal(
233
+ Array.isArray(nodesResponse) ? nodesData.length : (nodesResponse.total ?? nodesData.length),
234
+ );
179
235
  } catch {
180
236
  toast.error("Failed to load graph data");
181
237
  } finally {
182
238
  setLoading(false);
183
239
  }
184
- }, []);
240
+ }, [scopeId, scopeDepth]);
185
241
 
186
242
  useEffect(() => {
187
243
  loadData();
@@ -204,12 +260,31 @@ export default function GraphPage() {
204
260
  const params = new URLSearchParams(window.location.search);
205
261
  const node = params.get("node");
206
262
  if (node) setFocusedNodeId(node);
263
+ // ?scope=<id> arrives from an episode page's "Open in graph" and means
264
+ // "show me this and its neighbours", not "zoom at this".
265
+ const scope = params.get("scope");
266
+ if (scope) {
267
+ setScopeId(scope);
268
+ setFocusedNodeId(scope);
269
+ }
207
270
  const direction = params.get("direction");
208
271
  if (direction && (DIRECTION_NAMES as readonly string[]).includes(direction)) {
209
272
  setHighlightDirection(direction as DirectionParam);
210
273
  }
211
274
  }, []);
212
275
 
276
+ // Escape leaves the neighbourhood and returns the whole wheel. It is the only
277
+ // way out that needs no aiming, which matters when the reason you scoped was
278
+ // that you could not find anything.
279
+ useEffect(() => {
280
+ if (!scopeId) return;
281
+ const onKey = (event: KeyboardEvent) => {
282
+ if (event.key === "Escape") setScopeId(null);
283
+ };
284
+ window.addEventListener("keydown", onKey);
285
+ return () => window.removeEventListener("keydown", onKey);
286
+ }, [scopeId]);
287
+
213
288
  // Surface the deep-linked node in the side panel once data arrives.
214
289
  useEffect(() => {
215
290
  if (!focusedNodeId || graph.nodes.length === 0) return;
@@ -217,10 +292,58 @@ export default function GraphPage() {
217
292
  if (node) setSelectedNode(node);
218
293
  }, [focusedNodeId, graph.nodes]);
219
294
 
220
- const graphData = useMemo<MWGraphData>(
221
- () => (focusedNodeId ? { ...graph, focusedNodeId } : graph),
222
- [graph, focusedNodeId],
223
- );
295
+ /**
296
+ * The node you asked about must be findable in the picture.
297
+ *
298
+ * Before this, arriving from an episode page put your episode somewhere in a
299
+ * field of identical dots — the graph knew which node you meant (it zoomed at
300
+ * it) and gave you no way to see which one it was once you panned. Marking is
301
+ * done here rather than in `graph-viz` because `MedicineWheelNode` already
302
+ * reads `size`, `color` and `opacity` off each node; the renderer needs no
303
+ * new concept, only better data.
304
+ *
305
+ * Hubs are dimmed rather than hidden. The chronicle root genuinely is in your
306
+ * neighbourhood — every episode belongs to it — but it is context, not the
307
+ * subject, and drawing it at full weight makes the one true container compete
308
+ * with the thing you were looking at.
309
+ */
310
+ const graphData = useMemo<MWGraphData>(() => {
311
+ const hubIds = new Set((scopeMeta?.hubs ?? []).map((h) => h.id));
312
+ const marked =
313
+ scopeId || hubIds.size > 0
314
+ ? {
315
+ ...graph,
316
+ nodes: graph.nodes.map((n) => {
317
+ if (n.id === scopeId) {
318
+ return {
319
+ ...n,
320
+ size: (n.size ?? 8) * 1.9,
321
+ color: "#ffd75e",
322
+ opacity: 1,
323
+ };
324
+ }
325
+ if (hubIds.has(n.id)) {
326
+ return { ...n, size: (n.size ?? 8) * 1.25, opacity: 0.45 };
327
+ }
328
+ return n;
329
+ }),
330
+ }
331
+ : graph;
332
+
333
+ return focusedNodeId ? { ...marked, focusedNodeId } : marked;
334
+ }, [graph, focusedNodeId, scopeId, scopeMeta]);
335
+
336
+ /**
337
+ * Labels are legible up to a point and then they are texture.
338
+ *
339
+ * At 83 nodes crushed into one quadrant the labels stacked into unreadable
340
+ * bands — worse than absent, because they cost the whole canvas its clarity
341
+ * while conveying nothing. Off past the threshold, and the existing toggle
342
+ * still wins: this sets the default, it does not take the control away.
343
+ */
344
+ const LABEL_LEGIBLE_UP_TO = 35;
345
+ const labelsAutoSuppressed = graph.nodes.length > LABEL_LEGIBLE_UP_TO;
346
+ const labelsVisible = showLabels ?? !labelsAutoSuppressed;
224
347
 
225
348
  useEffect(() => {
226
349
  layoutStoreRef.current = layoutStore;
@@ -430,10 +553,15 @@ export default function GraphPage() {
430
553
  </div>
431
554
  <div className="flex flex-wrap gap-2">
432
555
  <button
433
- onClick={() => setShowLabels(!showLabels)}
434
- className={`min-h-11 px-3 py-1.5 rounded text-sm ${showLabels ? "bg-white/10" : "bg-white/5"}`}
556
+ onClick={() => setShowLabels(!labelsVisible)}
557
+ className={`min-h-11 px-3 py-1.5 rounded text-sm ${labelsVisible ? "bg-white/10" : "bg-white/5"}`}
558
+ title={
559
+ showLabels === null && labelsAutoSuppressed
560
+ ? `Off by default above ${LABEL_LEGIBLE_UP_TO} nodes — they overlap into bands`
561
+ : undefined
562
+ }
435
563
  >
436
- Labels {showLabels ? "ON" : "OFF"}
564
+ Labels {labelsVisible ? "ON" : "OFF"}
437
565
  </button>
438
566
  <button
439
567
  onClick={() =>
@@ -450,6 +578,64 @@ export default function GraphPage() {
450
578
  </div>
451
579
  </div>
452
580
 
581
+ {scopeId && (
582
+ <div className="mb-3 rounded-xl border border-yellow-400/30 bg-yellow-400/5 px-3 py-2 text-sm">
583
+ {/* min-w-0 on the growing child, so a long chronicle id truncates
584
+ instead of pushing the depth control and the exit off-screen —
585
+ the banner clipped its own warning at this width before. */}
586
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-2">
587
+ <span className="min-w-0 flex-1 text-gray-300">
588
+ <strong className="text-yellow-300">
589
+ {scopeMeta?.count ?? graph.nodes.length}
590
+ </strong>{" "}
591
+ nodes, {scopeDepth} {scopeDepth === 1 ? "hop" : "hops"} from{" "}
592
+ <span className="text-gray-100">{scopeMeta?.rootName ?? scopeId}</span>
593
+ </span>
594
+ <label className="inline-flex shrink-0 items-center gap-1.5 text-xs text-gray-400">
595
+ Depth
596
+ <select
597
+ value={scopeDepth}
598
+ onChange={(e) => setScopeDepth(Number(e.target.value))}
599
+ className="rounded bg-white/5 px-1.5 py-0.5 text-gray-200"
600
+ aria-label="Neighbourhood depth"
601
+ >
602
+ {[1, 2, 3, 4, 5, 6].map((d) => (
603
+ <option key={d} value={d}>
604
+ {d}
605
+ </option>
606
+ ))}
607
+ </select>
608
+ </label>
609
+ <button
610
+ onClick={() => setScopeId(null)}
611
+ className="min-h-8 shrink-0 rounded bg-white/5 px-2 py-1 text-xs hover:bg-white/10 inline-flex items-center gap-1.5"
612
+ >
613
+ <X className="h-3.5 w-3.5" /> Whole wheel
614
+ <span className="text-gray-500">(Esc)</span>
615
+ </button>
616
+ </div>
617
+
618
+ {/* Say what was held back. A neighbourhood that quietly stopped at a
619
+ container is the same class of lie as a list that quietly
620
+ stopped at 100 rows. */}
621
+ {(scopeMeta?.hubs?.length ?? 0) > 0 && (
622
+ <p className="mt-1.5 text-xs text-gray-500">
623
+ {scopeMeta!.hubs.map((h) => (
624
+ <span key={h.id} className="mr-3 inline-block">
625
+ <span className="text-gray-400">{h.name}</span> holds {h.degree} relations —
626
+ shown, not expanded{h.unexpanded > 0 ? ` (${h.unexpanded} beyond it)` : ""}
627
+ </span>
628
+ ))}
629
+ </p>
630
+ )}
631
+ {scopeMeta?.truncated && (
632
+ <p className="mt-1 text-xs text-gray-500">
633
+ stopped at the depth limit — there is more further out
634
+ </p>
635
+ )}
636
+ </div>
637
+ )}
638
+
453
639
  <div className="flex flex-col lg:flex-row gap-4 lg:gap-6">
454
640
  <div className="relative flex-1 rounded-xl border border-white/10 overflow-hidden">
455
641
  {!loading && graph.nodes.length > 0 && (
@@ -477,7 +663,7 @@ export default function GraphPage() {
477
663
  data={graphData}
478
664
  height={graphHeight}
479
665
  darkMode
480
- showNodeLabels={showLabels}
666
+ showNodeLabels={labelsVisible}
481
667
  showMiniMap={!isMobile}
482
668
  animationsEnabled={animationsEnabled}
483
669
  nodePositions={activeLayout.positions}
@@ -633,6 +819,14 @@ export default function GraphPage() {
633
819
  <div><span className="text-xs text-gray-500">Type</span><p className="capitalize">{selectedNode.type}</p></div>
634
820
  {selectedNode.direction && <div><span className="text-xs text-gray-500">Direction</span><p className="capitalize">{selectedNode.direction}</p></div>}
635
821
  <div><span className="text-xs text-gray-500">ID</span><p className="text-xs text-gray-400 font-mono break-all">{selectedNode.id}</p></div>
822
+ {scopeId !== selectedNode.id && (
823
+ <button
824
+ onClick={() => setScopeId(selectedNode.id)}
825
+ className="inline-flex w-full items-center justify-center gap-2 rounded-md bg-yellow-400/15 px-3 py-2 text-sm text-yellow-200 hover:bg-yellow-400/25"
826
+ >
827
+ <Route className="h-4 w-4" /> Show only its neighbourhood
828
+ </button>
829
+ )}
636
830
  <Link
637
831
  href={`/nodes?node=${encodeURIComponent(selectedNode.id)}`}
638
832
  className="inline-flex w-full items-center justify-center gap-2 rounded-md bg-white/10 px-3 py-2 text-sm text-white hover:bg-white/15"
@@ -644,9 +838,25 @@ export default function GraphPage() {
644
838
  )}
645
839
 
646
840
  <div className="rounded-xl border border-white/10 p-4">
647
- <h3 className="text-sm font-semibold text-gray-400 mb-3">Graph Stats</h3>
841
+ <h3 className="text-sm font-semibold text-gray-400 mb-3">
842
+ Graph Stats
843
+ {/* This panel read "100 Nodes / 100 Relations" against a store of
844
+ 205 and 191 until 2026-09-03, because the page fetched without
845
+ a limit and took the provider's default page in silence. A
846
+ count with nothing to compare it to cannot be doubted, which
847
+ is what made it a lie rather than a number. */}
848
+ {scopeId && <span className="ml-2 font-normal text-yellow-300/80">scoped</span>}
849
+ </h3>
648
850
  <div className="grid grid-cols-2 gap-3 text-center">
649
- <div><p className="text-2xl font-bold">{graph.nodes.length}</p><p className="text-xs text-gray-500">Nodes</p></div>
851
+ <div>
852
+ <p className="text-2xl font-bold">{graph.nodes.length}</p>
853
+ <p className="text-xs text-gray-500">
854
+ Nodes
855
+ {storeTotal !== null && graph.nodes.length < storeTotal && (
856
+ <span className="text-gray-600"> of {storeTotal}</span>
857
+ )}
858
+ </p>
859
+ </div>
650
860
  <div><p className="text-2xl font-bold">{graph.links.length}</p><p className="text-xs text-gray-500">Relations</p></div>
651
861
  <div><p className="text-2xl font-bold">{ceremoniedCount}</p><p className="text-xs text-gray-500">Ceremonied</p></div>
652
862
  <div><p className="text-2xl font-bold">{directionCount}</p><p className="text-xs text-gray-500">Directions</p></div>
@@ -50,13 +50,16 @@ export default function NodesPage() {
50
50
  const loadNodes = useCallback(async () => {
51
51
  setLoading(true);
52
52
  try {
53
- const params = new URLSearchParams();
53
+ // The list is client-side paginated below, so it needs the whole store.
54
+ // Without this the route applies its 100-row default and the table silently
55
+ // ends at 100 of 205 with no page control to reveal the rest.
56
+ const params = new URLSearchParams({ limit: "all" });
54
57
  if (filterType !== "all") params.set("type", filterType);
55
58
  if (filterDirection !== "all") params.set("direction", filterDirection);
56
59
 
57
60
  const [nodesRes, edgesRes] = await Promise.all([
58
61
  fetch(`/api/nodes?${params.toString()}`),
59
- fetch("/api/edges"),
62
+ fetch("/api/edges?limit=all"),
60
63
  ]);
61
64
  const nodesResponse = await nodesRes.json();
62
65
  const edgesData: RelationalEdge[] = edgesRes.ok ? await edgesRes.json() : [];
@@ -64,7 +64,7 @@ export default function RelationsPage() {
64
64
  const loadData = useCallback(async () => {
65
65
  setLoading(true);
66
66
  try {
67
- const [nodesRes, edgesRes] = await Promise.all([fetch("/api/nodes"), fetch("/api/edges")]);
67
+ const [nodesRes, edgesRes] = await Promise.all([fetch("/api/nodes?limit=all"), fetch("/api/edges?limit=all")]);
68
68
  const nodesResponse = await nodesRes.json();
69
69
  const edgesData = await edgesRes.json();
70
70
  const nodesData: NodeRecord[] = Array.isArray(nodesResponse) ? nodesResponse : (nodesResponse.nodes || []);
@@ -13,6 +13,7 @@ import { WorkspacesPanel, WORKSPACES, type Workspace } from "@/components/worksp
13
13
  const NAV_ITEMS = [
14
14
  { href: "/", label: "Wheel", color: "var(--mw-east)" },
15
15
  { href: "/graph", label: "Graph", color: "var(--mw-primary)" },
16
+ { href: "/episodes", label: "Episodes", color: "var(--mw-node-ancestor)" },
16
17
  { href: "/nodes", label: "Nodes", color: "var(--mw-node-land)" },
17
18
  { href: "/relations", label: "Relations", color: "var(--mw-node-future)" },
18
19
  { href: "/ceremonies", label: "Ceremonies", color: "var(--mw-south)" },
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Shared paging contract for the wheel's read routes.
3
+ *
4
+ * The provider's `getAllNodes()` / `getAllEdges()` default to 100 rows. A bare
5
+ * `GET /api/nodes` therefore returned 100 of the store's 205 nodes, and the graph
6
+ * page — which sends no parameters — drew that window while its own panel read
7
+ * "100 Nodes / 100 Relations". Of the 100 it drew, 53 had no drawable edge,
8
+ * because 27 of the 100 delivered edges pointed at nodes that were never sent.
9
+ *
10
+ * Nothing was broken. Every layer did what it was told. The lie was that no layer
11
+ * ever said how much it had left out.
12
+ *
13
+ * So both routes accept the same `limit`, and both report enough for a caller to
14
+ * know it holds a page: `?limit=all` for the whole store, `?limit=<n>` for n.
15
+ * This lives in `lib/` rather than in either route because a second hand-written
16
+ * copy of a validation rule is how the two drift apart — the same defect this
17
+ * repo just removed from `PersonRole`.
18
+ */
19
+ import { NextResponse } from "next/server";
20
+
21
+ /** The provider's own default page size, restated so a route can report honestly. */
22
+ export const DEFAULT_PAGE_SIZE = 100;
23
+
24
+ /** Sentinel meaning "the whole store", distinct from "no limit given". */
25
+ export const LIMIT_ALL = "all";
26
+
27
+ /**
28
+ * Returns the requested page size, `null` for "everything", or a 400 response.
29
+ *
30
+ * An unparseable limit is refused rather than coerced. Falling back to the
31
+ * default on bad input would reintroduce the silent truncation this parameter
32
+ * exists to close, and would do it precisely when a caller had tried to ask.
33
+ */
34
+ export function parseLimit(raw: string | null): number | null | NextResponse {
35
+ if (raw === null || raw === "") return DEFAULT_PAGE_SIZE;
36
+ if (raw === LIMIT_ALL) return null;
37
+
38
+ const parsed = Number(raw);
39
+ if (!Number.isInteger(parsed) || parsed < 1) {
40
+ return NextResponse.json(
41
+ {
42
+ error: `Invalid limit: ${raw} — nothing was returned.`,
43
+ hint: `limit must be a positive integer, or "${LIMIT_ALL}" for the whole store.`,
44
+ },
45
+ { status: 400 },
46
+ );
47
+ }
48
+ return parsed;
49
+ }