@medicine-wheel/app 0.10.0 → 0.12.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.
@@ -70,7 +70,35 @@ export async function PATCH(request: Request, context: RouteContext) {
70
70
  }
71
71
 
72
72
  const store = await createProvider();
73
- const node = await store.updateNode(id, parsed.data);
73
+
74
+ // `metadata` MERGES. It used to replace, and that cost 76 episode nodes
75
+ // their `kind`, `contract`, `schema_version`, `root`, `relative_path`,
76
+ // `parent_id`, `status` and `source_issue` on 2026-09-08 — a migration sent
77
+ // `{metadata: {occurred_at}}` intending to add one key, got 200 OK seventy-six
78
+ // times, and silently emptied the rest. `chronicle_episode` fell from 85 to 9.
79
+ //
80
+ // Replace-on-PATCH is a landmine because every caller reasonably assumes
81
+ // otherwise: PATCH means "change these fields", and a nested object that
82
+ // silently drops its siblings breaks that everywhere it is used. A caller
83
+ // that genuinely wants to clear a key now says so with `null`.
84
+ let patch = parsed.data;
85
+ if (patch.metadata) {
86
+ const existing = await store.getNode(id);
87
+ if (existing) {
88
+ const merged: Record<string, unknown> = {
89
+ ...(existing.metadata ?? {}),
90
+ ...patch.metadata,
91
+ };
92
+ // Explicit null removes a key — the only way to shrink metadata, and it
93
+ // has to be asked for rather than being the accidental default.
94
+ for (const [key, value] of Object.entries(patch.metadata)) {
95
+ if (value === null) delete merged[key];
96
+ }
97
+ patch = { ...patch, metadata: merged };
98
+ }
99
+ }
100
+
101
+ const node = await store.updateNode(id, patch);
74
102
  return NextResponse.json({ success: true, node, provider: detectProvider() });
75
103
  } catch (error: unknown) {
76
104
  if (error instanceof NodeNotFoundError) return notFound(error.nodeId);
@@ -23,6 +23,7 @@ import {
23
23
  import { type RelationalNode, type RelationalEdge, DIRECTION_COLORS } from "@/lib/types";
24
24
  import { cn } from "@/lib/utils";
25
25
  import {
26
+ applyLineageLayout,
26
27
  applyWheelLayout,
27
28
  buildGraphData,
28
29
  type MWGraphData,
@@ -74,11 +75,17 @@ const GRAPH_COMPONENT_LINKS = [
74
75
  { href: "/accountability", label: "Accountability", icon: ShieldCheck },
75
76
  ];
76
77
 
77
- function graphNodePositions(data: MWGraphData): MWGraphNodePositions {
78
- const laidOut = applyWheelLayout({
79
- nodes: data.nodes.map((node) => ({ ...node })),
80
- links: data.links,
81
- });
78
+ function graphNodePositions(
79
+ data: MWGraphData,
80
+ mode: "wheel" | "lineage" = "wheel",
81
+ ): MWGraphNodePositions {
82
+ const laidOut =
83
+ mode === "lineage"
84
+ ? applyLineageLayout({ nodes: data.nodes.map((node) => ({ ...node })), links: data.links })
85
+ : applyWheelLayout({
86
+ nodes: data.nodes.map((node) => ({ ...node })),
87
+ links: data.links,
88
+ });
82
89
  const positions: MWGraphNodePositions = {};
83
90
 
84
91
  for (const node of laidOut.nodes) {
@@ -119,6 +126,18 @@ export default function GraphPage() {
119
126
  rootName: string | null;
120
127
  } | null>(null);
121
128
  const [storeTotal, setStoreTotal] = useState<number | null>(null);
129
+ /**
130
+ * The wheel answers "what direction is this?" and cannot answer "what came
131
+ * before this?" — a circle has no before. Lineage lays x as time and y as
132
+ * direction, which is only honest because episodes carry `occurred_at`:
133
+ * `created_at` is when the wheel LEARNED of a node, and 34 of 76 episodes
134
+ * disagreed with it, episode 001 by four months.
135
+ */
136
+ const [layoutMode, setLayoutMode] = useState<"wheel" | "lineage">("wheel");
137
+ const [lineageMeta, setLineageMeta] = useState<{
138
+ span: { from: string; to: string } | null;
139
+ undated: number;
140
+ } | null>(null);
122
141
  const [highlightDirection, setHighlightDirection] = useState<DirectionParam | undefined>(undefined);
123
142
  const [radialSnap, setRadialSnap] = useState<"off" | "ring" | "sector">("off");
124
143
  const [loading, setLoading] = useState(true);
@@ -352,13 +371,35 @@ export default function GraphPage() {
352
371
  useEffect(() => {
353
372
  if (!layoutsHydrated || graph.nodes.length === 0) return;
354
373
 
374
+ // Choosing lineage re-seeds unconditionally: it is a different question
375
+ // about the same graph, not a nudge to the current arrangement, and keeping
376
+ // a saved wheel disposition would leave the beings where the circle put
377
+ // them while claiming to show time.
378
+ if (layoutMode === "lineage") {
379
+ const laid = applyLineageLayout({
380
+ nodes: graph.nodes.map((n) => ({ ...n })),
381
+ links: graph.links,
382
+ });
383
+ const positions: MWGraphNodePositions = {};
384
+ for (const n of laid.nodes) {
385
+ if (typeof n.x === "number" && typeof n.y === "number") {
386
+ positions[n.id] = { x: n.x, y: n.y };
387
+ }
388
+ }
389
+ setLineageMeta({ span: laid.span, undated: laid.undated.length });
390
+ saveLayoutStore(upsertCurrentGraphLayout(layoutStoreRef.current, positions));
391
+ return;
392
+ }
393
+
394
+ setLineageMeta(null);
395
+
355
396
  const activeLayout = getActiveGraphLayout(layoutStoreRef.current);
356
397
  if (Object.keys(activeLayout.positions).length > 0) return;
357
398
 
358
399
  saveLayoutStore(
359
400
  upsertCurrentGraphLayout(layoutStoreRef.current, graphNodePositions(graph)),
360
401
  );
361
- }, [graph, layoutsHydrated, saveLayoutStore]);
402
+ }, [graph, layoutsHydrated, saveLayoutStore, layoutMode]);
362
403
 
363
404
  const ceremoniedCount = useMemo(
364
405
  () => graph.links.filter((l) => l.ceremonyHonored).length,
@@ -572,6 +613,17 @@ export default function GraphPage() {
572
613
  >
573
614
  Snap {radialSnap === "off" ? "OFF" : radialSnap.toUpperCase()}
574
615
  </button>
616
+ <button
617
+ onClick={() => setLayoutMode(layoutMode === "wheel" ? "lineage" : "wheel")}
618
+ className={`min-h-11 px-3 py-1.5 rounded text-sm ${layoutMode === "lineage" ? "bg-yellow-400/20 text-yellow-200" : "bg-white/5 hover:bg-white/10"}`}
619
+ title={
620
+ layoutMode === "wheel"
621
+ ? "Lay the beings out by when they happened, left to right"
622
+ : "Return to the four-direction wheel"
623
+ }
624
+ >
625
+ {layoutMode === "wheel" ? "Wheel" : "Lineage"}
626
+ </button>
575
627
  <button onClick={loadData} className="min-h-11 px-3 py-1.5 rounded text-sm bg-white/5 hover:bg-white/10 inline-flex items-center gap-2">
576
628
  <RefreshCw className="h-4 w-4" /> Refresh
577
629
  </button>
@@ -636,6 +688,29 @@ export default function GraphPage() {
636
688
  </div>
637
689
  )}
638
690
 
691
+ {layoutMode === "lineage" && lineageMeta && (
692
+ <div className="mb-3 rounded-xl border border-yellow-400/30 bg-yellow-400/5 px-3 py-2 text-sm">
693
+ <span className="text-gray-300">
694
+ Laid out by when things happened
695
+ {lineageMeta.span && (
696
+ <>
697
+ {" — "}
698
+ <span className="text-yellow-300">{lineageMeta.span.from}</span> to{" "}
699
+ <span className="text-yellow-300">{lineageMeta.span.to}</span>
700
+ </>
701
+ )}
702
+ . Left to right is time; the bands are the four directions.
703
+ </span>
704
+ {lineageMeta.undated > 0 && (
705
+ <p className="mt-1 text-xs text-gray-500">
706
+ {lineageMeta.undated} of {graph.nodes.length} carry no{" "}
707
+ <code>occurred_at</code> and are placed by when the wheel recorded them —
708
+ registration order, not history.
709
+ </p>
710
+ )}
711
+ </div>
712
+ )}
713
+
639
714
  <div className="flex flex-col lg:flex-row gap-4 lg:gap-6">
640
715
  <div className="relative flex-1 rounded-xl border border-white/10 overflow-hidden">
641
716
  {!loading && graph.nodes.length > 0 && (
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@medicine-wheel/app",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "Medicine Wheel — Interactive visual layer for Indigenous relational research with Four Directions, ceremonies, and narrative arcs",
5
5
  "bin": {
6
6
  "mw": "dist/cli/mw.js",
@@ -85,29 +85,29 @@
85
85
  "release:major": "npm run version:major && npm run publish:all && npm run release:commit"
86
86
  },
87
87
  "dependencies": {
88
- "@medicine-wheel/ceremonial-diary": "^0.10.0",
89
- "@medicine-wheel/ceremony-protocol": "^0.10.0",
90
- "@medicine-wheel/community-review": "^0.10.0",
91
- "@medicine-wheel/consent-lifecycle": "^0.10.0",
92
- "@medicine-wheel/creative-orientation": "^0.10.0",
93
- "@medicine-wheel/data-store": "^0.10.0",
94
- "@medicine-wheel/data-store-postgres": "^0.10.0",
95
- "@medicine-wheel/fire-keeper": "^0.10.0",
96
- "@medicine-wheel/github-ceremony": "^0.10.0",
97
- "@medicine-wheel/graph-viz": "^0.10.0",
98
- "@medicine-wheel/importance-unit": "^0.10.0",
99
- "@medicine-wheel/mcp": "^4.10.0",
100
- "@medicine-wheel/narrative-cluster": "^0.10.0",
101
- "@medicine-wheel/narrative-engine": "^0.10.0",
102
- "@medicine-wheel/ontology-core": "^0.10.0",
103
- "@medicine-wheel/perception-layer": "^0.10.0",
104
- "@medicine-wheel/prompt-decomposition": "^0.10.0",
105
- "@medicine-wheel/relational-index": "^0.10.0",
106
- "@medicine-wheel/relational-query": "^0.10.0",
107
- "@medicine-wheel/session-reader": "^0.10.0",
108
- "@medicine-wheel/storage-provider": "^0.10.0",
109
- "@medicine-wheel/transformation-tracker": "^0.10.0",
110
- "@medicine-wheel/ui-components": "^0.10.0",
88
+ "@medicine-wheel/ceremonial-diary": "^0.12.0",
89
+ "@medicine-wheel/ceremony-protocol": "^0.12.0",
90
+ "@medicine-wheel/community-review": "^0.12.0",
91
+ "@medicine-wheel/consent-lifecycle": "^0.12.0",
92
+ "@medicine-wheel/creative-orientation": "^0.12.0",
93
+ "@medicine-wheel/data-store": "^0.12.0",
94
+ "@medicine-wheel/data-store-postgres": "^0.12.0",
95
+ "@medicine-wheel/fire-keeper": "^0.12.0",
96
+ "@medicine-wheel/github-ceremony": "^0.12.0",
97
+ "@medicine-wheel/graph-viz": "^0.12.0",
98
+ "@medicine-wheel/importance-unit": "^0.12.0",
99
+ "@medicine-wheel/mcp": "^4.12.0",
100
+ "@medicine-wheel/narrative-cluster": "^0.12.0",
101
+ "@medicine-wheel/narrative-engine": "^0.12.0",
102
+ "@medicine-wheel/ontology-core": "^0.12.0",
103
+ "@medicine-wheel/perception-layer": "^0.12.0",
104
+ "@medicine-wheel/prompt-decomposition": "^0.12.0",
105
+ "@medicine-wheel/relational-index": "^0.12.0",
106
+ "@medicine-wheel/relational-query": "^0.12.0",
107
+ "@medicine-wheel/session-reader": "^0.12.0",
108
+ "@medicine-wheel/storage-provider": "^0.12.0",
109
+ "@medicine-wheel/transformation-tracker": "^0.12.0",
110
+ "@medicine-wheel/ui-components": "^0.12.0",
111
111
  "@neondatabase/serverless": "^0.10.0",
112
112
  "@xyflow/react": "^12.3.0",
113
113
  "clsx": "^2.1.1",