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.connection.findMany(),
]);
const nameById = new Map(people.map((p: any) => [p.id, p.name]));
const nodes = people.map((p: any) => ({
id: p.id,
@@ -18,13 +19,21 @@ export async function GET() {
role: p.role,
}));
const edges = connections.map((c: any) => ({
id: c.id,
source: c.personAId,
target: c.personBId,
introducedByCount: c.introducedByChain.length,
hasProvenance: c.introducedByChain.length > 0,
}));
const edges = connections.map((c: any) => {
const introducedByNames: string[] = Array.isArray(c.introducedByChain)
? c.introducedByChain.map((pid: string) => nameById.get(pid)).filter(Boolean)
: [];
return {
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 });
} catch (err) {

View File

@@ -65,10 +65,23 @@ export default function ConnectionForm() {
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 = {
personAId: personA.id,
personBId: personB.id,
introducedByChain: introducedBy.filter(Boolean).map((p) => (p as Selected)!.id),
introducedByChain: chain,
eventLabels: splitCsv(eventLabelsText),
notes: notes.trim() || undefined,
};

View File

@@ -17,6 +17,9 @@ type GraphEdgeDTO = {
target: string;
introducedByCount: number;
hasProvenance: boolean;
introducedByNames?: string[];
eventLabels?: string[];
notes?: string | null;
};
type GraphData = {
@@ -46,15 +49,25 @@ export default function Graph({ data, height = 600 }: Props) {
sectors: n.sectors,
},
}));
const edges = data.edges.map((e) => ({
data: {
id: e.id,
source: e.source,
target: e.target,
introducedByCount: e.introducedByCount,
hasProvenance: e.hasProvenance,
},
}));
const edges = data.edges.map((e) => {
const label =
e.introducedByNames && e.introducedByNames.length
? `Introduced by: ${e.introducedByNames.join(" → ")}`
: "";
return {
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 };
}, [data]);
@@ -104,6 +117,23 @@ export default function Graph({ data, height = 600 }: Props) {
"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",
style: {
@@ -147,6 +177,9 @@ export default function Graph({ data, height = 600 }: Props) {
target: d.target,
introducedByCount: Number(d.introducedByCount ?? 0),
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}
</div>
<div className="text-sm">
<span className="text-xs text-zinc-500">Has provenance:</span>{" "}
{selectedEdge.hasProvenance ? "Yes" : "No"}
<span className="text-xs text-zinc-500">Introduced by:</span>{" "}
{selectedEdge.introducedByNames && selectedEdge.introducedByNames.length > 0
? selectedEdge.introducedByNames.join(" → ")
: "None"}
</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>

View File

@@ -52,6 +52,9 @@ export const graphEdgeSchema = z.object({
target: z.string().uuid(),
introducedByCount: z.number().int().nonnegative(),
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({