diff --git a/.gitignore b/.gitignore
index 43ef431..8a16d38 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,3 +13,11 @@ coverage/
.env
.session-secret
server/data/backups/
+
+# Playwright
+e2e/.auth.json
+test-results/
+playwright-report/
+
+# a local demo video, not part of the app
+demo/
diff --git a/README.md b/README.md
index c48ddbc..50c02bd 100644
--- a/README.md
+++ b/README.md
@@ -151,10 +151,17 @@ Private / personal use.
## Development
```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)
```
+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.
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).
diff --git a/TODO.md b/TODO.md
index 53da92b..2dbd48f 100644
--- a/TODO.md
+++ b/TODO.md
@@ -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.
- [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`).
-- [ ] **Checks on every push.** Run tests and lint on the Gitea server, plus a few real-browser tests (Playwright) for sign-in,
- creating a study and syncing; so far everything is tested in simulation.
+- [ ] **Checks on every push.** Run `npm run test:run`, `npm run lint` and `npm run test:e2e` on the Gitea server for every push.
+ 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).
## Teaching together
diff --git a/e2e/auth.setup.js b/e2e/auth.setup.js
new file mode 100644
index 0000000..8e906bb
--- /dev/null
+++ b/e2e/auth.setup.js
@@ -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' });
+});
diff --git a/e2e/compare.spec.js b/e2e/compare.spec.js
new file mode 100644
index 0000000..9f499dd
--- /dev/null
+++ b/e2e/compare.spec.js
@@ -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/);
+});
diff --git a/e2e/helpers.js b/e2e/helpers.js
new file mode 100644
index 0000000..7734645
--- /dev/null
+++ b/e2e/helpers.js
@@ -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);
+}
diff --git a/e2e/practice.spec.js b/e2e/practice.spec.js
new file mode 100644
index 0000000..9dd1a7a
--- /dev/null
+++ b/e2e/practice.spec.js
@@ -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();
+});
diff --git a/e2e/present.spec.js b/e2e/present.spec.js
new file mode 100644
index 0000000..da0eaaa
--- /dev/null
+++ b/e2e/present.spec.js
@@ -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();
+});
diff --git a/e2e/start-server.sh b/e2e/start-server.sh
new file mode 100755
index 0000000..fa351cd
--- /dev/null
+++ b/e2e/start-server.sh
@@ -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
diff --git a/e2e/study.spec.js b/e2e/study.spec.js
new file mode 100644
index 0000000..2f5e2a0
--- /dev/null
+++ b/e2e/study.spec.js
@@ -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();
+});
diff --git a/eslint.config.js b/eslint.config.js
index c452fd7..36a575f 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -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 } },
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/**'],
languageOptions: { globals: { ...globals.browser, ...globals.node, vi: 'readonly' } },
diff --git a/package-lock.json b/package-lock.json
index c0dbe63..bb302c5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -27,6 +27,7 @@
},
"devDependencies": {
"@eslint/js": "^10.0.1",
+ "@playwright/test": "^1.64.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
@@ -1635,6 +1636,22 @@
"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": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz",
@@ -6190,6 +6207,35 @@
"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": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
diff --git a/package.json b/package.json
index e430542..dfa9d07 100644
--- a/package.json
+++ b/package.json
@@ -15,7 +15,8 @@
"coverage": "vitest run --coverage",
"backup": "bash scripts/backup-data.sh",
"restore": "bash scripts/restore-data.sh",
- "lint": "eslint ."
+ "lint": "eslint .",
+ "test:e2e": "playwright test"
},
"dependencies": {
"@simplewebauthn/browser": "^13.3.0",
@@ -37,6 +38,7 @@
},
"devDependencies": {
"@eslint/js": "^10.0.1",
+ "@playwright/test": "^1.64.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
diff --git a/playwright.config.js b/playwright.config.js
new file mode 100644
index 0000000..30ecc95
--- /dev/null
+++ b/playwright.config.js
@@ -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' } },
+ ],
+});
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
+
+ );
+}
diff --git a/src/components/DailyFive.test.jsx b/src/components/DailyFive.test.jsx
new file mode 100644
index 0000000..d3068de
--- /dev/null
+++ b/src/components/DailyFive.test.jsx
@@ -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();
+ 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);
+});
diff --git a/src/components/ShortcutsHelp.jsx b/src/components/ShortcutsHelp.jsx
index 3c61e7c..5d8ff49 100644
--- a/src/components/ShortcutsHelp.jsx
+++ b/src/components/ShortcutsHelp.jsx
@@ -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'],
],
};
diff --git a/src/components/study/ReadingListTab.jsx b/src/components/study/ReadingListTab.jsx
new file mode 100644
index 0000000..8f8662b
--- /dev/null
+++ b/src/components/study/ReadingListTab.jsx
@@ -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 (
+
+
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.