Studying: daily five minutes, reading list, plan-day studies use your template
- Home: "Your five minutes": a rotating Observe/Interpret/Apply prompt on the passage whose matching note is thinnest in your own words, a 5:00 clock and a box that appends to that note. Pasting is blocked; sessions show in Insights. - Study: a Reading list tab (R) for the study: books and articles to consult, with a note and a tick; merges across devices. - Starting a study from a reading-plan day now uses the notes template you last picked when starting a study by hand (logic moved to utils/planStudy.js). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0781fa3476
commit
eccc5e509d
20 files changed
+376
-32
No files matched your search
@@ -0,0 +1,66 @@
|
||||
// Home card: your five minutes for today. A short prompt on a passage from your studies, a five-minute clock, and a
|
||||
// box to write in. What you write is added to that passage's notes. Pasting is turned off here on purpose.
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useApp } from '../context/AppContext.js';
|
||||
import { loadProjectById } from '../utils/projectStorage.js';
|
||||
import { FIELD_NAMES, MINUTES, dailyPatch, pickDaily } from '../utils/dailyFive.js';
|
||||
import { formatClock } from '../utils/present.js';
|
||||
|
||||
const box = 'mb-4 rounded-xl border border-[var(--r-line)] bg-[var(--r-bar)] px-4 py-3 text-sm';
|
||||
|
||||
export default function DailyFive() {
|
||||
const { projectIndex, saveChunkPatch } = useApp();
|
||||
const [step, setStep] = useState('card'); // card -> write -> done
|
||||
const [text, setText] = useState('');
|
||||
const [left, setLeft] = useState(MINUTES * 60);
|
||||
const [pasteTried, setPasteTried] = useState(false);
|
||||
const pick = useMemo(() => pickDaily(projectIndex.map((e) => loadProjectById(e.id)).filter(Boolean)), [projectIndex]);
|
||||
const started = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (step !== 'write') return undefined;
|
||||
started.current = Date.now();
|
||||
const t = window.setInterval(() => setLeft(Math.max(0, MINUTES * 60 - Math.round((Date.now() - started.current) / 1000))), 1000);
|
||||
return () => window.clearInterval(t);
|
||||
}, [step]);
|
||||
|
||||
if (step === 'done') return <div role="status" className={box}>Saved to your notes. Same time tomorrow.</div>;
|
||||
if (!pick) return null;
|
||||
const { prompt } = pick;
|
||||
|
||||
const save = () => {
|
||||
saveChunkPatch(pick.projectId, pick.chunk.id, dailyPatch(pick.chunk, prompt.field, text));
|
||||
setStep('done');
|
||||
};
|
||||
|
||||
if (step === 'card') {
|
||||
return (
|
||||
<div className={`${box} flex flex-wrap items-center justify-between gap-3`}>
|
||||
<span><span className="font-medium">Your five minutes:</span> {pick.reference} <span className="text-[var(--r-sub)]">· {FIELD_NAMES[prompt.field]} · {pick.projectTitle}</span></span>
|
||||
<button type="button" onClick={() => { setText(''); setLeft(MINUTES * 60); setPasteTried(false); setStep('write'); }} className="rounded-full bg-[var(--r-text)] px-3 py-1 text-xs font-medium text-[var(--r-bg)]">Start</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<section aria-label="Your five minutes" className={box}>
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<p className="font-medium">{pick.reference} <span className="font-normal text-[var(--r-sub)]">· {FIELD_NAMES[prompt.field]}</span></p>
|
||||
<span role="timer" aria-label="Time left" className={`font-mono text-sm tabular-nums ${left === 0 ? 'text-[var(--r-accent)]' : 'text-[var(--r-sub)]'}`}>{left === 0 ? 'Time’s up, finish your thought' : formatClock(left)}</span>
|
||||
</div>
|
||||
{pick.verses.length > 0 && (
|
||||
<div className="mt-2 max-h-40 overflow-y-auto rounded-lg bg-[var(--r-bg)] p-3 font-serif text-[13px] leading-relaxed">
|
||||
{pick.verses.map((v) => <p key={v.number}><sup className="mr-1 font-sans text-[var(--r-accent)]">{v.number}</sup>{v.text}</p>)}
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-[var(--r-sub)]">{prompt.text}</p>
|
||||
<textarea aria-label="Your five minutes" value={text} onChange={(e) => setText(e.target.value)} rows={6} autoFocus
|
||||
onPaste={(e) => { e.preventDefault(); setPasteTried(true); }}
|
||||
className="mt-2 w-full resize-y rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 font-serif text-[15px] leading-7 focus:outline-none" />
|
||||
{pasteTried && <p role="note" className="mt-1 text-xs text-[var(--r-accent)]">Pasting is off here. Type it in your own words; that’s what the five minutes are for.</p>}
|
||||
<div className="mt-2 flex gap-2">
|
||||
<button type="button" disabled={!text.trim()} onClick={save} className="rounded-full bg-[var(--r-text)] px-3 py-1 text-xs font-medium text-[var(--r-bg)] disabled:opacity-40">Save to my notes</button>
|
||||
<button type="button" onClick={() => setStep('card')} className="text-xs text-[var(--r-sub)] hover:underline">Not now</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { vi, test, expect, beforeEach } from 'vitest';
|
||||
import { AppContext } from '../context/AppContext.js';
|
||||
import { saveProjectToStorage } from '../utils/projectStorage.js';
|
||||
import DailyFive from './DailyFive.jsx';
|
||||
import { promptFor } from '../utils/dailyFive.js';
|
||||
|
||||
const project = () => ({
|
||||
id: 'p1', title: 'John', translation: 'BSB', lastEdited: 1,
|
||||
chapters: [{ book: 'John', bookAbbrev: 'JHN', chapter: '3', verses: [{ number: 16, text: 'For God so loved the world.' }],
|
||||
chunks: [{ id: 'c1', startVerse: 16, endVerse: 16, observation: '', interpretation: '', application: '', editedAt: 1 }] }],
|
||||
});
|
||||
|
||||
function setup() {
|
||||
localStorage.clear();
|
||||
saveProjectToStorage(project());
|
||||
const saveChunkPatch = vi.fn();
|
||||
const value = { projectIndex: [{ id: 'p1' }], saveChunkPatch };
|
||||
render(<AppContext.Provider value={value}><DailyFive /></AppContext.Provider>);
|
||||
return { saveChunkPatch, user: userEvent.setup() };
|
||||
}
|
||||
beforeEach(() => localStorage.clear());
|
||||
|
||||
test('the card names a passage and what kind of note the five minutes are for', () => {
|
||||
setup();
|
||||
expect(screen.getByText(/your five minutes/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/John 3:16/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('pasting is blocked, and what you type is saved to the matching note', async () => {
|
||||
const { saveChunkPatch, user } = setup();
|
||||
await user.click(screen.getByRole('button', { name: 'Start' }));
|
||||
expect(screen.getByText('For God so loved the world.')).toBeInTheDocument();
|
||||
expect(screen.getByRole('timer')).toHaveTextContent('5:00');
|
||||
const box = screen.getByRole('textbox', { name: /your five minutes/i });
|
||||
fireEvent.paste(box, { clipboardData: { getData: () => 'text from somewhere else' } });
|
||||
expect(screen.getByText(/pasting is off here/i)).toBeInTheDocument();
|
||||
expect(box).toHaveValue('');
|
||||
expect(screen.getByRole('button', { name: /save to my notes/i })).toBeDisabled();
|
||||
await user.type(box, 'God gave.');
|
||||
await user.click(screen.getByRole('button', { name: /save to my notes/i }));
|
||||
const field = promptFor().field;
|
||||
expect(saveChunkPatch).toHaveBeenCalledWith('p1', 'c1', expect.objectContaining({ [field]: 'God gave.', fiveMinutes: [expect.objectContaining({ field })] }));
|
||||
expect(screen.getByRole('status')).toHaveTextContent(/saved to your notes/i);
|
||||
});
|
||||
@@ -24,7 +24,7 @@ const BY_PAGE = {
|
||||
['← →', 'Previous / next chunk'],
|
||||
['1 2 3', 'Write in Observe / Interpret / Apply'],
|
||||
['Esc', 'Leave a note box (then keys work again)'],
|
||||
['C W X P', 'Commentary / Word study / Cross-refs / People & places'],
|
||||
['C W X P R', 'Commentary / Word study / Cross-refs / People & places / Reading list'],
|
||||
['V', 'Compare passages or translations'],
|
||||
],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// A reading list for the whole study: the books, commentaries and articles you plan to consult, with a note on
|
||||
// each and a tick when done. It belongs to the study, not one chunk.
|
||||
import { makeId } from '../../utils/bibleUtils.jsx';
|
||||
|
||||
export default function ReadingListTab({ project, updateProject }) {
|
||||
const items = project?.readingList ?? [];
|
||||
const set = (next) => updateProject((current) => ({ ...current, readingList: next(current.readingList ?? []) }));
|
||||
const add = (title) => { const t = title.trim(); if (t) set((list) => [...list, { id: makeId(), title: t, note: '', done: false }]); };
|
||||
const patch = (id, change) => set((list) => list.map((i) => (i.id === id ? { ...i, ...change } : i)));
|
||||
const open = items.filter((i) => !i.done).length;
|
||||
return (
|
||||
<div className="space-y-3 text-sm">
|
||||
<p className="text-xs text-[var(--r-sub)]">Books and articles to consult for this study{items.length ? ` · ${open} to read, ${items.length - open} done` : ''}. Read the passage and write your own notes first; this list is for afterwards.</p>
|
||||
<ul className="space-y-2">
|
||||
{items.map((i) => (
|
||||
<li key={i.id} className="rounded-xl border border-[var(--r-line)] p-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="checkbox" aria-label={`Read: ${i.title}`} checked={!!i.done} onChange={() => patch(i.id, { done: !i.done })} className="accent-[var(--r-accent)]" />
|
||||
<span className={`min-w-0 flex-1 ${i.done ? 'text-[var(--r-sub)] line-through' : 'font-medium'}`}>{i.title}</span>
|
||||
<button type="button" aria-label={`Remove ${i.title}`} onClick={() => set((list) => list.filter((x) => x.id !== i.id))} className="text-xs text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]">×</button>
|
||||
</div>
|
||||
<input aria-label={`Note on ${i.title}`} value={i.note ?? ''} onChange={(e) => patch(i.id, { note: e.target.value })} placeholder="Where in it, or what to look for"
|
||||
className="mt-1.5 w-full rounded-lg border border-transparent bg-transparent px-2 py-1 text-xs text-[var(--r-text)] placeholder:text-[var(--r-sub)] placeholder:opacity-60 hover:border-[var(--r-line)] focus:border-[var(--r-line)] focus:bg-[var(--r-bg)] focus:outline-none" />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<input aria-label="Add to the reading list" placeholder="Add a book or article, then Enter"
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); add(e.target.value); e.target.value = ''; } }}
|
||||
className="w-full rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm text-[var(--r-text)] focus:outline-none" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import CommentaryChunkTab from './CommentaryChunkTab.jsx';
|
||||
import WordStudyTab from './WordStudyTab.jsx';
|
||||
import CrossRefsTab from './CrossRefsTab.jsx';
|
||||
import PeopleChunkTab from './PeopleChunkTab.jsx';
|
||||
import ReadingListTab from './ReadingListTab.jsx';
|
||||
import IqChunkAids from '../iq/IqChunkAids.jsx';
|
||||
import { useIqAvailable } from '../../hooks/useIq.js';
|
||||
import { OBSERVE_FIRST_MIN, observeFirstDone, ownWords } from '../../utils/ownWork.js';
|
||||
@@ -14,10 +15,11 @@ export const RESOURCE_TABS = [
|
||||
{ id: 'words', label: 'Word study' },
|
||||
{ id: 'refs', label: 'Cross-refs' },
|
||||
{ id: 'people', label: 'People & places' },
|
||||
{ id: 'readings', label: 'Reading list' },
|
||||
{ id: 'aids', label: 'Study aids', needsIq: true }, // shown only when the server has an IQ Bible key
|
||||
];
|
||||
|
||||
export default function ResourcePane({ tab, setTab, chunk, chapter, chapterIndex, project, updateChunk, notify, resources, wordStudy, crossRefs, openWordId, setOpenWordId }) {
|
||||
export default function ResourcePane({ tab, setTab, chunk, chapter, chapterIndex, project, updateChunk, updateProject, notify, resources, wordStudy, crossRefs, openWordId, setOpenWordId }) {
|
||||
const iq = useIqAvailable();
|
||||
const tabs = RESOURCE_TABS.filter((t) => !t.needsIq || iq);
|
||||
if (tab === 'aids' && !iq) tab = 'commentary';
|
||||
@@ -32,7 +34,7 @@ export default function ResourcePane({ tab, setTab, chunk, chapter, chapterIndex
|
||||
if (!seeing || chunk.firstDraft) return;
|
||||
updateChunk(chunk.id, { firstDraft: { at: Date.now(), observation: chunk.observation ?? '', interpretation: chunk.interpretation ?? '', application: chunk.application ?? '' } }, { touch: false });
|
||||
}, [seeing, chunk.id, chunk.firstDraft]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
const counts = { words: chunk.greekWords.length, refs: (chunk.crossReferences ?? []).length };
|
||||
const counts = { words: chunk.greekWords.length, refs: (chunk.crossReferences ?? []).length, readings: (project?.readingList ?? []).filter((i) => !i.done).length };
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div role="tablist" aria-label="Study resources" className="flex shrink-0 flex-wrap gap-1.5 border-b border-[var(--r-line)] px-3 py-2.5">
|
||||
@@ -55,6 +57,7 @@ export default function ResourcePane({ tab, setTab, chunk, chapter, chapterIndex
|
||||
{tab === 'words' && <WordStudyTab wordStudy={wordStudy} chunk={chunk} openWordId={openWordId} setOpenWordId={setOpenWordId} />}
|
||||
{tab === 'refs' && <CrossRefsTab crossRefs={crossRefs} chunk={chunk} />}
|
||||
{tab === 'people' && <PeopleChunkTab book={chapter.bookAbbrev} chapter={chapter.chapter} chunk={chunk} />}
|
||||
{tab === 'readings' && updateProject && <ReadingListTab project={project} updateProject={updateProject} />}
|
||||
{!held && tab === 'aids' && <IqChunkAids chunk={chunk} chapter={chapter} updateChunk={updateChunk} notify={notify} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user