diff --git a/src/App.jsx b/src/App.jsx
index 10ffb59..14f634e 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -11,6 +11,7 @@ import Tooltips from './components/Tooltips.jsx';
import { useAudio } from './hooks/useAudio.js';
import { useAuth } from './hooks/useAuth.js';
import { useBibleReader } from './hooks/useBibleReader.js';
+import { lastTemplateId, planStudyProject } from './utils/planStudy.js';
import { isAudienceWindow } from './utils/presentSync.js';
import { useProjectLibrary } from './hooks/useProjectLibrary.js';
import { useHistoryNav } from './hooks/useHistoryNav.js';
@@ -425,29 +426,13 @@ const AppInner = () => {
const studyReadingPlanDay = async (dayRefs, title) => {
setStudyBuildBusy(true);
try {
+ const lastTemplate = lastTemplateId(); // the notes template you picked last time you started a study by hand
+ const templateDef = reader.userTemplates?.find((t) => t.id === lastTemplate);
const chapters = await Promise.all(dayRefs.map(async ({ book, chapter }) => {
const data = await fetchStudyChapter('BSB', book, chapter);
- const verses = parseBibleChapter(data);
- const bookName = bookOptions.find((b) => b.abbrev === book)?.name ?? book;
- const chunk = {
- id: makeId(),
- startVerse: verses[0]?.number ?? 1,
- endVerse: verses.at(-1)?.number ?? 1,
- spilloverEndVerse: null,
- observation: '', interpretation: '', application: '',
- crossReferences: [], greekWords: [], generalNotes: '',
- episodeNumber: '', episodeTitle: '', finalScript: '', tags: [],
- };
- return { book: bookName, bookAbbrev: book, chapter: String(chapter), verses, chunks: [chunk] };
+ return { bookAbbrev: book, bookName: bookOptions.find((b) => b.abbrev === book)?.name ?? book, chapter, verses: parseBibleChapter(data) };
}));
- const newProject = {
- id: makeId(),
- title: title || 'Reading Plan Study',
- translation: 'BSB',
- lastEdited: Date.now(),
- selectedChunkId: chapters[0]?.chunks[0]?.id ?? null,
- chapters,
- };
+ const newProject = planStudyProject({ chapters, title, templateId: lastTemplate, templateDef, makeId });
setProject(newProject);
setCurrentPage('study');
} catch (err) {
diff --git a/src/components/DailyFive.jsx b/src/components/DailyFive.jsx
new file mode 100644
index 0000000..70bf82a
--- /dev/null
+++ b/src/components/DailyFive.jsx
@@ -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
Saved to your notes. Same time tomorrow.
;
+ 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 (
+ {week.teachBacks} teach-it-back summar{week.teachBacks === 1 ? 'y' : 'ies'} written.
}
+ {(week.teachBacks > 0 || week.fiveMinutes > 0) && {[week.fiveMinutes > 0 && `${week.fiveMinutes} five-minute session${week.fiveMinutes === 1 ? '' : 's'}`, week.teachBacks > 0 && `${week.teachBacks} teach-it-back summar${week.teachBacks === 1 ? 'y' : 'ies'}`].filter(Boolean).join(' · ')} this week.
}
diff --git a/src/pages/StudyPage.jsx b/src/pages/StudyPage.jsx
index 3e32897..d40efe9 100644
--- a/src/pages/StudyPage.jsx
+++ b/src/pages/StudyPage.jsx
@@ -16,9 +16,9 @@ import ResourcePane, { useResourceTab } from '../components/study/ResourcePane.j
import CompareSheet from '../components/study/CompareSheet.jsx';
import SuggestWordsSheet from '../components/study/SuggestWordsSheet.jsx';
-// Keyboard-only study: 1 / 2 / 3 jump to Observe / Interpret / Apply; c, w, x, p open the resource tabs; v compares.
+// Keyboard-only study: 1 / 2 / 3 jump to Observe / Interpret / Apply; c, w, x, p, r open the resource tabs; v compares.
const NOTE_KEYS = { 1: 'observation', 2: 'interpretation', 3: 'application' };
-const TAB_KEYS = { c: 'commentary', w: 'words', x: 'refs', p: 'people' };
+const TAB_KEYS = { c: 'commentary', w: 'words', x: 'refs', p: 'people', r: 'readings' };
export default function StudyPage() {
const { project, allChunks, projectIndex, updateProject, updateChunk, setCurrentPage, readerCommentary, setReaderCommentary, saveUserTemplate, startPrayerFor } = useApp();
@@ -183,7 +183,7 @@ export default function StudyPage() {
diff --git a/src/test/App.home.test.jsx b/src/test/App.home.test.jsx
index 11cc6b6..c5ba525 100644
--- a/src/test/App.home.test.jsx
+++ b/src/test/App.home.test.jsx
@@ -509,6 +509,18 @@ describe('Study workspace', () => {
expect(screen.getByRole('textbox', { name: 'Observe' })).toHaveValue('Written beside the translations.');
});
+ test('reading list: add a book to the study, tick it off, and it is remembered with the study', async () => {
+ const user = await studyOneChunk();
+ await user.click(screen.getByRole('tab', { name: /reading list/i }));
+ await user.type(screen.getByRole('textbox', { name: /add to the reading list/i }), 'Carson on John{Enter}');
+ const tick = screen.getByRole('checkbox', { name: /read: carson on john/i });
+ expect(tick).not.toBeChecked();
+ await user.click(tick);
+ expect(screen.getByRole('checkbox', { name: /read: carson on john/i })).toBeChecked();
+ await user.click(screen.getByRole('tab', { name: /commentary/i }));
+ expect(screen.getByRole('tab', { name: /reading list/i })).toBeInTheDocument(); // none left to read, so no count
+ });
+
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: [] } }]]);
await user.click(screen.getByRole('tab', { name: /people & places/i }));
diff --git a/src/utils/dailyFive.js b/src/utils/dailyFive.js
new file mode 100644
index 0000000..1ed2728
--- /dev/null
+++ b/src/utils/dailyFive.js
@@ -0,0 +1,53 @@
+// Daily five minutes: one short prompt a day, on a passage from your studies where that kind of note is thinnest.
+// What you write is added to the matching note (Observe, Interpret or Apply) and counts as your own words.
+import { formatChunkReference, getChunkVerseEntries } from './bibleUtils.jsx';
+import { ownWords } from './ownWork.js';
+
+export const MINUTES = 5;
+
+export const PROMPTS = [
+ { field: 'observation', text: 'Read the passage slowly. Write three things you notice: a repeated word, who is speaking, something that surprises you.' },
+ { field: 'interpretation', text: 'What would the first hearers have understood? Write one or two sentences about the situation they were in.' },
+ { field: 'application', text: 'Write one thing you will believe differently, and one thing you will do, because of this passage.' },
+ { field: 'observation', text: 'What does this passage say about God? Say it using only what the text itself says.' },
+ { field: 'interpretation', text: 'How does this passage connect to what comes just before or after it? Write how it fits.' },
+ { field: 'application', text: 'Who is one person this passage speaks to? Write what you would say to them.' },
+ { field: 'observation', text: 'Read it aloud. Which word or phrase stands out most? Write down why.' },
+];
+export const FIELD_NAMES = { observation: 'Observe', interpretation: 'Interpret', application: 'Apply' };
+
+const dayNumber = (now) => { const d = new Date(now); return Math.floor(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()) / 86400000); };
+export const promptFor = (now = Date.now()) => PROMPTS[dayNumber(now) % PROMPTS.length];
+const sameDay = (a, b) => new Date(a).toDateString() === new Date(b).toDateString();
+
+// Did you already do your five minutes today?
+export function doneToday(projects, now = Date.now()) {
+ return (projects ?? []).some((p) => (p?.chapters ?? []).some((ch) => (ch.chunks ?? []).some((c) => (c.fiveMinutes ?? []).some((f) => sameDay(f.at, now)))));
+}
+
+// Today's prompt and the passage to use it on: the chunk whose notes for that prompt have the fewest words of
+// your own (oldest first on a tie). null when there are no studies, or today's five minutes are done.
+export function pickDaily(projects, now = Date.now()) {
+ if (doneToday(projects, now)) return null;
+ const prompt = promptFor(now);
+ const all = (projects ?? []).flatMap((project) => (project?.chapters ?? []).flatMap((chapter, chapterIndex) => (chapter.chunks ?? []).map((chunk) => ({ project, chapterIndex, chunk }))));
+ const [best] = all.sort((a, b) => ownWords(a.chunk, prompt.field) - ownWords(b.chunk, prompt.field) || (a.chunk.editedAt ?? 0) - (b.chunk.editedAt ?? 0));
+ if (!best) return null;
+ return {
+ prompt, projectId: best.project.id, projectTitle: best.project.title, chunk: best.chunk,
+ reference: formatChunkReference(best.project, best.chapterIndex, best.chunk, '–'),
+ verses: getChunkVerseEntries(best.project, best.chapterIndex, best.chunk).map((v) => ({ number: v.number, text: v.text })),
+ };
+}
+
+const marker = (now) => `— five minutes, ${new Date(now).toLocaleDateString([], { month: 'short', day: 'numeric' })} —`;
+
+// The patch that adds what you wrote to the chunk (below what's already there) and records the session.
+export function dailyPatch(chunk, field, text, now = Date.now()) {
+ const added = text.trim();
+ const have = (chunk[field] ?? '').replace(/\s+$/, '');
+ return {
+ [field]: have ? `${have}\n\n${marker(now)}\n${added}` : added,
+ fiveMinutes: [...(chunk.fiveMinutes ?? []), { at: now, field }],
+ };
+}
diff --git a/src/utils/dailyFive.test.js b/src/utils/dailyFive.test.js
new file mode 100644
index 0000000..7b8b424
--- /dev/null
+++ b/src/utils/dailyFive.test.js
@@ -0,0 +1,42 @@
+import { test, expect } from 'vitest';
+import { PROMPTS, dailyPatch, doneToday, pickDaily, promptFor } from './dailyFive.js';
+
+const NOW = new Date('2026-10-14T12:00:00').getTime();
+const DAY = 86400000;
+const text = (n) => Array.from({ length: n }, (_, i) => `w${i}`).join(' ');
+const chunk = (id, extra = {}) => ({ id, startVerse: 1, endVerse: 2, observation: '', interpretation: '', application: '', editedAt: 1, ...extra });
+const study = (chunks) => ({ id: 'p', title: 'John', translation: 'BSB', chapters: [{ book: 'John', bookAbbrev: 'JHN', chapter: '3', verses: [{ number: 1, text: 'a' }, { number: 2, text: 'b' }], chunks }] });
+
+test('the prompt changes each day and cycles through all of them', () => {
+ const seen = new Set(Array.from({ length: PROMPTS.length }, (_, i) => promptFor(NOW + i * DAY).text));
+ expect(seen.size).toBe(PROMPTS.length);
+ expect(promptFor(NOW)).toBe(promptFor(NOW + 3600_000));
+});
+
+test('the passage chosen is the one with the fewest words of your own for that prompt', () => {
+ const field = promptFor(NOW).field;
+ const thick = chunk('thick', { [field]: text(80) });
+ const thin = chunk('thin', { [field]: text(3) });
+ const pasted = text(60);
+ const fake = chunk('fake', { [field]: pasted, aiPastes: { [field]: [pasted] } }); // pasted words count for nothing
+ const pick = pickDaily([study([thick, thin, fake])], NOW);
+ expect(pick.chunk.id).toBe('fake');
+ expect(pick.reference).toContain('John');
+ expect(pick.verses).toHaveLength(2);
+ expect(pickDaily([], NOW)).toBeNull();
+});
+
+test('once today is done, nothing more is offered until tomorrow', () => {
+ const done = chunk('a', { fiveMinutes: [{ at: NOW - 3600_000, field: 'observation' }] });
+ expect(doneToday([study([done])], NOW)).toBe(true);
+ expect(pickDaily([study([done, chunk('b')])], NOW)).toBeNull();
+ expect(pickDaily([study([done, chunk('b')])], NOW + DAY)).not.toBeNull();
+});
+
+test('what you write is added below your existing note and the session is recorded', () => {
+ const empty = dailyPatch(chunk('a'), 'observation', ' Three things. ', NOW);
+ expect(empty.observation).toBe('Three things.');
+ expect(empty.fiveMinutes).toEqual([{ at: NOW, field: 'observation' }]);
+ const more = dailyPatch(chunk('a', { observation: 'Earlier.\n' }), 'observation', 'Later.', NOW);
+ expect(more.observation).toMatch(/^Earlier\.\n\n— five minutes, .+ —\nLater\.$/);
+});
diff --git a/src/utils/insights.js b/src/utils/insights.js
index 3a40259..4da5b0a 100644
--- a/src/utils/insights.js
+++ b/src/utils/insights.js
@@ -25,6 +25,7 @@ export function weeklySummary(projects, now = Date.now()) {
ownWords: ownWordsTotal,
ownShare: shares.length ? Math.round(shares.reduce((a, b) => a + b, 0) / shares.length) : null,
recalls: all.reduce((n, { chunk }) => n + (chunk.recalls ?? []).filter((r) => now - (r.at ?? 0) < WEEK_MS).length, 0),
+ fiveMinutes: all.reduce((n, { chunk }) => n + (chunk.fiveMinutes ?? []).filter((f) => now - (f.at ?? 0) < WEEK_MS).length, 0),
teachBacks: recent.filter(({ chunk }) => (chunk.teachBack ?? '').trim()).length,
};
}
diff --git a/src/utils/insights.test.js b/src/utils/insights.test.js
index d5cf9f5..c4d48e4 100644
--- a/src/utils/insights.test.js
+++ b/src/utils/insights.test.js
@@ -46,3 +46,8 @@ test('slipped lines and follow-ups are collected, open follow-ups first', () =>
expect(slippedList(projects)[0].items).toEqual(['the serpent', 'who spoke']);
expect(followUpList(projects).map((f) => f.text)).toEqual(['call Sam', 'done thing']);
});
+
+test('five-minute sessions this week are counted', () => {
+ const p = [study([chunk('a', { fiveMinutes: [{ at: NOW - 1000, field: 'observation' }, { at: NOW - 3 * WEEK_MS, field: 'application' }] })])];
+ expect(weeklySummary(p, NOW).fiveMinutes).toBe(1);
+});
diff --git a/src/utils/mergeStudies.js b/src/utils/mergeStudies.js
index 4bc6a49..bf7bb62 100644
--- a/src/utils/mergeStudies.js
+++ b/src/utils/mergeStudies.js
@@ -41,6 +41,7 @@ export function mergeChunk(mine, theirs) {
const noteKeys = new Set([...Object.keys(newer.verseNotes ?? {}), ...Object.keys(older.verseNotes ?? {})]);
out.verseNotes = Object.fromEntries([...noteKeys].map((k) => [k, mergeText(newer.verseNotes?.[k], older.verseNotes?.[k])]));
out.firstDraft = [newer.firstDraft, older.firstDraft].filter(Boolean).sort((a, b) => a.at - b.at)[0];
+ out.fiveMinutes = union(newer.fiveMinutes, older.fiveMinutes);
out.followUps = mergeById(newer.followUps, older.followUps);
out.recalls = union(newer.recalls, older.recalls);
out.editedAt = Math.max(mine.editedAt ?? 0, theirs.editedAt ?? 0) || undefined;
@@ -77,7 +78,7 @@ export function mergeStudies(mine, theirs) {
}
const ids = new Set(chapters.flatMap((c) => c.chunks.map((k) => k.id)));
return {
- project: { ...mine, chapters, selectedChunkId: ids.has(mine.selectedChunkId) ? mine.selectedChunkId : [...ids][0] ?? null },
+ project: { ...mine, chapters, ...(mine.readingList || theirs.readingList ? { readingList: mergeById(mine.readingList, theirs.readingList) } : {}), selectedChunkId: ids.has(mine.selectedChunkId) ? mine.selectedChunkId : [...ids][0] ?? null },
stats,
};
}
diff --git a/src/utils/mergeStudies.test.js b/src/utils/mergeStudies.test.js
index 72d0ffd..ae3f88b 100644
--- a/src/utils/mergeStudies.test.js
+++ b/src/utils/mergeStudies.test.js
@@ -49,3 +49,15 @@ test('identical copies merge to themselves', () => {
expect(project).toEqual(s);
expect(stats).toEqual({ added: 0, merged: 0 });
});
+
+import { mergeStudies as mergeBoth } from './mergeStudies.js';
+
+test('merging two copies keeps every reading-list item, mine winning when both changed one', () => {
+ const base = { id: 'p', title: 'T', chapters: [] };
+ const mine = { ...base, readingList: [{ id: 'a', title: 'Mounce', done: true }, { id: 'b', title: 'Carson', done: false }] };
+ const theirs = { ...base, readingList: [{ id: 'a', title: 'Mounce', done: false }, { id: 'c', title: 'Stott', done: false }] };
+ const { project } = mergeBoth(mine, theirs);
+ expect(project.readingList.map((i) => i.id).sort()).toEqual(['a', 'b', 'c']);
+ expect(project.readingList.find((i) => i.id === 'a').done).toBe(true);
+ expect(mergeBoth(base, base).project.readingList).toBeUndefined();
+});
diff --git a/src/utils/planStudy.js b/src/utils/planStudy.js
new file mode 100644
index 0000000..8bd5f00
--- /dev/null
+++ b/src/utils/planStudy.js
@@ -0,0 +1,33 @@
+// A study started from one day of a reading plan: a chunk for each whole chapter in the day, with the notes template
+// you last used when starting a study by hand (so the prompts and starter text are the ones you're used to).
+import { templateStarter } from './studyTemplates.js';
+
+export const lastTemplateId = () => { try { return localStorage.getItem('last-template') || 'blank'; } catch { return 'blank'; } };
+export const rememberTemplate = (id) => { try { localStorage.setItem('last-template', id || 'blank'); } catch { /* storage unavailable */ } };
+
+// chapters: [{ bookAbbrev, bookName, chapter, verses: [{ number, text }] }]; ids come from `makeId`.
+export function planStudyProject({ chapters, title, templateId = 'blank', templateDef = null, makeId, now = Date.now() }) {
+ const built = chapters.map(({ bookAbbrev, bookName, chapter, verses }) => ({
+ book: bookName, bookAbbrev, chapter: String(chapter), verses,
+ chunks: [{
+ id: makeId(),
+ startVerse: verses[0]?.number ?? 1,
+ endVerse: verses.at(-1)?.number ?? 1,
+ spilloverEndVerse: null,
+ observation: '', interpretation: '', application: '',
+ crossReferences: [], greekWords: [], generalNotes: '',
+ episodeNumber: '', episodeTitle: '', finalScript: '', tags: [],
+ ...templateStarter(templateDef ?? templateId),
+ }],
+ }));
+ return {
+ id: makeId(),
+ title: title || 'Reading Plan Study',
+ translation: 'BSB',
+ templateId,
+ ...(templateDef ? { templateDef } : {}),
+ lastEdited: now,
+ selectedChunkId: built[0]?.chunks[0]?.id ?? null,
+ chapters: built,
+ };
+}
diff --git a/src/utils/planStudy.test.js b/src/utils/planStudy.test.js
new file mode 100644
index 0000000..61732de
--- /dev/null
+++ b/src/utils/planStudy.test.js
@@ -0,0 +1,43 @@
+import { test, expect, beforeEach } from 'vitest';
+import { lastTemplateId, planStudyProject, rememberTemplate } from './planStudy.js';
+import { STUDY_TEMPLATES } from './studyTemplates.js';
+
+beforeEach(() => localStorage.clear());
+let n = 0;
+const makeId = () => `id${n += 1}`;
+const chapters = [
+ { bookAbbrev: 'JHN', bookName: 'John', chapter: 3, verses: [{ number: 1, text: 'a' }, { number: 2, text: 'b' }] },
+ { bookAbbrev: 'JHN', bookName: 'John', chapter: 4, verses: [{ number: 1, text: 'c' }] },
+];
+
+test('a plan day becomes one whole-chapter chunk per chapter', () => {
+ const project = planStudyProject({ chapters, title: 'Day 4', makeId });
+ expect(project.title).toBe('Day 4');
+ expect(project.chapters.map((c) => [c.chapter, c.chunks[0].startVerse, c.chunks[0].endVerse])).toEqual([['3', 1, 2], ['4', 1, 1]]);
+ expect(project.selectedChunkId).toBe(project.chapters[0].chunks[0].id);
+ expect(planStudyProject({ chapters, makeId }).title).toBe('Reading Plan Study');
+});
+
+test('it starts from the template you used last, with its starter text', () => {
+ const withStarter = STUDY_TEMPLATES.find((t) => Object.keys(t).some((k) => k === 'starter' || k === 'starters'));
+ const id = withStarter?.id ?? 'blank';
+ rememberTemplate(id);
+ expect(lastTemplateId()).toBe(id);
+ const project = planStudyProject({ chapters, templateId: lastTemplateId(), makeId });
+ expect(project.templateId).toBe(id);
+ if (withStarter) {
+ const chunk = project.chapters[0].chunks[0];
+ expect(Object.values(chunk).some((v) => typeof v === 'string' && v.length > 20)).toBe(true);
+ }
+});
+
+test('your own template travels with the study', () => {
+ const def = { id: 'mine', name: 'Mine', prompts: {}, starters: { observation: 'Who? What? Where?' } };
+ const project = planStudyProject({ chapters, templateId: 'mine', templateDef: def, makeId });
+ expect(project.templateDef).toBe(def);
+ expect(project.chapters[0].chunks[0].observation).toBe('Who? What? Where?');
+});
+
+test('with no template remembered, it is blank', () => {
+ expect(lastTemplateId()).toBe('blank');
+});