From f05d38ecded30ce463f21ed7ab9dbf6e7c244dc3 Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:20:20 -0400 Subject: [PATCH] Units hover notes and a family tree for people (Theographic) Co-Authored-By: Claude Sonnet 5.5 --- src/components/FamilyTree.jsx | 61 ++++++++++++++++++++++++++ src/components/FamilyTree.test.jsx | 46 +++++++++++++++++++ src/components/StudyPanel.jsx | 2 + src/components/study/ScripturePane.jsx | 5 ++- src/utils/biblicalUnits.js | 55 +++++++++++++++++++++++ src/utils/biblicalUnits.test.js | 29 ++++++++++++ src/utils/family.js | 35 +++++++++++++++ src/utils/family.test.js | 50 +++++++++++++++++++++ 8 files changed, 282 insertions(+), 1 deletion(-) create mode 100644 src/components/FamilyTree.jsx create mode 100644 src/components/FamilyTree.test.jsx create mode 100644 src/utils/biblicalUnits.js create mode 100644 src/utils/biblicalUnits.test.js create mode 100644 src/utils/family.js create mode 100644 src/utils/family.test.js diff --git a/src/components/FamilyTree.jsx b/src/components/FamilyTree.jsx new file mode 100644 index 0000000..c2dd902 --- /dev/null +++ b/src/components/FamilyTree.jsx @@ -0,0 +1,61 @@ +// A person's family: the relatives named in their record (click one to read about them), and a tree of ancestors and +// descendants a few generations out. From Theographic Bible Metadata. +import { useEffect, useState } from 'react'; +import { loadPerson } from '../hooks/useVerseStudy.js'; +import { relatives, walk } from '../utils/family.js'; + +function Branch({ node, onOpen }) { + return ( +
  • + + {node.more && …} + {node.kids.length > 0 && } +
  • + ); +} + +function Tree({ id, onOpen }) { + const [state, setState] = useState({ loading: true }); + useEffect(() => { + let live = true; + Promise.all([walk(loadPerson, id, { direction: 'up', depth: 3, max: 30 }), walk(loadPerson, id, { direction: 'down', depth: 2, max: 40 })]) + .then(([up, down]) => { if (live) setState({ up, down }); }) + .catch(() => { if (live) setState({ error: true }); }); + return () => { live = false; }; + }, [id]); + if (state.loading) return

    Loading the family…

    ; + if (state.error) return

    The family tree couldn’t be loaded right now.

    ; + const block = (title, node, empty) => ( +
    +

    {title}

    + {node.kids.length ? :

    {empty}

    } +
    + ); + return
    {block('Ancestors', state.up, 'None recorded.')}{block('Descendants', state.down, 'None recorded.')}
    ; +} + +// `person` is the record; `renderPerson(id)` shows another person's details inside this one when a name is clicked. +export default function Family({ person, renderPerson }) { + const groups = relatives(person); + const [shown, setShown] = useState(null); // a relative opened from a name or the tree + const [treeOpen, setTreeOpen] = useState(false); // the tree's records are only fetched once it is opened + if (groups.length === 0) return null; + return ( +
    + {groups.map((g) => ( +

    + {g.label} + {g.people.map((p) => ( + + ))} +

    + ))} +
    setTreeOpen(e.currentTarget.open)}> + Family tree + {treeOpen && setShown(id)} />} +
    + {shown &&
    {renderPerson(shown)}
    } +
    + ); +} diff --git a/src/components/FamilyTree.test.jsx b/src/components/FamilyTree.test.jsx new file mode 100644 index 0000000..170a96a --- /dev/null +++ b/src/components/FamilyTree.test.jsx @@ -0,0 +1,46 @@ +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { vi, test, expect, beforeEach } from 'vitest'; +import Family from './FamilyTree.jsx'; + +const ref = (id, name) => ({ id, name, type: 'people' }); +const RECORDS = { + fam_seth: { id: 'fam_seth', name: 'Seth', father: [ref('fam_adam', 'Adam')], mother: [ref('fam_eve', 'Eve')], children: [ref('fam_enos', 'Enos')], siblings: [ref('fam_abel', 'Abel')] }, + fam_adam: { id: 'fam_adam', name: 'Adam', children: [ref('fam_seth', 'Seth')] }, + fam_eve: { id: 'fam_eve', name: 'Eve', children: [ref('fam_seth', 'Seth')] }, + fam_enos: { id: 'fam_enos', name: 'Enos', father: [ref('fam_seth', 'Seth')] }, + fam_abel: { id: 'fam_abel', name: 'Abel' }, +}; + +beforeEach(() => { + vi.stubGlobal('fetch', vi.fn(async (url) => { + const id = String(url).match(/people\/(.+)\.json/)?.[1]; + return RECORDS[id] ? { ok: true, json: async () => ({ person: RECORDS[id] }) } : { ok: false, json: async () => ({}) }; + })); +}); + +test('the relatives named in the record are listed, and clicking one opens them', async () => { + const user = userEvent.setup(); + render(
    details for {id}
    } />); + const family = screen.getByLabelText('Family'); + for (const name of ['Adam', 'Eve', 'Abel', 'Enos']) expect(within(family).getByRole('button', { name })).toBeInTheDocument(); + await user.click(within(family).getByRole('button', { name: 'Adam' })); + expect(screen.getByText('details for fam_adam')).toBeInTheDocument(); + await user.click(within(family).getByRole('button', { name: 'Adam' })); + expect(screen.queryByText('details for fam_adam')).not.toBeInTheDocument(); +}); + +test('the family tree shows ancestors and descendants', async () => { + const user = userEvent.setup(); + render(
    details for {id}
    } />); + await user.click(screen.getByText('Family tree')); + expect(await screen.findByText('Ancestors')).toBeInTheDocument(); + const tree = screen.getByText('Ancestors').closest('div').parentElement; + expect(within(tree).getAllByRole('button', { name: 'Adam' }).length).toBeGreaterThan(0); + expect(within(tree).getAllByRole('button', { name: 'Enos' }).length).toBeGreaterThan(0); +}); + +test('a person with no recorded family shows nothing', () => { + const { container } = render( null} />); + expect(container).toBeEmptyDOMElement(); +}); diff --git a/src/components/StudyPanel.jsx b/src/components/StudyPanel.jsx index 52f83ba..738e24a 100644 --- a/src/components/StudyPanel.jsx +++ b/src/components/StudyPanel.jsx @@ -4,6 +4,7 @@ import { useState } from 'react'; import { useApp } from '../context/AppContext.js'; import { useIqAvailable } from '../hooks/useIq.js'; import IqCommentary from './iq/IqCommentary.jsx'; +import Family from './FamilyTree.jsx'; import { COMMENTARIES, loadCommentary, loadTheographic, loadPerson, pickCommentaryEntry, useAsync, } from '../hooks/useVerseStudy.js'; @@ -71,6 +72,7 @@ function PersonDetails({ id }) {
    {(data.description ?? []).map((d, i) =>

    {d}

    )} {!data.description?.length &&

    No description available.

    } + } /> {data.minYear != null && (

    Lived approx. {era(data.minYear)}{data.maxYear != null && data.maxYear !== data.minYear ? ` – ${era(data.maxYear)}` : ''} diff --git a/src/components/study/ScripturePane.jsx b/src/components/study/ScripturePane.jsx index c453382..e3f2176 100644 --- a/src/components/study/ScripturePane.jsx +++ b/src/components/study/ScripturePane.jsx @@ -3,6 +3,7 @@ import { useState } from 'react'; import { NT_BOOK_NUMBER } from '../../utils/bibleUtils.jsx'; import GrammarPanel from '../GrammarPanel.jsx'; +import { markUnits } from '../../utils/biblicalUnits.js'; export default function ScripturePane({ verses, chapter, interlinear, interlinearLoading, addedStrongs, onPickWord, verseNotes = {}, onVerseNote }) { const [openVerse, setOpenVerse] = useState(null); @@ -22,7 +23,9 @@ export default function ScripturePane({ verses, chapter, interlinear, interlinea className={`mr-1.5 align-super font-sans text-[0.6em] ${words ? 'text-[var(--r-accent)] hover:underline' : 'cursor-default text-[var(--r-sub)]'} ${open ? 'font-bold' : ''}`}> {v.chapter !== chapter?.chapter ? `${v.chapter}:` : ''}{v.number} - {v.text} + {markUnits(v.text).map((part, i) => (part.note + ? {part.text} + : {part.text}))} {onVerseNote && (