@medicine-wheel/app 0.4.9 → 0.4.12
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/app/accountability/page.tsx +5 -1
- package/app/api/inquiry-weaves/[id]/route.ts +28 -0
- package/app/api/inquiry-weaves/route.ts +71 -0
- package/app/ceremonies/page.tsx +35 -10
- package/app/narrative/page.tsx +9 -5
- package/app/nodes/page.tsx +2 -1
- package/app/relations/page.tsx +11 -2
- package/lib/format-time.ts +43 -0
- package/package.json +21 -21
|
@@ -16,6 +16,7 @@ export default function AccountabilityPage() {
|
|
|
16
16
|
const [ceremonies, setCeremonies] = useState<any[]>([]);
|
|
17
17
|
const [beats, setBeats] = useState<any[]>([]);
|
|
18
18
|
const [expandedRes, setExpandedRes] = useState<string | null>(null);
|
|
19
|
+
const [loading, setLoading] = useState(true);
|
|
19
20
|
|
|
20
21
|
useEffect(() => {
|
|
21
22
|
Promise.all([
|
|
@@ -32,7 +33,7 @@ export default function AccountabilityPage() {
|
|
|
32
33
|
setEdges(Array.isArray(e) ? e : []);
|
|
33
34
|
setCeremonies(Array.isArray(c) ? c : []);
|
|
34
35
|
setBeats(Array.isArray(b) ? b : []);
|
|
35
|
-
}).catch(() => {});
|
|
36
|
+
}).catch(() => {}).finally(() => setLoading(false));
|
|
36
37
|
}, []);
|
|
37
38
|
|
|
38
39
|
const ceremoniedCount = edges.filter((e: any) => e.ceremony_honored).length;
|
|
@@ -42,6 +43,8 @@ export default function AccountabilityPage() {
|
|
|
42
43
|
<h1 className="text-2xl font-bold mb-2">Accountability & Frameworks</h1>
|
|
43
44
|
<p className="text-sm text-muted-foreground mb-6">Relational accountability metrics and Indigenous research frameworks</p>
|
|
44
45
|
|
|
46
|
+
{loading && <div className="text-center py-16 text-muted-foreground">Loading accountability metrics…</div>}
|
|
47
|
+
{!loading && (<>
|
|
45
48
|
{/* Metric Cards */}
|
|
46
49
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
|
47
50
|
<div className="p-4 rounded-lg border bg-card text-center">
|
|
@@ -92,6 +95,7 @@ export default function AccountabilityPage() {
|
|
|
92
95
|
</div>
|
|
93
96
|
))}
|
|
94
97
|
</div>
|
|
98
|
+
</>)}
|
|
95
99
|
</div>
|
|
96
100
|
);
|
|
97
101
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { createProvider, detectProvider } from "@medicine-wheel/storage-provider";
|
|
3
|
+
|
|
4
|
+
export async function GET(
|
|
5
|
+
_request: Request,
|
|
6
|
+
context: { params: Promise<{ id: string }> },
|
|
7
|
+
) {
|
|
8
|
+
try {
|
|
9
|
+
const params = await context.params;
|
|
10
|
+
const store = await createProvider();
|
|
11
|
+
const inquiryWeave = await store.getInquiryWeave(params.id);
|
|
12
|
+
|
|
13
|
+
if (!inquiryWeave) {
|
|
14
|
+
return NextResponse.json({
|
|
15
|
+
inquiry_weave: null,
|
|
16
|
+
provider: detectProvider(),
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return NextResponse.json({
|
|
21
|
+
inquiry_weave: inquiryWeave,
|
|
22
|
+
provider: detectProvider(),
|
|
23
|
+
});
|
|
24
|
+
} catch (error: unknown) {
|
|
25
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
26
|
+
return NextResponse.json({ error: message }, { status: 500 });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import {
|
|
3
|
+
createProvider,
|
|
4
|
+
detectProvider,
|
|
5
|
+
type InquiryWeaveFilters,
|
|
6
|
+
type WeaveRecord,
|
|
7
|
+
} from "@medicine-wheel/storage-provider";
|
|
8
|
+
|
|
9
|
+
export async function GET(request: Request) {
|
|
10
|
+
try {
|
|
11
|
+
const { searchParams } = new URL(request.url);
|
|
12
|
+
const filters = inquiryWeaveFiltersFromSearchParams(searchParams);
|
|
13
|
+
const store = await createProvider();
|
|
14
|
+
const inquiryWeaves = await store.listInquiryWeaves(filters);
|
|
15
|
+
|
|
16
|
+
return NextResponse.json({
|
|
17
|
+
inquiry_weaves: inquiryWeaves,
|
|
18
|
+
provider: detectProvider(),
|
|
19
|
+
count: inquiryWeaves.length,
|
|
20
|
+
});
|
|
21
|
+
} catch (error: unknown) {
|
|
22
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
23
|
+
return NextResponse.json({ error: message }, { status: 500 });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function POST(request: Request) {
|
|
28
|
+
try {
|
|
29
|
+
const store = await createProvider();
|
|
30
|
+
const record = (await request.json()) as WeaveRecord;
|
|
31
|
+
|
|
32
|
+
if (!record || typeof record.id !== "string" || record.id.length === 0) {
|
|
33
|
+
return NextResponse.json(
|
|
34
|
+
{ error: "Inquiry Weave registration requires a non-empty id" },
|
|
35
|
+
{ status: 400 },
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
await store.registerInquiryWeave(record);
|
|
40
|
+
|
|
41
|
+
return NextResponse.json(
|
|
42
|
+
{
|
|
43
|
+
success: true,
|
|
44
|
+
inquiry_weave: record,
|
|
45
|
+
provider: detectProvider(),
|
|
46
|
+
},
|
|
47
|
+
{ status: 201 },
|
|
48
|
+
);
|
|
49
|
+
} catch (error: unknown) {
|
|
50
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
51
|
+
return NextResponse.json({ error: message }, { status: 500 });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function inquiryWeaveFiltersFromSearchParams(searchParams: URLSearchParams): InquiryWeaveFilters {
|
|
56
|
+
const filters: InquiryWeaveFilters = {};
|
|
57
|
+
const episodePath = searchParams.get("episode_path");
|
|
58
|
+
const episodeNumber = searchParams.get("episode_number");
|
|
59
|
+
const issue = searchParams.get("issue");
|
|
60
|
+
const artefact = searchParams.get("artefact");
|
|
61
|
+
|
|
62
|
+
if (episodePath) filters.episode_path = episodePath;
|
|
63
|
+
if (episodeNumber) {
|
|
64
|
+
const parsed = Number(episodeNumber);
|
|
65
|
+
if (Number.isInteger(parsed)) filters.episode_number = parsed;
|
|
66
|
+
}
|
|
67
|
+
if (issue) filters.issue = issue;
|
|
68
|
+
if (artefact) filters.artefact = artefact;
|
|
69
|
+
|
|
70
|
+
return filters;
|
|
71
|
+
}
|
package/app/ceremonies/page.tsx
CHANGED
|
@@ -5,6 +5,7 @@ import type { FormEvent } from "react";
|
|
|
5
5
|
import { useSearchParams } from "next/navigation";
|
|
6
6
|
import { type CeremonyLog, DIRECTION_COLORS, CEREMONY_ICONS, type DirectionName, type CeremonyType } from "@/lib/types";
|
|
7
7
|
import { extractCeremonyLogs } from "@/lib/ceremony-response";
|
|
8
|
+
import { relativeTime, absoluteTime } from "@/lib/format-time";
|
|
8
9
|
import { toast } from "sonner";
|
|
9
10
|
|
|
10
11
|
function CeremoniesContent() {
|
|
@@ -14,12 +15,14 @@ function CeremoniesContent() {
|
|
|
14
15
|
const [filterType, setFilterType] = useState<string>("all");
|
|
15
16
|
const [showForm, setShowForm] = useState(false);
|
|
16
17
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
|
18
|
+
const [loading, setLoading] = useState(true);
|
|
17
19
|
|
|
18
20
|
const loadCeremonies = useCallback(async () => {
|
|
19
21
|
const params = new URLSearchParams();
|
|
20
22
|
if (filterDir !== "all") params.set("direction", filterDir);
|
|
21
23
|
if (filterType !== "all") params.set("type", filterType);
|
|
22
24
|
|
|
25
|
+
setLoading(true);
|
|
23
26
|
try {
|
|
24
27
|
const res = await fetch(`/api/ceremonies?${params}`);
|
|
25
28
|
if (!res.ok) throw new Error("Failed to load ceremonies");
|
|
@@ -27,9 +30,14 @@ function CeremoniesContent() {
|
|
|
27
30
|
setCeremonies(extractCeremonyLogs(data));
|
|
28
31
|
} catch {
|
|
29
32
|
setCeremonies([]);
|
|
33
|
+
} finally {
|
|
34
|
+
setLoading(false);
|
|
30
35
|
}
|
|
31
36
|
}, [filterDir, filterType]);
|
|
32
37
|
|
|
38
|
+
const filtersActive = filterDir !== "all" || filterType !== "all";
|
|
39
|
+
const clearFilters = () => { setFilterDir("all"); setFilterType("all"); };
|
|
40
|
+
|
|
33
41
|
useEffect(() => {
|
|
34
42
|
loadCeremonies();
|
|
35
43
|
}, [loadCeremonies]);
|
|
@@ -55,6 +63,7 @@ function CeremoniesContent() {
|
|
|
55
63
|
|
|
56
64
|
const directions: DirectionName[] = ["east", "south", "west", "north"];
|
|
57
65
|
const cTypes: CeremonyType[] = ["smudging", "talking_circle", "spirit_feeding", "opening", "closing"];
|
|
66
|
+
const DIRECTION_ICONS: Record<DirectionName, string> = { east: "🌅", south: "🌞", west: "🌄", north: "❄️" };
|
|
58
67
|
|
|
59
68
|
const phaseMap: Record<DirectionName, string> = { east: "opening", south: "council", west: "integration", north: "closure" };
|
|
60
69
|
const currentPhase = useMemo(() => {
|
|
@@ -110,6 +119,9 @@ function CeremoniesContent() {
|
|
|
110
119
|
<option value="all">All Types</option>
|
|
111
120
|
{cTypes.map((t) => <option key={t} value={t}>{t.replace("_", " ")}</option>)}
|
|
112
121
|
</select>
|
|
122
|
+
{filtersActive && (
|
|
123
|
+
<button onClick={clearFilters} className="px-3 py-1 rounded-md border text-xs text-muted-foreground hover:text-foreground">Clear filters</button>
|
|
124
|
+
)}
|
|
113
125
|
</div>
|
|
114
126
|
|
|
115
127
|
{showForm && (
|
|
@@ -129,21 +141,34 @@ function CeremoniesContent() {
|
|
|
129
141
|
)}
|
|
130
142
|
|
|
131
143
|
<div className="space-y-3">
|
|
132
|
-
{
|
|
133
|
-
{ceremonies.
|
|
144
|
+
{loading && <div className="text-center py-12 text-muted-foreground">Loading ceremonies…</div>}
|
|
145
|
+
{!loading && ceremonies.length === 0 && (
|
|
146
|
+
<div className="text-center py-12 text-muted-foreground">
|
|
147
|
+
<p>{filtersActive ? "No ceremonies match these filters." : "No ceremonies found. Log your first ceremony to begin."}</p>
|
|
148
|
+
{filtersActive && <button onClick={clearFilters} className="text-sm mt-2 underline hover:text-foreground">Clear filters</button>}
|
|
149
|
+
</div>
|
|
150
|
+
)}
|
|
151
|
+
{!loading && ceremonies.map((c) => (
|
|
134
152
|
<div key={c.id} className="border rounded-lg bg-card overflow-hidden cursor-pointer hover:border-ring/50 transition-colors"
|
|
135
153
|
style={{ borderTopColor: DIRECTION_COLORS[c.direction], borderTopWidth: 3 }} onClick={() => setExpandedId(expandedId === c.id ? null : c.id)}>
|
|
136
|
-
<div className="p-4 flex items-center justify-between">
|
|
137
|
-
<div className="flex items-center gap-3">
|
|
138
|
-
<span className="text-2xl">{CEREMONY_ICONS[c.type]}</span>
|
|
139
|
-
<div>
|
|
140
|
-
<div className="
|
|
141
|
-
|
|
154
|
+
<div className="p-4 flex items-center justify-between gap-3">
|
|
155
|
+
<div className="flex items-center gap-3 min-w-0">
|
|
156
|
+
<span className="text-2xl shrink-0">{CEREMONY_ICONS[c.type]}</span>
|
|
157
|
+
<div className="min-w-0">
|
|
158
|
+
<div className="flex items-center gap-2">
|
|
159
|
+
<span className="font-medium text-sm capitalize">{c.type.replace("_", " ")}</span>
|
|
160
|
+
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium capitalize border"
|
|
161
|
+
style={{ borderColor: DIRECTION_COLORS[c.direction], color: DIRECTION_COLORS[c.direction] }}>
|
|
162
|
+
{DIRECTION_ICONS[c.direction]} {c.direction}
|
|
163
|
+
</span>
|
|
164
|
+
</div>
|
|
165
|
+
<div className="text-xs text-muted-foreground" title={absoluteTime(c.timestamp)}>{relativeTime(c.timestamp)}</div>
|
|
142
166
|
</div>
|
|
143
167
|
</div>
|
|
144
|
-
<div className="flex items-center gap-2">
|
|
168
|
+
<div className="flex items-center gap-2 shrink-0">
|
|
145
169
|
{c.participants.length > 0 && <span className="px-2 py-0.5 rounded-full bg-secondary text-xs">{c.participants.length} participant{c.participants.length !== 1 ? "s" : ""}</span>}
|
|
146
170
|
{c.medicines_used.length > 0 && <span className="px-2 py-0.5 rounded-full bg-secondary text-xs">🌿 {c.medicines_used.length}</span>}
|
|
171
|
+
<span className="text-muted-foreground text-sm w-4 text-center" aria-hidden>{expandedId === c.id ? "▲" : "▼"}</span>
|
|
147
172
|
</div>
|
|
148
173
|
</div>
|
|
149
174
|
{expandedId === c.id && (
|
|
@@ -151,7 +176,7 @@ function CeremoniesContent() {
|
|
|
151
176
|
{c.intentions.length > 0 && <div><div className="text-xs font-medium text-muted-foreground uppercase mb-1">Intentions</div>{c.intentions.map((int, i) => <p key={i} className="text-sm">{int}</p>)}</div>}
|
|
152
177
|
{c.participants.length > 0 && <div><div className="text-xs font-medium text-muted-foreground uppercase mb-1">Participants</div><div className="flex flex-wrap gap-1">{c.participants.map((p) => <span key={p} className="px-2 py-0.5 rounded bg-secondary text-xs">{p}</span>)}</div></div>}
|
|
153
178
|
{c.medicines_used.length > 0 && <div><div className="text-xs font-medium text-muted-foreground uppercase mb-1">Medicines</div><div className="flex flex-wrap gap-1">{c.medicines_used.map((m) => <span key={m} className="px-2 py-0.5 rounded bg-secondary text-xs">🌿 {m}</span>)}</div></div>}
|
|
154
|
-
{c.research_context && <div><div className="text-xs font-medium text-muted-foreground uppercase mb-1">Research Context</div><
|
|
179
|
+
{c.research_context && <div><div className="text-xs font-medium text-muted-foreground uppercase mb-1">Research Context</div><code className="inline-block px-2 py-1 rounded bg-secondary/60 text-xs font-mono text-muted-foreground break-all">{c.research_context}</code></div>}
|
|
155
180
|
</div>
|
|
156
181
|
)}
|
|
157
182
|
</div>
|
package/app/narrative/page.tsx
CHANGED
|
@@ -3,16 +3,19 @@
|
|
|
3
3
|
import { useEffect, useState } from "react";
|
|
4
4
|
import { type NarrativeBeat, type MedicineWheelCycle, DIRECTION_COLORS, type DirectionName } from "@/lib/types";
|
|
5
5
|
import { extractCycles } from "@/lib/cycle-response";
|
|
6
|
+
import { relativeTime, absoluteTime } from "@/lib/format-time";
|
|
6
7
|
|
|
7
8
|
export default function NarrativePage() {
|
|
8
9
|
const [beats, setBeats] = useState<NarrativeBeat[]>([]);
|
|
9
10
|
const [cycles, setCycles] = useState<MedicineWheelCycle[]>([]);
|
|
10
11
|
const [expandedBeat, setExpandedBeat] = useState<string | null>(null);
|
|
12
|
+
const [loading, setLoading] = useState(true);
|
|
11
13
|
|
|
12
14
|
useEffect(() => {
|
|
13
15
|
Promise.all([fetch("/api/narrative/beats").then((r) => r.json()), fetch("/api/narrative/cycles").then((r) => r.json())])
|
|
14
16
|
.then(([b, c]) => { setBeats(Array.isArray(b) ? b : []); setCycles(extractCycles(c)); })
|
|
15
|
-
.catch(() => {})
|
|
17
|
+
.catch(() => {})
|
|
18
|
+
.finally(() => setLoading(false));
|
|
16
19
|
}, []);
|
|
17
20
|
|
|
18
21
|
const activeCycle = cycles[0];
|
|
@@ -32,7 +35,7 @@ export default function NarrativePage() {
|
|
|
32
35
|
<h2 className="text-lg font-semibold">Active Cycle</h2>
|
|
33
36
|
<p className="text-sm text-muted-foreground italic">“{activeCycle.research_question}”</p>
|
|
34
37
|
</div>
|
|
35
|
-
<span className="text-xs text-muted-foreground">Started {
|
|
38
|
+
<span className="text-xs text-muted-foreground" title={absoluteTime(activeCycle.start_date)}>Started {relativeTime(activeCycle.start_date)}</span>
|
|
36
39
|
</div>
|
|
37
40
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-center">
|
|
38
41
|
<div>
|
|
@@ -83,8 +86,9 @@ export default function NarrativePage() {
|
|
|
83
86
|
{/* Beat List */}
|
|
84
87
|
<h2 className="text-lg font-semibold mb-3">Narrative Beats ({beats.length})</h2>
|
|
85
88
|
<div className="space-y-3">
|
|
86
|
-
{
|
|
87
|
-
{beats.
|
|
89
|
+
{loading && <div className="text-center py-8 text-muted-foreground">Loading narrative beats…</div>}
|
|
90
|
+
{!loading && beats.length === 0 && <div className="text-center py-8 text-muted-foreground">No beats recorded yet.</div>}
|
|
91
|
+
{!loading && beats.map((beat) => (
|
|
88
92
|
<div key={beat.id} className="border rounded-lg bg-card overflow-hidden cursor-pointer hover:border-ring/50"
|
|
89
93
|
style={{ borderLeftColor: DIRECTION_COLORS[beat.direction as DirectionName], borderLeftWidth: 4 }}
|
|
90
94
|
onClick={() => setExpandedBeat(expandedBeat === beat.id ? null : beat.id)}>
|
|
@@ -96,7 +100,7 @@ export default function NarrativePage() {
|
|
|
96
100
|
</div>
|
|
97
101
|
<div className="text-right text-xs text-muted-foreground">
|
|
98
102
|
<div className="capitalize">{beat.direction} · Act {beat.act}</div>
|
|
99
|
-
<div
|
|
103
|
+
<div title={absoluteTime(beat.timestamp)}>{relativeTime(beat.timestamp)}</div>
|
|
100
104
|
</div>
|
|
101
105
|
</div>
|
|
102
106
|
</div>
|
package/app/nodes/page.tsx
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
NODE_TYPE_COLORS,
|
|
11
11
|
DIRECTION_COLORS,
|
|
12
12
|
} from "@/lib/types";
|
|
13
|
+
import { relativeTime, absoluteTime } from "@/lib/format-time";
|
|
13
14
|
import { toast } from "sonner";
|
|
14
15
|
|
|
15
16
|
const NODE_TYPES: NodeType[] = ["human", "land", "spirit", "ancestor", "future", "knowledge"];
|
|
@@ -195,7 +196,7 @@ export default function NodesPage() {
|
|
|
195
196
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
|
196
197
|
<div><span className="text-muted-foreground">Type:</span> <span className="capitalize">{node.type}</span></div>
|
|
197
198
|
{node.direction && <div><span className="text-muted-foreground">Direction:</span> <span className="capitalize">{node.direction}</span></div>}
|
|
198
|
-
<div><span className="text-muted-foreground">Created:</span> {
|
|
199
|
+
<div><span className="text-muted-foreground">Created:</span> <span title={absoluteTime(node.created_at)}>{relativeTime(node.created_at)}</span></div>
|
|
199
200
|
<div><span className="text-muted-foreground">ID:</span> <code className="text-xs">{node.id.slice(0, 8)}…</code></div>
|
|
200
201
|
</div>
|
|
201
202
|
<div>
|
package/app/relations/page.tsx
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { useEffect, useState, useCallback, useRef } from "react";
|
|
4
4
|
import type { MouseEvent, FormEvent } from "react";
|
|
5
5
|
import { type RelationalNode, type RelationalEdge, NODE_TYPE_COLORS, type NodeType } from "@/lib/types";
|
|
6
|
+
import { relativeTime, absoluteTime } from "@/lib/format-time";
|
|
6
7
|
import { toast } from "sonner";
|
|
7
8
|
|
|
8
9
|
interface GraphNode extends RelationalNode {
|
|
@@ -18,8 +19,10 @@ export default function RelationsPage() {
|
|
|
18
19
|
const [showAddNode, setShowAddNode] = useState(false);
|
|
19
20
|
const svgRef = useRef<SVGSVGElement>(null);
|
|
20
21
|
const [dragging, setDragging] = useState<string | null>(null);
|
|
22
|
+
const [loading, setLoading] = useState(true);
|
|
21
23
|
|
|
22
24
|
const loadData = useCallback(async () => {
|
|
25
|
+
setLoading(true);
|
|
23
26
|
try {
|
|
24
27
|
const [nodesRes, edgesRes] = await Promise.all([fetch("/api/nodes"), fetch("/api/edges")]);
|
|
25
28
|
const nodesResponse = await nodesRes.json();
|
|
@@ -36,6 +39,7 @@ export default function RelationsPage() {
|
|
|
36
39
|
setNodes(graphNodes);
|
|
37
40
|
setEdges(Array.isArray(edgesData) ? edgesData : []);
|
|
38
41
|
} catch (err) { console.error("Failed to load relational data:", err); }
|
|
42
|
+
finally { setLoading(false); }
|
|
39
43
|
}, []);
|
|
40
44
|
|
|
41
45
|
useEffect(() => { loadData(); }, [loadData]);
|
|
@@ -100,7 +104,12 @@ export default function RelationsPage() {
|
|
|
100
104
|
)}
|
|
101
105
|
|
|
102
106
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
103
|
-
<div className="lg:col-span-2 border rounded-lg bg-card overflow-hidden">
|
|
107
|
+
<div className="lg:col-span-2 border rounded-lg bg-card overflow-hidden relative">
|
|
108
|
+
{(loading || nodes.length === 0) && (
|
|
109
|
+
<div className="absolute inset-0 z-10 flex items-center justify-center text-center text-muted-foreground pointer-events-none">
|
|
110
|
+
{loading ? "Loading relational web…" : "No nodes yet — add one to begin mapping relations."}
|
|
111
|
+
</div>
|
|
112
|
+
)}
|
|
104
113
|
<svg ref={svgRef} viewBox="0 0 800 600" className="w-full h-[600px] cursor-crosshair"
|
|
105
114
|
onMouseMove={handleMouseMove} onMouseUp={handleMouseUp} onMouseLeave={handleMouseUp}>
|
|
106
115
|
{filteredEdges.map((edge) => {
|
|
@@ -136,7 +145,7 @@ export default function RelationsPage() {
|
|
|
136
145
|
<div className="mt-3 space-y-2 text-sm">
|
|
137
146
|
<div><span className="text-muted-foreground">Type:</span> {selectedNode.type}</div>
|
|
138
147
|
{selectedNode.direction && <div><span className="text-muted-foreground">Direction:</span> {selectedNode.direction}</div>}
|
|
139
|
-
<div><span className="text-muted-foreground">Created:</span> {
|
|
148
|
+
<div><span className="text-muted-foreground">Created:</span> <span title={absoluteTime(selectedNode.created_at)}>{relativeTime(selectedNode.created_at)}</span></div>
|
|
140
149
|
</div>
|
|
141
150
|
<div className="mt-4">
|
|
142
151
|
<h4 className="text-sm font-medium mb-2">Connected Edges</h4>
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* format-time — human-readable, recency-aware timestamps shared across views.
|
|
3
|
+
*
|
|
4
|
+
* relativeTime() gives a glanceable sense of recency ("3 hours ago"), falling
|
|
5
|
+
* back to an absolute short date once an entry is older than a month.
|
|
6
|
+
* absoluteTime() gives the exact moment — used as the `title` tooltip so the
|
|
7
|
+
* precise timestamp is always one hover away.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export function relativeTime(iso: string | number | Date): string {
|
|
11
|
+
const then = new Date(iso).getTime();
|
|
12
|
+
if (Number.isNaN(then)) return "";
|
|
13
|
+
|
|
14
|
+
const diff = Date.now() - then; // positive = past
|
|
15
|
+
const future = diff < 0;
|
|
16
|
+
const abs = Math.abs(diff);
|
|
17
|
+
|
|
18
|
+
const sec = Math.round(abs / 1000);
|
|
19
|
+
const min = Math.round(sec / 60);
|
|
20
|
+
const hr = Math.round(min / 60);
|
|
21
|
+
const day = Math.round(hr / 24);
|
|
22
|
+
|
|
23
|
+
const suffix = future ? "from now" : "ago";
|
|
24
|
+
const unit = (n: number, u: string) => `${n} ${u}${n !== 1 ? "s" : ""} ${suffix}`;
|
|
25
|
+
|
|
26
|
+
if (sec < 45) return "just now";
|
|
27
|
+
if (min < 60) return unit(min, "minute");
|
|
28
|
+
if (hr < 24) return unit(hr, "hour");
|
|
29
|
+
if (day < 30) return unit(day, "day");
|
|
30
|
+
|
|
31
|
+
// Older than ~a month: an absolute short date reads cleaner than "47 days ago".
|
|
32
|
+
return new Date(iso).toLocaleDateString(undefined, {
|
|
33
|
+
year: "numeric",
|
|
34
|
+
month: "short",
|
|
35
|
+
day: "numeric",
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function absoluteTime(iso: string | number | Date): string {
|
|
40
|
+
const d = new Date(iso);
|
|
41
|
+
if (Number.isNaN(d.getTime())) return String(iso);
|
|
42
|
+
return d.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
|
|
43
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@medicine-wheel/app",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.12",
|
|
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",
|
|
@@ -76,26 +76,26 @@
|
|
|
76
76
|
"release:major": "npm run version:major && npm run publish:all && npm run release:commit"
|
|
77
77
|
},
|
|
78
78
|
"dependencies": {
|
|
79
|
-
"@medicine-wheel/ceremony-protocol": "^0.4.
|
|
80
|
-
"@medicine-wheel/community-review": "^0.4.
|
|
81
|
-
"@medicine-wheel/consent-lifecycle": "^0.4.
|
|
82
|
-
"@medicine-wheel/data-store": "^0.4.
|
|
83
|
-
"@medicine-wheel/data-store-postgres": "^0.4.
|
|
84
|
-
"@medicine-wheel/fire-keeper": "^0.4.
|
|
85
|
-
"@medicine-wheel/graph-viz": "^0.4.
|
|
86
|
-
"@medicine-wheel/importance-unit": "^0.4.
|
|
87
|
-
"@medicine-wheel/mcp": "^4.4.
|
|
88
|
-
"@medicine-wheel/narrative-cluster": "^0.4.
|
|
89
|
-
"@medicine-wheel/narrative-engine": "^0.4.
|
|
90
|
-
"@medicine-wheel/ontology-core": "^0.4.
|
|
91
|
-
"@medicine-wheel/perception-layer": "^0.4.
|
|
92
|
-
"@medicine-wheel/prompt-decomposition": "^0.4.
|
|
93
|
-
"@medicine-wheel/relational-index": "^0.4.
|
|
94
|
-
"@medicine-wheel/relational-query": "^0.4.
|
|
95
|
-
"@medicine-wheel/session-reader": "^0.4.
|
|
96
|
-
"@medicine-wheel/storage-provider": "^0.4.
|
|
97
|
-
"@medicine-wheel/transformation-tracker": "^0.4.
|
|
98
|
-
"@medicine-wheel/ui-components": "^0.4.
|
|
79
|
+
"@medicine-wheel/ceremony-protocol": "^0.4.12",
|
|
80
|
+
"@medicine-wheel/community-review": "^0.4.12",
|
|
81
|
+
"@medicine-wheel/consent-lifecycle": "^0.4.12",
|
|
82
|
+
"@medicine-wheel/data-store": "^0.4.12",
|
|
83
|
+
"@medicine-wheel/data-store-postgres": "^0.4.12",
|
|
84
|
+
"@medicine-wheel/fire-keeper": "^0.4.12",
|
|
85
|
+
"@medicine-wheel/graph-viz": "^0.4.12",
|
|
86
|
+
"@medicine-wheel/importance-unit": "^0.4.12",
|
|
87
|
+
"@medicine-wheel/mcp": "^4.4.12",
|
|
88
|
+
"@medicine-wheel/narrative-cluster": "^0.4.12",
|
|
89
|
+
"@medicine-wheel/narrative-engine": "^0.4.12",
|
|
90
|
+
"@medicine-wheel/ontology-core": "^0.4.12",
|
|
91
|
+
"@medicine-wheel/perception-layer": "^0.4.12",
|
|
92
|
+
"@medicine-wheel/prompt-decomposition": "^0.4.12",
|
|
93
|
+
"@medicine-wheel/relational-index": "^0.4.12",
|
|
94
|
+
"@medicine-wheel/relational-query": "^0.4.12",
|
|
95
|
+
"@medicine-wheel/session-reader": "^0.4.12",
|
|
96
|
+
"@medicine-wheel/storage-provider": "^0.4.12",
|
|
97
|
+
"@medicine-wheel/transformation-tracker": "^0.4.12",
|
|
98
|
+
"@medicine-wheel/ui-components": "^0.4.12",
|
|
99
99
|
"@neondatabase/serverless": "^0.10.0",
|
|
100
100
|
"clsx": "^2.1.1",
|
|
101
101
|
"lucide-react": "^0.475.0",
|