Merge studying-and-housekeeping: daily five minutes, reading list, browser tests
Build and Push Docker Image / build-and-push (push) Successful in 8m10s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-14 14:13:03 -04:00
commit 1bc515164a
35 files changed
+692 -37

No files matched your search

+8
View File
@@ -13,3 +13,11 @@ coverage/
.env .env
.session-secret .session-secret
server/data/backups/ server/data/backups/
# Playwright
e2e/.auth.json
test-results/
playwright-report/
# a local demo video, not part of the app
demo/
+8 -1
View File
@@ -151,10 +151,17 @@ Private / personal use.
## Development ## Development
```bash ```bash
npm run test:run # all tests npm run test:run # all unit and component tests
npm run test:e2e # real-browser tests (Playwright, in your installed Chrome)
npm run lint # ESLint (errors fail; warnings are advice) npm run lint # ESLint (errors fail; warnings are advice)
``` ```
The browser tests (`e2e/`) build the app, run it on port 3101 with an empty throwaway database (your real studies are never
touched), make an account, and click through the main flows: writing notes and the paste check, the daily five minutes and weekly
recall, comparing translations (Bible text is faked, so no internet is needed), and presenting on two screens. They use the
Chrome already on the computer, so there is no browser to download; install Chrome, or change `channel` in `playwright.config.js`
and run `npx playwright install` to use Playwright's own browser.
Who can sign up: the administrator (the `ADMIN_EMAIL` account) chooses Open, Invite only or Closed under Admin → Sign-ups, and makes single-use invite links there. The very first account can always be created. Who can sign up: the administrator (the `ADMIN_EMAIL` account) chooses Open, Invite only or Closed under Admin → Sign-ups, and makes single-use invite links there. The very first account can always be created.
Verse of the day push: with `IQBIBLE_API_KEY` set, each person can turn on a daily verse notification in Settings → Reading reminders (it goes to the same ntfy topic). Verse of the day push: with `IQBIBLE_API_KEY` set, each person can turn on a daily verse notification in Settings → Reading reminders (it goes to the same ntfy topic).
+3 -2
View File
@@ -9,8 +9,9 @@ Ideas for the app that haven't been built yet, roughly in the order I'd do them.
"sign out other devices"; optionally a login alert through ntfy. "sign out other devices"; optionally a login alert through ntfy.
- [x] **Server health page for the admin.** Last backup, disk use, failed notifications, recent sign-in failures, and a - [x] **Server health page for the admin.** Last backup, disk use, failed notifications, recent sign-in failures, and a
"restore a snapshot" button (snapshots already exist under `DATA_DIR/backups`). "restore a snapshot" button (snapshots already exist under `DATA_DIR/backups`).
- [ ] **Checks on every push.** Run tests and lint on the Gitea server, plus a few real-browser tests (Playwright) for sign-in, - [ ] **Checks on every push.** Run `npm run test:run`, `npm run lint` and `npm run test:e2e` on the Gitea server for every push.
creating a study and syncing; so far everything is tested in simulation. The real-browser tests now exist (`e2e/`, run by hand); what's left is running them automatically, and adding sign-in
and cross-device sync to them.
- [x] **Split `server/index.js`** into one file per area (`server/routes/`: auth, account, projects, reader, sharing, admin). - [x] **Split `server/index.js`** into one file per area (`server/routes/`: auth, account, projects, reader, sharing, admin).
## Teaching together ## Teaching together
+12
View File
@@ -0,0 +1,12 @@
// Makes one account in the throwaway database and keeps its sign-in for the other tests.
import { test as setup, expect } from '@playwright/test';
setup('create an account and sign in', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: /need an account\? sign up/i }).click();
await page.locator('input[type="email"]').fill('e2e@example.test');
await page.locator('input[type="password"]').fill('E2e-Test-Password-7391');
await page.getByRole('button', { name: 'Create account' }).click();
await expect(page.getByRole('heading', { name: 'My Studies' })).toBeVisible();
await page.context().storageState({ path: 'e2e/.auth.json' });
});
+37
View File
@@ -0,0 +1,37 @@
import { test, expect } from '@playwright/test';
import { resumeButton, seedStudy } from './helpers.js';
// Fake the free Bible text so the test needs no internet: every translation answers with its own wording.
const WORDING = {
BSB: { 16: 'For God so loved the world that He gave His one and only Son', 17: 'For God did not send His Son into the world to condemn the world', 18: 'Whoever believes in Him is not condemned' },
NET: { 16: 'For this is the way God loved the world: He gave his one and only Son', 17: 'For God did not send his Son into the world to condemn the world', 18: 'The one who believes in him is not condemned' },
};
test('compare translations with differences highlighted, a verse note, and the notes boxes', async ({ page }) => {
await page.route('https://bible.helloao.org/api/**', async (route) => {
const id = route.request().url().match(/api\/([^/]+)\//)[1].replace('eng_', '').toUpperCase();
const words = WORDING[id] ?? WORDING.BSB;
await route.fulfill({ json: { verses: Object.entries(words).map(([number, text]) => ({ number: Number(number), text })) } });
});
const { title } = await seedStudy(page);
await resumeButton(page, title).click();
await page.getByRole('button', { name: /compare…/i }).click();
await page.getByRole('tab', { name: 'Translations' }).click();
const dialog = page.getByRole('dialog', { name: /compare/i });
await expect(dialog.getByText('Berean Standard Bible')).toBeVisible();
await expect(dialog.getByText('NET Bible', { exact: true })).toBeVisible();
await expect(dialog.locator('mark').first()).toBeVisible(); // words that differ are marked
await dialog.getByLabel(/highlight the words that differ/i).uncheck();
await expect(dialog.locator('mark')).toHaveCount(0);
// a note on one verse, and the Observe / Interpret / Apply boxes edit the same notes as the Study page
await dialog.getByRole('textbox', { name: 'Note on verse 16' }).fill('NET says "the way" where BSB says "so"');
await expect(dialog.getByRole('textbox', { name: 'Interpret' })).toHaveValue(/not a slogan/);
await dialog.getByRole('textbox', { name: 'Apply' }).fill('Decide one thing to trust him with.');
await dialog.getByRole('button', { name: 'Close' }).click();
await page.getByRole('button', { name: 'Your notes' }).first().click(); // keep both halves tidy
await page.keyboard.press('3');
await expect(page.getByRole('textbox', { name: 'Apply' })).toHaveValue('Decide one thing to trust him with.');
await expect(page.getByRole('textbox', { name: 'Verse 16 note' })).toHaveValue(/the way/);
});
+51
View File
@@ -0,0 +1,51 @@
// Shared test helpers: a ready-made study so tests don't depend on downloading Bible text.
const DAY = 86400000;
const verses = [
{ number: 16, text: 'For God so loved the world, that he gave his only begotten Son, that whosoever believeth in him should not perish, but have everlasting life.' },
{ number: 17, text: 'For God sent not his Son into the world to condemn the world; but that the world through him might be saved.' },
{ number: 18, text: 'He that believeth on him is not condemned: but he that believeth not is condemned already.' },
{ number: 19, text: 'And this is the condemnation, that light is come into the world, and men loved darkness rather than light, because their deeds were evil.' },
];
export const NOTE_TEXT = {
observation: 'Jesus is speaking to Nicodemus at night. The verse starts with "for", so it explains the previous verses about the serpent lifted up. God is the one who acts: he loved, he gave.',
interpretation: 'In its setting this is not a slogan but the reason the cross happens. The world is the object of love, not just the good in it. Giving the Son points back to Moses lifting the serpent.',
application: 'I do not have to earn being loved before I trust him. This week I want to stop treating belief as a feeling and decide one concrete thing to trust him with.',
};
export const SPEAKER_NOTE = 'PRIVATE: open with the story about Nicodemus at night, then pause after perish.';
// Writes a two-chunk John 3 study (edited two weeks ago, so a weekly recall is due) into this browser's storage
// for the signed-in account, then reloads.
export async function seedStudy(page, { id = `e2e-${Date.now()}-${Math.floor(Math.random() * 1e6)}`, withOutline = false } = {}) {
await page.goto('/');
await page.waitForFunction(() => Object.keys(localStorage).some((k) => /^bible-study-index:.+:known$/.test(k)));
await page.evaluate(({ id, verses, notes, speakerNote, withOutline, DAY }) => {
const uid = Object.keys(localStorage).find((k) => /^bible-study-index:.+:known$/.test(k)).split(':')[1];
const now = Date.now();
const chunk = (cid, s, e, extra) => ({ id: cid, startVerse: s, endVerse: e, spilloverEndVerse: null, observation: '', interpretation: '', application: '', crossReferences: [], greekWords: [], generalNotes: '', episodeNumber: '', episodeTitle: '', finalScript: '', tags: [], editedAt: now - 14 * DAY, ...extra });
const node = (nid, text, extra = {}) => ({ id: nid, text, ref: '', chunkId: null, note: '', kind: '', minutes: null, scripture: null, attachments: [], children: [], ...extra });
const a = chunk(`${id}-a`, 16, 18, { ...notes });
const b = chunk(`${id}-b`, 19, 19, {});
// a title of its own: studies from earlier tests sync back through the account, so titles must not repeat
const title = `John 3 (${id.slice(-6)})`;
const outline = withOutline ? [node('n1', 'God so loved', { minutes: 5, note: speakerNote, ref: 'John 3:16', scripture: { text: verses[0].text, ref: 'John 3:16' } }), node('n2', 'Light and darkness', { minutes: 5 })] : [];
const project = { id, title, translation: 'BSB', lastEdited: now - 2 * DAY, selectedChunkId: a.id, outline, chapters: [{ book: 'John', bookAbbrev: 'JHN', chapter: '3', verses, chunks: [a, b] }] };
localStorage.setItem(`bible-study-project:${uid}:${id}`, JSON.stringify(project));
const index = JSON.parse(localStorage.getItem(`bible-study-index:${uid}`) || '[]').filter((e) => e.id !== id);
index.push({ id, title: project.title, lastEdited: project.lastEdited, chapterSummary: 'John 3', series: '', tags: [] });
localStorage.setItem(`bible-study-index:${uid}`, JSON.stringify(index));
}, { id, verses, notes: NOTE_TEXT, speakerNote: SPEAKER_NOTE, withOutline, DAY });
await page.reload();
return { id, title: `John 3 (${id.slice(-6)})` };
}
// The Resume button on one study's card on Home
export const resumeButton = (page, title) => page.getByRole('heading', { name: title }).locator('xpath=ancestor::div[contains(@class,"rounded-2xl")][1]').getByRole('button', { name: 'Resume' });
// Fakes a long paste into a note box (Playwright can't use the real clipboard in every setup)
export async function pasteInto(locator, text) {
await locator.evaluate((el, value) => {
const dt = new DataTransfer();
dt.setData('text', value);
el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }));
}, text);
}
+34
View File
@@ -0,0 +1,34 @@
import { test, expect } from '@playwright/test';
import { pasteInto, seedStudy } from './helpers.js';
test('the daily five minutes blocks pasting and saves to the note; the weekly recall rates and records', async ({ page }) => {
await seedStudy(page);
// daily five minutes
await expect(page.getByText(/your five minutes:/i)).toBeVisible();
await page.getByRole('button', { name: 'Start' }).first().click();
const box = page.getByRole('textbox', { name: /your five minutes/i });
await expect(page.getByRole('timer')).toContainText('4:');
await pasteInto(box, 'text from somewhere else');
await expect(page.getByText(/pasting is off here/i)).toBeVisible();
await expect(box).toHaveValue('');
await box.fill('Three things I notice in the passage.');
await page.getByRole('button', { name: /save to my notes/i }).click();
await expect(page.getByText(/saved to your notes/i)).toBeVisible();
// weekly recall: write from memory, compare with the notes, rate it
await page.getByRole('button', { name: 'Start' }).click();
await page.getByRole('textbox', { name: 'What you remember' }).fill('God loved the world and gave his Son so that believers would not perish.');
await page.getByRole('button', { name: 'Show my notes' }).click();
await expect(page.getByText('Your notes', { exact: true })).toBeVisible();
await page.getByRole('textbox', { name: 'What slipped' }).fill('who Jesus was talking to');
await page.getByRole('button', { name: 'Partly' }).click();
await expect(page.getByText(/recall saved/i)).toBeVisible();
// it shows in the history and in Insights
await page.getByRole('button', { name: /recall history/i }).click();
await expect(page.getByText('Partly', { exact: true }).first()).toBeVisible();
await page.getByRole('button', { name: 'Insights' }).click();
await expect(page.getByRole('heading', { name: 'Insights' })).toBeVisible();
await expect(page.getByText('who Jesus was talking to')).toBeVisible();
});
+36
View File
@@ -0,0 +1,36 @@
import { test, expect } from '@playwright/test';
import { SPEAKER_NOTE, resumeButton, seedStudy } from './helpers.js';
test('presenting on two screens: the audience window never shows the speaker notes', async ({ page, context }) => {
const { title } = await seedStudy(page, { withOutline: true });
await resumeButton(page, title).click();
await page.getByRole('navigation', { name: 'Study stages' }).getByRole('button', { name: 'Outline' }).click();
await page.getByRole('button', { name: 'Present' }).click();
const presenter = page.getByRole('dialog', { name: /presenting/i });
// one screen: notes stay hidden until turned on
await expect(presenter.getByRole('heading', { name: /God so loved/ })).toBeVisible();
await expect(presenter.getByText(SPEAKER_NOTE)).toHaveCount(0);
// a second window (the projector) connects and follows along
const audience = await context.newPage();
await audience.goto('/?audience=1');
await expect(audience.getByRole('heading', { name: /God so loved/ })).toBeVisible();
await expect(presenter.getByText('Only you see this')).toBeVisible();
await expect(presenter.getByText(SPEAKER_NOTE)).toBeVisible(); // on the presenter's screen only
await expect(audience.getByText(SPEAKER_NOTE)).toHaveCount(0);
expect(await audience.locator('body').innerText()).not.toMatch(/PRIVATE|On pace|Start/);
await page.keyboard.press('ArrowRight');
await expect(audience.getByRole('heading', { name: /Light and darkness/ })).toBeVisible();
// B blacks the audience screen out, and again brings it back
await page.keyboard.press('b');
await expect(audience.getByRole('heading', { name: /Light and darkness/ })).toHaveCount(0);
await page.keyboard.press('b');
await expect(audience.getByRole('heading', { name: /Light and darkness/ })).toBeVisible();
// closing the presentation tells the audience window
await presenter.getByRole('button', { name: 'Close' }).click();
await expect(audience.getByText(/presentation has ended/i)).toBeVisible();
});
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# Builds the app and runs the server on port 3101 with a fresh, empty data folder (so tests never touch real studies).
set -euo pipefail
cd "$(dirname "$0")/.."
export DATA_DIR="$(mktemp -d)"
export PORT=3101
export NODE_ENV=production
npx vite build --logLevel error
exec node server/index.js
+43
View File
@@ -0,0 +1,43 @@
import { test, expect } from '@playwright/test';
import { NOTE_TEXT, pasteInto, resumeButton, seedStudy } from './helpers.js';
test('open a study, write notes, answer the paste check, and use the keyboard', async ({ page }) => {
const { title } = await seedStudy(page);
await expect(page.getByRole('heading', { name: title })).toBeVisible();
await expect(page.getByText(/needs a connection for john/i).first()).toBeVisible(); // the offline badge
await resumeButton(page, title).click();
await expect(page.getByRole('heading', { name: 'John 3:16–18' })).toBeVisible();
// keyboard: 2 jumps into Interpret, Escape leaves it
await page.keyboard.press('2');
await expect(page.getByRole('textbox', { name: 'Interpret' })).toBeFocused();
await page.keyboard.press('Escape');
await expect(page.getByRole('textbox', { name: 'Interpret' })).not.toBeFocused();
// a long paste asks whether it is from AI; "No, I wrote it" counts the words as yours
await page.keyboard.press('1');
const observe = page.getByRole('textbox', { name: 'Observe' });
const pasted = Array.from({ length: 24 }, (_, i) => `pasted${i}`).join(' ');
await pasteInto(observe, pasted);
await observe.fill(`${NOTE_TEXT.observation} ${pasted}`);
await expect(page.getByText(/is this from ai\?/i)).toBeVisible();
await expect(page.getByText(/24 words here were pasted/i)).toBeVisible();
await page.getByRole('button', { name: 'No, I wrote it' }).click();
await expect(page.getByText(/is this from ai\?/i)).toHaveCount(0);
await expect(page.getByText(/were pasted/i)).toHaveCount(0);
// either half of the right column can be folded
const resources = page.getByRole('button', { name: /commentary & resources/i });
await resources.click();
await expect(resources).toHaveAttribute('aria-expanded', 'false');
await page.getByRole('button', { name: 'Your notes' }).click(); // folding the last open half opens the other
await expect(resources).toHaveAttribute('aria-expanded', 'true');
// the reading list belongs to the study
await page.getByRole('button', { name: 'Your notes' }).click(); // make room: fold notes, resources stay open
await page.getByRole('tab', { name: /reading list/i }).click();
await page.getByRole('textbox', { name: /add to the reading list/i }).fill('Carson on John');
await page.keyboard.press('Enter');
await page.getByRole('checkbox', { name: /read: carson on john/i }).check();
await expect(page.getByRole('checkbox', { name: /read: carson on john/i })).toBeChecked();
});
+7 -1
View File
@@ -22,10 +22,16 @@ export default [
}, },
}, },
{ {
files: ['server/**/*.js', 'scripts/**/*.mjs', 'vite.config.js', 'eslint.config.js', 'postcss.config.js', 'tailwind.config.js'], files: ['server/**/*.js', 'scripts/**/*.mjs', 'vite.config.js', 'playwright.config.js', 'eslint.config.js', 'postcss.config.js', 'tailwind.config.js'],
languageOptions: { ecmaVersion: 'latest', sourceType: 'module', globals: { ...globals.node } }, languageOptions: { ecmaVersion: 'latest', sourceType: 'module', globals: { ...globals.node } },
rules: { 'no-unused-vars': ['warn', { argsIgnorePattern: '^_', caughtErrors: 'none' }], 'no-empty': ['error', { allowEmptyCatch: true }] }, rules: { 'no-unused-vars': ['warn', { argsIgnorePattern: '^_', caughtErrors: 'none' }], 'no-empty': ['error', { allowEmptyCatch: true }] },
}, },
{
// Playwright tests run in Node but hand functions to the browser page, which use browser globals
files: ['e2e/**/*.js'],
languageOptions: { ecmaVersion: 'latest', sourceType: 'module', globals: { ...globals.node, ...globals.browser } },
rules: { 'no-unused-vars': ['warn', { argsIgnorePattern: '^_', caughtErrors: 'none' }] },
},
{ {
files: ['**/*.test.{js,jsx}', 'src/setupTests.js', 'src/test/**'], files: ['**/*.test.{js,jsx}', 'src/setupTests.js', 'src/test/**'],
languageOptions: { globals: { ...globals.browser, ...globals.node, vi: 'readonly' } }, languageOptions: { globals: { ...globals.browser, ...globals.node, vi: 'readonly' } },
+46
View File
@@ -27,6 +27,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@playwright/test": "^1.64.1",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
@@ -1635,6 +1636,22 @@
"node": ">=20.0.0" "node": ">=20.0.0"
} }
}, },
"node_modules/@playwright/test": {
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz",
"integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.64.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@rolldown/binding-android-arm64": { "node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz",
@@ -6190,6 +6207,35 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/playwright": {
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz",
"integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.64.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
"integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/pngjs": { "node_modules/pngjs": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
+3 -1
View File
@@ -15,7 +15,8 @@
"coverage": "vitest run --coverage", "coverage": "vitest run --coverage",
"backup": "bash scripts/backup-data.sh", "backup": "bash scripts/backup-data.sh",
"restore": "bash scripts/restore-data.sh", "restore": "bash scripts/restore-data.sh",
"lint": "eslint ." "lint": "eslint .",
"test:e2e": "playwright test"
}, },
"dependencies": { "dependencies": {
"@simplewebauthn/browser": "^13.3.0", "@simplewebauthn/browser": "^13.3.0",
@@ -37,6 +38,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@playwright/test": "^1.64.1",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
+18
View File
@@ -0,0 +1,18 @@
// Real-browser tests: a production build of the app on its own port with a throwaway database, driven in Chrome.
// Run with `npm run test:e2e`. It uses the Chrome already on this computer (no browser download).
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
timeout: 60_000,
expect: { timeout: 10_000 },
fullyParallel: false,
workers: 1,
reporter: [['list']],
use: { baseURL: 'http://localhost:3101', channel: 'chrome', trace: 'retain-on-failure' },
webServer: { command: 'bash e2e/start-server.sh', url: 'http://localhost:3101/', timeout: 120_000, reuseExistingServer: false },
projects: [
{ name: 'setup', testMatch: /auth\.setup\.js/ },
{ name: 'chrome', testMatch: /.*\.spec\.js/, dependencies: ['setup'], use: { storageState: 'e2e/.auth.json' } },
],
});
+5 -20
View File
@@ -11,6 +11,7 @@ import Tooltips from './components/Tooltips.jsx';
import { useAudio } from './hooks/useAudio.js'; import { useAudio } from './hooks/useAudio.js';
import { useAuth } from './hooks/useAuth.js'; import { useAuth } from './hooks/useAuth.js';
import { useBibleReader } from './hooks/useBibleReader.js'; import { useBibleReader } from './hooks/useBibleReader.js';
import { lastTemplateId, planStudyProject } from './utils/planStudy.js';
import { isAudienceWindow } from './utils/presentSync.js'; import { isAudienceWindow } from './utils/presentSync.js';
import { useProjectLibrary } from './hooks/useProjectLibrary.js'; import { useProjectLibrary } from './hooks/useProjectLibrary.js';
import { useHistoryNav } from './hooks/useHistoryNav.js'; import { useHistoryNav } from './hooks/useHistoryNav.js';
@@ -425,29 +426,13 @@ const AppInner = () => {
const studyReadingPlanDay = async (dayRefs, title) => { const studyReadingPlanDay = async (dayRefs, title) => {
setStudyBuildBusy(true); setStudyBuildBusy(true);
try { 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 chapters = await Promise.all(dayRefs.map(async ({ book, chapter }) => {
const data = await fetchStudyChapter('BSB', book, chapter); const data = await fetchStudyChapter('BSB', book, chapter);
const verses = parseBibleChapter(data); return { bookAbbrev: book, bookName: bookOptions.find((b) => b.abbrev === book)?.name ?? book, chapter, 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] };
})); }));
const newProject = { const newProject = planStudyProject({ chapters, title, templateId: lastTemplate, templateDef, makeId });
id: makeId(),
title: title || 'Reading Plan Study',
translation: 'BSB',
lastEdited: Date.now(),
selectedChunkId: chapters[0]?.chunks[0]?.id ?? null,
chapters,
};
setProject(newProject); setProject(newProject);
setCurrentPage('study'); setCurrentPage('study');
} catch (err) { } catch (err) {
+66
View File
@@ -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>
);
}
+46
View File
@@ -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);
});
+1 -1
View File
@@ -24,7 +24,7 @@ const BY_PAGE = {
['← →', 'Previous / next chunk'], ['← →', 'Previous / next chunk'],
['1 2 3', 'Write in Observe / Interpret / Apply'], ['1 2 3', 'Write in Observe / Interpret / Apply'],
['Esc', 'Leave a note box (then keys work again)'], ['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'], ['V', 'Compare passages or translations'],
], ],
}; };
+32
View File
@@ -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 -2
View File
@@ -5,6 +5,7 @@ import CommentaryChunkTab from './CommentaryChunkTab.jsx';
import WordStudyTab from './WordStudyTab.jsx'; import WordStudyTab from './WordStudyTab.jsx';
import CrossRefsTab from './CrossRefsTab.jsx'; import CrossRefsTab from './CrossRefsTab.jsx';
import PeopleChunkTab from './PeopleChunkTab.jsx'; import PeopleChunkTab from './PeopleChunkTab.jsx';
import ReadingListTab from './ReadingListTab.jsx';
import IqChunkAids from '../iq/IqChunkAids.jsx'; import IqChunkAids from '../iq/IqChunkAids.jsx';
import { useIqAvailable } from '../../hooks/useIq.js'; import { useIqAvailable } from '../../hooks/useIq.js';
import { OBSERVE_FIRST_MIN, observeFirstDone, ownWords } from '../../utils/ownWork.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: 'words', label: 'Word study' },
{ id: 'refs', label: 'Cross-refs' }, { id: 'refs', label: 'Cross-refs' },
{ id: 'people', label: 'People & places' }, { 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 { 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 iq = useIqAvailable();
const tabs = RESOURCE_TABS.filter((t) => !t.needsIq || iq); const tabs = RESOURCE_TABS.filter((t) => !t.needsIq || iq);
if (tab === 'aids' && !iq) tab = 'commentary'; if (tab === 'aids' && !iq) tab = 'commentary';
@@ -32,7 +34,7 @@ export default function ResourcePane({ tab, setTab, chunk, chapter, chapterIndex
if (!seeing || chunk.firstDraft) return; if (!seeing || chunk.firstDraft) return;
updateChunk(chunk.id, { firstDraft: { at: Date.now(), observation: chunk.observation ?? '', interpretation: chunk.interpretation ?? '', application: chunk.application ?? '' } }, { touch: false }); 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 }, [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 ( return (
<div className="flex min-h-0 flex-1 flex-col"> <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"> <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 === 'words' && <WordStudyTab wordStudy={wordStudy} chunk={chunk} openWordId={openWordId} setOpenWordId={setOpenWordId} />}
{tab === 'refs' && <CrossRefsTab crossRefs={crossRefs} chunk={chunk} />} {tab === 'refs' && <CrossRefsTab crossRefs={crossRefs} chunk={chunk} />}
{tab === 'people' && <PeopleChunkTab book={chapter.bookAbbrev} chapter={chapter.chapter} 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} />} {!held && tab === 'aids' && <IqChunkAids chunk={chunk} chapter={chapter} updateChunk={updateChunk} notify={notify} />}
</div> </div>
</div> </div>
+3 -1
View File
@@ -6,6 +6,7 @@ import { useToast } from '../components/Toasts.jsx';
import { bookOptions, CHAPTER_COUNTS, makeId, parseBibleChapter } from '../utils/bibleUtils.jsx'; import { bookOptions, CHAPTER_COUNTS, makeId, parseBibleChapter } from '../utils/bibleUtils.jsx';
import { fetchStudyChapter } from '../utils/studyText.js'; import { fetchStudyChapter } from '../utils/studyText.js';
import { templateStarter } from '../utils/studyTemplates.js'; import { templateStarter } from '../utils/studyTemplates.js';
import { rememberTemplate } from '../utils/planStudy.js';
// Every chunk starts blank; all note fields exist from the start. // Every chunk starts blank; all note fields exist from the start.
export function blankChunk(start, end, spilloverEndVerse = null, templateId = 'blank') { export function blankChunk(start, end, spilloverEndVerse = null, templateId = 'blank') {
@@ -192,7 +193,8 @@ export function useChunkBuilder({ project, setProject, updateProject, allChunks,
setShowAddChapterForm(false); setShowAddChapterForm(false);
setErrorMessage(''); setErrorMessage('');
} else { } else {
// Creating a brand-new project // Creating a brand-new project; remember the template so studies started from a reading plan can use it too
rememberTemplate(setup.template);
const newProject = { const newProject = {
id: makeId(), id: makeId(),
title: setup.title, title: setup.title,
+9 -3
View File
@@ -221,8 +221,8 @@ export function useProjectLibrary({
}); });
}; };
// Saves a weekly recall (what you remembered, and how it went) on one chunk of a stored study. // Changes one chunk of a stored study (from Home, without opening it) and syncs it. `change` takes the chunk, returns the new one.
const saveChunkRecall = (id, chunkId, recall) => { const updateStoredChunk = (id, chunkId, change) => {
const loaded = loadProjectById(id); const loaded = loadProjectById(id);
if (!loaded) return; if (!loaded) return;
const updated = { const updated = {
@@ -230,7 +230,7 @@ export function useProjectLibrary({
lastEdited: Date.now(), lastEdited: Date.now(),
chapters: loaded.chapters.map((ch) => ({ chapters: loaded.chapters.map((ch) => ({
...ch, ...ch,
chunks: ch.chunks.map((c) => (c.id === chunkId ? { ...c, recalls: [...(c.recalls ?? []), recall] } : c)), chunks: ch.chunks.map((c) => (c.id === chunkId ? change(c) : c)),
})), })),
}; };
saveProjectToStorage(updated); saveProjectToStorage(updated);
@@ -247,6 +247,11 @@ export function useProjectLibrary({
}); });
}; };
// A weekly recall (what you remembered, and how it went) on one chunk.
const saveChunkRecall = (id, chunkId, recall) => updateStoredChunk(id, chunkId, (c) => ({ ...c, recalls: [...(c.recalls ?? []), recall] }));
// Notes written from Home (the daily five minutes): counts as editing the chunk.
const saveChunkPatch = (id, chunkId, patch) => updateStoredChunk(id, chunkId, (c) => ({ ...c, ...patch, editedAt: Date.now() }));
// Deleting is immediate here, with an Undo for a few seconds; only after that is the server copy // Deleting is immediate here, with an Undo for a few seconds; only after that is the server copy
// removed (and straight away if the tab is closed first, so a deleted project never comes back). // removed (and straight away if the tab is closed first, so a deleted project never comes back).
const finishDelete = (id) => { const finishDelete = (id) => {
@@ -314,6 +319,7 @@ export function useProjectLibrary({
renameProjectInStorage, renameProjectInStorage,
setProjectSeries, setProjectSeries,
saveChunkRecall, saveChunkRecall,
saveChunkPatch,
deleteProject, deleteProject,
pullLatestFromServer, pullLatestFromServer,
}; };
+2
View File
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { useApp } from '../context/AppContext.js'; import { useApp } from '../context/AppContext.js';
import { filterStudies, groupBySeries, isTagQuery, seriesList, tagCounts } from '../utils/studyFilter.js'; import { filterStudies, groupBySeries, isTagQuery, seriesList, tagCounts } from '../utils/studyFilter.js';
import BookCover from '../components/BookCover.jsx'; import BookCover from '../components/BookCover.jsx';
import DailyFive from '../components/DailyFive.jsx';
import WeeklyRecall from '../components/WeeklyRecall.jsx'; import WeeklyRecall from '../components/WeeklyRecall.jsx';
import OfflineBadge from '../components/OfflineBadge.jsx'; import OfflineBadge from '../components/OfflineBadge.jsx';
import { useOfflineSave } from '../hooks/useOfflineSave.js'; import { useOfflineSave } from '../hooks/useOfflineSave.js';
@@ -155,6 +156,7 @@ export default function HomePage() {
</p> </p>
); );
})()} })()}
<DailyFive />
<WeeklyRecall /> <WeeklyRecall />
<RecallHistory /> <RecallHistory />
<IqDailyCard /> <IqDailyCard />
+1 -1
View File
@@ -39,7 +39,7 @@ export default function InsightsPage() {
<Stat label="Your own words" value={week.ownShare === null ? '–' : `${week.ownShare}%`} /> <Stat label="Your own words" value={week.ownShare === null ? '–' : `${week.ownShare}%`} />
<Stat label="Recalls done" value={week.recalls} /> <Stat label="Recalls done" value={week.recalls} />
</div> </div>
{week.teachBacks > 0 && <p className="mt-2 text-xs text-[var(--r-sub)]">{week.teachBacks} teach-it-back summar{week.teachBacks === 1 ? 'y' : 'ies'} written.</p>} {(week.teachBacks > 0 || week.fiveMinutes > 0) && <p className="mt-2 text-xs text-[var(--r-sub)]">{[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.</p>}
</section> </section>
<section aria-label="Open follow-ups" className={card}> <section aria-label="Open follow-ups" className={card}>
+3 -3
View File
@@ -16,9 +16,9 @@ import ResourcePane, { useResourceTab } from '../components/study/ResourcePane.j
import CompareSheet from '../components/study/CompareSheet.jsx'; import CompareSheet from '../components/study/CompareSheet.jsx';
import SuggestWordsSheet from '../components/study/SuggestWordsSheet.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 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() { export default function StudyPage() {
const { project, allChunks, projectIndex, updateProject, updateChunk, setCurrentPage, readerCommentary, setReaderCommentary, saveUserTemplate, startPrayerFor } = useApp(); const { project, allChunks, projectIndex, updateProject, updateChunk, setCurrentPage, readerCommentary, setReaderCommentary, saveUserTemplate, startPrayerFor } = useApp();
@@ -183,7 +183,7 @@ export default function StudyPage() {
<div className={`flex min-h-0 flex-1 flex-col border-t border-[var(--r-line)] ${folded.resources ? 'hidden' : ''}`}> <div className={`flex min-h-0 flex-1 flex-col border-t border-[var(--r-line)] ${folded.resources ? 'hidden' : ''}`}>
<ResourcePane <ResourcePane
tab={tab} setTab={setTab} chunk={selectedChunk} chapter={chapter} chapterIndex={chapterIndex} project={project} tab={tab} setTab={setTab} chunk={selectedChunk} chapter={chapter} chapterIndex={chapterIndex} project={project}
updateChunk={updateChunk} notify={notify} updateChunk={updateChunk} updateProject={updateProject} notify={notify}
resources={resources} wordStudy={wordStudy} crossRefs={crossRefs} resources={resources} wordStudy={wordStudy} crossRefs={crossRefs}
openWordId={openWordId} setOpenWordId={setOpenWordId} openWordId={openWordId} setOpenWordId={setOpenWordId}
/> />
+12
View File
@@ -509,6 +509,18 @@ describe('Study workspace', () => {
expect(screen.getByRole('textbox', { name: 'Observe' })).toHaveValue('Written beside the translations.'); 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 () => { 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 }));
+53
View File
@@ -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 }],
};
}
+42
View File
@@ -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\.$/);
});
+1
View File
@@ -25,6 +25,7 @@ export function weeklySummary(projects, now = Date.now()) {
ownWords: ownWordsTotal, ownWords: ownWordsTotal,
ownShare: shares.length ? Math.round(shares.reduce((a, b) => a + b, 0) / shares.length) : null, 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), 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, teachBacks: recent.filter(({ chunk }) => (chunk.teachBack ?? '').trim()).length,
}; };
} }
+5
View File
@@ -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(slippedList(projects)[0].items).toEqual(['the serpent', 'who spoke']);
expect(followUpList(projects).map((f) => f.text)).toEqual(['call Sam', 'done thing']); 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);
});
+2 -1
View File
@@ -41,6 +41,7 @@ export function mergeChunk(mine, theirs) {
const noteKeys = new Set([...Object.keys(newer.verseNotes ?? {}), ...Object.keys(older.verseNotes ?? {})]); 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.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.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.followUps = mergeById(newer.followUps, older.followUps);
out.recalls = union(newer.recalls, older.recalls); out.recalls = union(newer.recalls, older.recalls);
out.editedAt = Math.max(mine.editedAt ?? 0, theirs.editedAt ?? 0) || undefined; 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))); const ids = new Set(chapters.flatMap((c) => c.chunks.map((k) => k.id)));
return { 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, stats,
}; };
} }
+12
View File
@@ -49,3 +49,15 @@ test('identical copies merge to themselves', () => {
expect(project).toEqual(s); expect(project).toEqual(s);
expect(stats).toEqual({ added: 0, merged: 0 }); 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();
});
+33
View File
@@ -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,
};
}
+43
View File
@@ -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');
});
+1
View File
@@ -18,6 +18,7 @@ export default defineConfig({
}, },
}, },
test: { test: {
exclude: ['e2e/**', 'node_modules/**', 'dist/**'], // e2e/ holds the Playwright browser tests (npm run test:e2e)
environment: 'jsdom', environment: 'jsdom',
environmentOptions: { environmentOptions: {
jsdom: { url: 'http://localhost:3000/' }, jsdom: { url: 'http://localhost:3000/' },