2 Commits

Author SHA1 Message Date
justin 99a660485e Pedigree: connector lines + correct 4-grandparent structure
Rebuilds the family view's pedigree as a recursive bracket chart with CSS connector lines — focus links to its two parents (2 lines), and each parent links to its two parents (4 lines to grandparents). Fixes the prior ambiguity where grandparent slots weren't tied to a specific parent: now every parent shows its own two parent slots, so a person clearly has up to four grandparents grouped by lineage. Height-robust connectors (each leaf draws its own spine half + stub).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 22:32:10 -04:00
justin cf6dcf9ce2 Merge pull request 'Family view + soft-delete/recovery' (#10) from phase1-familyview into main
build-backend / build (push) Successful in 30s
build-frontend / build (push) Successful in 1m18s
2026-06-06 22:19:02 -04:00
2 changed files with 115 additions and 45 deletions
+52
View File
@@ -54,3 +54,55 @@ h3,
::selection {
background: color-mix(in srgb, var(--color-bronze) 22%, transparent);
}
/* Pedigree bracket connectors (ancestors grow rightward). Each leaf draws its
own half of the vertical spine + a horizontal stub, so lines stay correct
regardless of box heights: focus → 2 parents, each parent → 2 grandparents. */
.ped-person {
display: flex;
align-items: center;
}
.ped-self {
flex-shrink: 0;
}
.ped-branch {
position: relative;
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-left: 2.5rem;
}
.ped-branch::before {
content: "";
position: absolute;
left: -2.5rem;
top: 50%;
width: 2.5rem;
border-top: 1px solid var(--border);
}
.ped-leaf {
position: relative;
padding-left: 1.5rem;
}
.ped-leaf::before {
content: "";
position: absolute;
left: 0;
top: 50%;
width: 1.5rem;
border-top: 1px solid var(--border);
}
.ped-leaf::after {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
border-left: 1px solid var(--border);
}
.ped-leaf:first-child::after {
top: 50%;
}
.ped-leaf:last-child::after {
bottom: 50%;
}
+63 -45
View File
@@ -36,7 +36,9 @@ export default function FamilyViewPage() {
const [search, setSearch] = useState("");
const [firstName, setFirstName] = useState("");
// Inline add-relative form: which anchor + kind is open, and the typed name.
const [adding, setAdding] = useState<{ kind: AddKind; anchor: string } | null>(null);
// `key` keeps each empty slot's inline form independent (a person has 2
// parents, 4 grandparents — many same-kind/anchor slots can coexist).
const [adding, setAdding] = useState<{ key: string; kind: AddKind; anchor: string } | null>(null);
const [addName, setAddName] = useState("");
const load = useCallback(async () => {
@@ -179,8 +181,18 @@ export default function FamilyViewPage() {
);
};
const AddSlot = ({ kind, anchor, label }: { kind: AddKind; anchor: string; label: string }) =>
adding && adding.kind === kind && adding.anchor === anchor ? (
const AddSlot = ({
formKey,
kind,
anchor,
label,
}: {
formKey: string;
kind: AddKind;
anchor: string;
label: string;
}) =>
adding?.key === formKey ? (
<form onSubmit={submitAdd} className="flex w-44 flex-col gap-1">
<Input
autoFocus
@@ -205,7 +217,7 @@ export default function FamilyViewPage() {
) : (
<button
onClick={() => {
setAdding({ kind, anchor });
setAdding({ key: formKey, kind, anchor });
setAddName("");
}}
className="w-44 rounded-lg border border-dashed border-[var(--border)] px-3 py-2 text-left text-sm text-[var(--muted)] hover:border-bronze hover:text-bronze"
@@ -214,7 +226,39 @@ export default function FamilyViewPage() {
</button>
);
const parents = parentsOf(focus.id);
// Recursive ancestor chart (grows rightward): a node is its box plus a
// two-leaf "branch" of its parents, with CSS bracket connectors. Depth 0 =
// focus, capped at grandparents (depth 2).
const renderNode = (
slotPersonId: string | null,
childId: string,
keyPrefix: string,
depth: number,
): React.ReactNode => {
const box = slotPersonId ? (
<PersonBox id={slotPersonId} muted={depth > 0} />
) : (
<AddSlot formKey={keyPrefix} kind="parent" anchor={childId} label="add parent" />
);
if (!slotPersonId || depth >= 2) {
return <div className="ped-person">{box}</div>;
}
const ps = parentsOf(slotPersonId);
return (
<div className="ped-person">
<div className="ped-self">{box}</div>
<div className="ped-branch">
<div className="ped-leaf">
{renderNode(ps[0] ?? null, slotPersonId, `${keyPrefix}-a`, depth + 1)}
</div>
<div className="ped-leaf">
{renderNode(ps[1] ?? null, slotPersonId, `${keyPrefix}-b`, depth + 1)}
</div>
</div>
</div>
);
};
const partners = partnersOf(focus.id);
const children = childrenOf(focus.id);
@@ -237,46 +281,10 @@ export default function FamilyViewPage() {
</Link>
</div>
{/* Pedigree: focus → parents → grandparents */}
{/* Pedigree: focus → parents → grandparents, with bracket connectors */}
<Card>
<CardContent className="overflow-x-auto p-6">
<div className="flex min-w-[40rem] items-stretch gap-8">
<div className="flex flex-1 flex-col justify-center gap-3">
<div className="mb-1 text-xs font-semibold uppercase tracking-wider text-[var(--muted)]">
Focus
</div>
<PersonBox id={focus.id} />
</div>
<div className="flex flex-1 flex-col justify-center gap-4">
<div className="mb-1 text-xs font-semibold uppercase tracking-wider text-[var(--muted)]">
Parents
</div>
{parents.map((pid) => (
<PersonBox key={pid} id={pid} muted />
))}
{parents.length < 2 && <AddSlot kind="parent" anchor={focus.id} label="add parent" />}
</div>
<div className="flex flex-1 flex-col justify-center gap-4">
<div className="mb-1 text-xs font-semibold uppercase tracking-wider text-[var(--muted)]">
Grandparents
</div>
{parents.length === 0 && (
<div className="text-sm text-[var(--muted)]">Add parents first.</div>
)}
{parents.map((pid) => (
<div key={pid} className="flex flex-col gap-2">
{parentsOf(pid).map((gp) => (
<PersonBox key={gp} id={gp} muted />
))}
{parentsOf(pid).length < 2 && (
<AddSlot kind="parent" anchor={pid} label="add parent" />
)}
</div>
))}
</div>
</div>
<div className="min-w-[44rem]">{renderNode(focus.id, focus.id, "ped", 0)}</div>
</CardContent>
</Card>
@@ -289,7 +297,12 @@ export default function FamilyViewPage() {
{partners.map((id) => (
<PersonBox key={id} id={id} muted />
))}
<AddSlot kind="partner" anchor={focus.id} label="add spouse" />
<AddSlot
formKey={`partner-${focus.id}`}
kind="partner"
anchor={focus.id}
label="add spouse"
/>
</div>
</CardContent>
</Card>
@@ -301,7 +314,12 @@ export default function FamilyViewPage() {
{children.map((id) => (
<PersonBox key={id} id={id} muted />
))}
<AddSlot kind="child" anchor={focus.id} label="add child" />
<AddSlot
formKey={`child-${focus.id}`}
kind="child"
anchor={focus.id}
label="add child"
/>
</div>
</CardContent>
</Card>