MVP 2: added introduced by

This commit is contained in:
Maxime Van Hees
2025-11-14 21:43:26 +01:00
parent 4d024a39f4
commit ae4b4405d2
4 changed files with 90 additions and 19 deletions

View File

@@ -9,6 +9,7 @@ export async function GET() {
prisma.person.findMany(), prisma.person.findMany(),
prisma.connection.findMany(), prisma.connection.findMany(),
]); ]);
const nameById = new Map(people.map((p: any) => [p.id, p.name]));
const nodes = people.map((p: any) => ({ const nodes = people.map((p: any) => ({
id: p.id, id: p.id,
@@ -18,13 +19,21 @@ export async function GET() {
role: p.role, role: p.role,
})); }));
const edges = connections.map((c: any) => ({ const edges = connections.map((c: any) => {
id: c.id, const introducedByNames: string[] = Array.isArray(c.introducedByChain)
source: c.personAId, ? c.introducedByChain.map((pid: string) => nameById.get(pid)).filter(Boolean)
target: c.personBId, : [];
introducedByCount: c.introducedByChain.length, return {
hasProvenance: c.introducedByChain.length > 0, id: c.id,
})); source: c.personAId,
target: c.personBId,
introducedByCount: c.introducedByChain.length,
hasProvenance: c.introducedByChain.length > 0,
introducedByNames,
eventLabels: Array.isArray(c.eventLabels) ? c.eventLabels : [],
notes: c.notes ?? null,
};
});
return NextResponse.json({ nodes, edges }, { status: 200 }); return NextResponse.json({ nodes, edges }, { status: 200 });
} catch (err) { } catch (err) {

View File

@@ -65,10 +65,23 @@ export default function ConnectionForm() {
return; return;
} }
// Build introduced-by chain preserving order. If the user selected an introducer
// but forgot to click “Add”, include it automatically on save (last position).
const baseChain = introducedBy.filter(Boolean).map((p) => (p as Selected)!.id);
let chain = [...baseChain];
if (
introducerPick &&
introducerPick.id !== personA.id &&
introducerPick.id !== personB.id &&
!chain.includes(introducerPick.id)
) {
chain.push(introducerPick.id);
}
const payload = { const payload = {
personAId: personA.id, personAId: personA.id,
personBId: personB.id, personBId: personB.id,
introducedByChain: introducedBy.filter(Boolean).map((p) => (p as Selected)!.id), introducedByChain: chain,
eventLabels: splitCsv(eventLabelsText), eventLabels: splitCsv(eventLabelsText),
notes: notes.trim() || undefined, notes: notes.trim() || undefined,
}; };

View File

@@ -17,6 +17,9 @@ type GraphEdgeDTO = {
target: string; target: string;
introducedByCount: number; introducedByCount: number;
hasProvenance: boolean; hasProvenance: boolean;
introducedByNames?: string[];
eventLabels?: string[];
notes?: string | null;
}; };
type GraphData = { type GraphData = {
@@ -46,15 +49,25 @@ export default function Graph({ data, height = 600 }: Props) {
sectors: n.sectors, sectors: n.sectors,
}, },
})); }));
const edges = data.edges.map((e) => ({ const edges = data.edges.map((e) => {
data: { const label =
id: e.id, e.introducedByNames && e.introducedByNames.length
source: e.source, ? `Introduced by: ${e.introducedByNames.join(" → ")}`
target: e.target, : "";
introducedByCount: e.introducedByCount, return {
hasProvenance: e.hasProvenance, data: {
}, id: e.id,
})); source: e.source,
target: e.target,
introducedByCount: e.introducedByCount,
hasProvenance: e.hasProvenance,
introducedByNames: e.introducedByNames ?? [],
eventLabels: e.eventLabels ?? [],
notes: e.notes ?? null,
edgeLabel: label,
},
};
});
return { nodes, edges }; return { nodes, edges };
}, [data]); }, [data]);
@@ -104,6 +117,23 @@ export default function Graph({ data, height = 600 }: Props) {
"line-color": "#16a34a", // green-600 "line-color": "#16a34a", // green-600
}, },
}, },
{
selector: "edge:selected",
style: {
label: "data(edgeLabel)",
"font-size": 10,
color: "#111827",
"text-background-color": "#ffffff",
"text-background-opacity": 1,
"text-background-padding": "2px",
"text-border-color": "#e5e7eb",
"text-border-width": 1,
"text-border-opacity": 1,
"text-wrap": "wrap",
"text-max-width": "120px",
"text-rotation": "autorotate",
},
},
{ {
selector: ":selected", selector: ":selected",
style: { style: {
@@ -147,6 +177,9 @@ export default function Graph({ data, height = 600 }: Props) {
target: d.target, target: d.target,
introducedByCount: Number(d.introducedByCount ?? 0), introducedByCount: Number(d.introducedByCount ?? 0),
hasProvenance: Boolean(d.hasProvenance), hasProvenance: Boolean(d.hasProvenance),
introducedByNames: Array.isArray(d.introducedByNames) ? (d.introducedByNames as string[]) : [],
eventLabels: Array.isArray(d.eventLabels) ? (d.eventLabels as string[]) : [],
notes: (d.notes ?? null) as string | null,
}); });
}; };
@@ -249,9 +282,22 @@ export default function Graph({ data, height = 600 }: Props) {
{selectedEdge.introducedByCount} {selectedEdge.introducedByCount}
</div> </div>
<div className="text-sm"> <div className="text-sm">
<span className="text-xs text-zinc-500">Has provenance:</span>{" "} <span className="text-xs text-zinc-500">Introduced by:</span>{" "}
{selectedEdge.hasProvenance ? "Yes" : "No"} {selectedEdge.introducedByNames && selectedEdge.introducedByNames.length > 0
? selectedEdge.introducedByNames.join(" → ")
: "None"}
</div> </div>
{selectedEdge.eventLabels && selectedEdge.eventLabels.length > 0 && (
<div className="text-sm">
<span className="text-xs text-zinc-500">Event labels:</span>{" "}
{selectedEdge.eventLabels.join(", ")}
</div>
)}
{selectedEdge.notes ? (
<div className="text-sm">
<span className="text-xs text-zinc-500">Notes:</span> {selectedEdge.notes}
</div>
) : null}
</div> </div>
)} )}
</div> </div>

View File

@@ -52,6 +52,9 @@ export const graphEdgeSchema = z.object({
target: z.string().uuid(), target: z.string().uuid(),
introducedByCount: z.number().int().nonnegative(), introducedByCount: z.number().int().nonnegative(),
hasProvenance: z.boolean(), hasProvenance: z.boolean(),
introducedByNames: z.array(z.string()).default([]).optional(),
eventLabels: z.array(z.string()).default([]).optional(),
notes: z.string().nullable().optional(),
}); });
export const graphResponseSchema = z.object({ export const graphResponseSchema = z.object({