Add Observe/Interpret/Apply boxes to the translation compare view

The same notes as the Study page, editable beside the translations; long
pastes are tagged as pasted the same way.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-14 12:50:26 -04:00
1 parent 76df145b1e
commit 3a28ffa51a
2 files changed
+53 -3

No files matched your search

+41 -3
View File
@@ -6,6 +6,7 @@ import { loadProjectById } from '../../utils/projectStorage.js';
import { formatChunkReference, getChunkVerseEntries } from '../../utils/bibleUtils.jsx'; import { formatChunkReference, getChunkVerseEntries } from '../../utils/bibleUtils.jsx';
import { READER_TRANSLATIONS, fetchChapter, translationName } from '../../utils/bibleText.js'; import { READER_TRANSLATIONS, fetchChapter, translationName } from '../../utils/bibleText.js';
import { alignVerses, translationSpans } from '../../utils/compareTranslations.js'; import { alignVerses, translationSpans } from '../../utils/compareTranslations.js';
import { ownShare, pastedWords, withPaste } from '../../utils/ownWork.js';
const FIELDS = [['observation', 'Observe'], ['interpretation', 'Interpret'], ['application', 'Apply']]; const FIELDS = [['observation', 'Observe'], ['interpretation', 'Interpret'], ['application', 'Apply']];
@@ -37,6 +38,37 @@ function Column({ entry }) {
); );
} }
const OIA = [
['observation', 'Observe', 'What does the text say?'],
['interpretation', 'Interpret', 'What does it mean?'],
['application', 'Apply', 'What do I do with it?'],
];
// The chunk's own Observe / Interpret / Apply notes, editable right here while the translations are side by side.
// They are the same notes as on the Study page, and long pastes are tagged the same way.
function OiaBoxes({ chunk, updateChunk }) {
const share = ownShare(chunk);
return (
<section aria-label="Your notes" className="border-t border-[var(--r-line)] px-4 pb-6 pt-4">
<p className="mb-3 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Your notes{share !== null ? ` · ${share}% your own words` : ''}</p>
<div className="grid gap-4 md:grid-cols-3">
{OIA.map(([field, label, hint]) => {
const pasted = pastedWords(chunk, field);
return (
<label key={field} className="flex flex-col text-xs text-[var(--r-sub)]">
<span className="mb-1 flex items-baseline justify-between"><span><span className="text-sm font-medium text-[var(--r-text)]">{label}</span> {hint}</span>{pasted > 0 && <span className="rounded-full bg-[var(--r-hl)] px-2 py-0.5 text-[var(--r-text)]">{pasted} pasted</span>}</span>
<textarea aria-label={label} value={chunk[field] ?? ''} rows={9}
onPaste={(e) => { const next = withPaste(chunk, field, e.clipboardData?.getData('text')); if (next) updateChunk(chunk.id, { aiPastes: next }); }}
onChange={(e) => updateChunk(chunk.id, { [field]: e.target.value })}
className="min-h-[10rem] w-full resize-y rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2.5 font-serif text-[15px] leading-7 text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]" />
</label>
);
})}
</div>
</section>
);
}
// One chunk's verses in several translations, a row per verse. Free translations load straight away; licensed ones // One chunk's verses in several translations, a row per verse. Free translations load straight away; licensed ones
// come through the server and may be unavailable (no key set), which shows as a note instead of text. // come through the server and may be unavailable (no key set), which shows as a note instead of text.
function TranslationsView({ project, chunkEntry }) { function TranslationsView({ project, chunkEntry }) {
@@ -118,9 +150,14 @@ function TranslationsView({ project, chunkEntry }) {
} }
export default function CompareSheet({ currentChunkId, onClose }) { export default function CompareSheet({ currentChunkId, onClose }) {
const { projectIndex, project } = useApp(); const { projectIndex, project, updateChunk } = useApp();
const all = useMemo(() => choices(projectIndex, project), [projectIndex, project]); // Other studies are read from storage once per list change; the open study comes straight from state so typing stays quick.
const mine = all.find((c) => c.chunk.id === currentChunkId && c.project.id === project?.id); const others = useMemo(() => choices(projectIndex.filter((e) => e.id !== project?.id), null), [projectIndex, project?.id]);
const here = useMemo(() => (project?.chapters ?? []).flatMap((chapter, chapterIndex) => chapter.chunks.map((chunk) => ({
key: `${project.id}:${chunk.id}`, project, chapterIndex, chunk, label: `${formatChunkReference(project, chapterIndex, chunk, '–')} · ${project.title}`,
}))), [project]);
const mine = here.find((c) => c.chunk.id === currentChunkId);
const all = useMemo(() => [...here, ...others], [here, others]);
const [mode, setMode] = useState('passage'); // 'passage' | 'translations' const [mode, setMode] = useState('passage'); // 'passage' | 'translations'
const [otherKey, setOtherKey] = useState(''); const [otherKey, setOtherKey] = useState('');
const other = all.find((c) => c.key === otherKey); const other = all.find((c) => c.key === otherKey);
@@ -151,6 +188,7 @@ export default function CompareSheet({ currentChunkId, onClose }) {
<div className="min-h-0 flex-1 overflow-y-auto"> <div className="min-h-0 flex-1 overflow-y-auto">
<h3 className="px-4 pt-4 font-medium">{mine?.label}</h3> <h3 className="px-4 pt-4 font-medium">{mine?.label}</h3>
<TranslationsView project={project} chunkEntry={mine} /> <TranslationsView project={project} chunkEntry={mine} />
{mine && <OiaBoxes chunk={mine.chunk} updateChunk={updateChunk} />}
</div> </div>
)} )}
</div> </div>
+12
View File
@@ -448,6 +448,18 @@ describe('Study workspace', () => {
expect(notes).toHaveAttribute('aria-expanded', 'true'); expect(notes).toHaveAttribute('aria-expanded', 'true');
}); });
test('compare translations: the Observe / Interpret / Apply boxes edit the same notes', async () => {
const user = await studyOneChunk();
await user.click(screen.getByRole('button', { name: /compare…/i }));
await user.click(screen.getByRole('tab', { name: /translations/i }));
const dialog = screen.getByRole('dialog', { name: /compare/i });
expect(within(dialog).getByRole('textbox', { name: 'Interpret' })).toBeInTheDocument();
expect(within(dialog).getByRole('textbox', { name: 'Apply' })).toBeInTheDocument();
fireEvent.change(within(dialog).getByRole('textbox', { name: 'Observe' }), { target: { value: 'Written beside the translations.' } });
await user.click(within(dialog).getByRole('button', { name: /close/i }));
expect(screen.getByRole('textbox', { name: 'Observe' })).toHaveValue('Written beside the translations.');
});
test('people & places: lists what is tagged in the chunk', async () => { test('people & places: lists what is tagged in the chunk', async () => {
const user = await studyOneChunk([['/d/theographic/TIT/1.json', { chapter: { people: [{ id: 'paul_1', name: 'Paul', verses: [1] }, { id: 'x_2', name: 'Elsewhere', verses: [9] }], places: [], events: [] } }]]); const user = await studyOneChunk([['/d/theographic/TIT/1.json', { chapter: { people: [{ id: 'paul_1', name: 'Paul', verses: [1] }, { id: 'x_2', name: 'Elsewhere', verses: [9] }], places: [], events: [] } }]]);
await user.click(screen.getByRole('tab', { name: /people & places/i })); await user.click(screen.getByRole('tab', { name: /people & places/i }));