Merge studying-and-housekeeping: daily five minutes, reading list, browser tests
Build and Push Docker Image / build-and-push (push) Successful in 8m10s
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:
commit
1bc515164a
35 files changed
+692
-37
No files matched your search
@@ -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/
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
Executable
+9
@@ -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
|
||||
@@ -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
@@ -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' } },
|
||||
|
||||
Generated
+46
@@ -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",
|
||||
|
||||
+3
-1
@@ -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",
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// Home card: your five minutes for today. A short prompt on a passage from your studies, a five-minute clock, and a
|
||||
// box to write in. What you write is added to that passage's notes. Pasting is turned off here on purpose.
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useApp } from '../context/AppContext.js';
|
||||
import { loadProjectById } from '../utils/projectStorage.js';
|
||||
import { FIELD_NAMES, MINUTES, dailyPatch, pickDaily } from '../utils/dailyFive.js';
|
||||
import { formatClock } from '../utils/present.js';
|
||||
|
||||
const box = 'mb-4 rounded-xl border border-[var(--r-line)] bg-[var(--r-bar)] px-4 py-3 text-sm';
|
||||
|
||||
export default function DailyFive() {
|
||||
const { projectIndex, saveChunkPatch } = useApp();
|
||||
const [step, setStep] = useState('card'); // card -> write -> done
|
||||
const [text, setText] = useState('');
|
||||
const [left, setLeft] = useState(MINUTES * 60);
|
||||
const [pasteTried, setPasteTried] = useState(false);
|
||||
const pick = useMemo(() => pickDaily(projectIndex.map((e) => loadProjectById(e.id)).filter(Boolean)), [projectIndex]);
|
||||
const started = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (step !== 'write') return undefined;
|
||||
started.current = Date.now();
|
||||
const t = window.setInterval(() => setLeft(Math.max(0, MINUTES * 60 - Math.round((Date.now() - started.current) / 1000))), 1000);
|
||||
return () => window.clearInterval(t);
|
||||
}, [step]);
|
||||
|
||||
if (step === 'done') return <div role="status" className={box}>Saved to your notes. Same time tomorrow.</div>;
|
||||
if (!pick) return null;
|
||||
const { prompt } = pick;
|
||||
|
||||
const save = () => {
|
||||
saveChunkPatch(pick.projectId, pick.chunk.id, dailyPatch(pick.chunk, prompt.field, text));
|
||||
setStep('done');
|
||||
};
|
||||
|
||||
if (step === 'card') {
|
||||
return (
|
||||
<div className={`${box} flex flex-wrap items-center justify-between gap-3`}>
|
||||
<span><span className="font-medium">Your five minutes:</span> {pick.reference} <span className="text-[var(--r-sub)]">· {FIELD_NAMES[prompt.field]} · {pick.projectTitle}</span></span>
|
||||
<button type="button" onClick={() => { setText(''); setLeft(MINUTES * 60); setPasteTried(false); setStep('write'); }} className="rounded-full bg-[var(--r-text)] px-3 py-1 text-xs font-medium text-[var(--r-bg)]">Start</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<section aria-label="Your five minutes" className={box}>
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<p className="font-medium">{pick.reference} <span className="font-normal text-[var(--r-sub)]">· {FIELD_NAMES[prompt.field]}</span></p>
|
||||
<span role="timer" aria-label="Time left" className={`font-mono text-sm tabular-nums ${left === 0 ? 'text-[var(--r-accent)]' : 'text-[var(--r-sub)]'}`}>{left === 0 ? 'Time’s up, finish your thought' : formatClock(left)}</span>
|
||||
</div>
|
||||
{pick.verses.length > 0 && (
|
||||
<div className="mt-2 max-h-40 overflow-y-auto rounded-lg bg-[var(--r-bg)] p-3 font-serif text-[13px] leading-relaxed">
|
||||
{pick.verses.map((v) => <p key={v.number}><sup className="mr-1 font-sans text-[var(--r-accent)]">{v.number}</sup>{v.text}</p>)}
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-[var(--r-sub)]">{prompt.text}</p>
|
||||
<textarea aria-label="Your five minutes" value={text} onChange={(e) => setText(e.target.value)} rows={6} autoFocus
|
||||
onPaste={(e) => { e.preventDefault(); setPasteTried(true); }}
|
||||
className="mt-2 w-full resize-y rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 font-serif text-[15px] leading-7 focus:outline-none" />
|
||||
{pasteTried && <p role="note" className="mt-1 text-xs text-[var(--r-accent)]">Pasting is off here. Type it in your own words; that’s what the five minutes are for.</p>}
|
||||
<div className="mt-2 flex gap-2">
|
||||
<button type="button" disabled={!text.trim()} onClick={save} className="rounded-full bg-[var(--r-text)] px-3 py-1 text-xs font-medium text-[var(--r-bg)] disabled:opacity-40">Save to my notes</button>
|
||||
<button type="button" onClick={() => setStep('card')} className="text-xs text-[var(--r-sub)] hover:underline">Not now</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { vi, test, expect, beforeEach } from 'vitest';
|
||||
import { AppContext } from '../context/AppContext.js';
|
||||
import { saveProjectToStorage } from '../utils/projectStorage.js';
|
||||
import DailyFive from './DailyFive.jsx';
|
||||
import { promptFor } from '../utils/dailyFive.js';
|
||||
|
||||
const project = () => ({
|
||||
id: 'p1', title: 'John', translation: 'BSB', lastEdited: 1,
|
||||
chapters: [{ book: 'John', bookAbbrev: 'JHN', chapter: '3', verses: [{ number: 16, text: 'For God so loved the world.' }],
|
||||
chunks: [{ id: 'c1', startVerse: 16, endVerse: 16, observation: '', interpretation: '', application: '', editedAt: 1 }] }],
|
||||
});
|
||||
|
||||
function setup() {
|
||||
localStorage.clear();
|
||||
saveProjectToStorage(project());
|
||||
const saveChunkPatch = vi.fn();
|
||||
const value = { projectIndex: [{ id: 'p1' }], saveChunkPatch };
|
||||
render(<AppContext.Provider value={value}><DailyFive /></AppContext.Provider>);
|
||||
return { saveChunkPatch, user: userEvent.setup() };
|
||||
}
|
||||
beforeEach(() => localStorage.clear());
|
||||
|
||||
test('the card names a passage and what kind of note the five minutes are for', () => {
|
||||
setup();
|
||||
expect(screen.getByText(/your five minutes/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/John 3:16/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('pasting is blocked, and what you type is saved to the matching note', async () => {
|
||||
const { saveChunkPatch, user } = setup();
|
||||
await user.click(screen.getByRole('button', { name: 'Start' }));
|
||||
expect(screen.getByText('For God so loved the world.')).toBeInTheDocument();
|
||||
expect(screen.getByRole('timer')).toHaveTextContent('5:00');
|
||||
const box = screen.getByRole('textbox', { name: /your five minutes/i });
|
||||
fireEvent.paste(box, { clipboardData: { getData: () => 'text from somewhere else' } });
|
||||
expect(screen.getByText(/pasting is off here/i)).toBeInTheDocument();
|
||||
expect(box).toHaveValue('');
|
||||
expect(screen.getByRole('button', { name: /save to my notes/i })).toBeDisabled();
|
||||
await user.type(box, 'God gave.');
|
||||
await user.click(screen.getByRole('button', { name: /save to my notes/i }));
|
||||
const field = promptFor().field;
|
||||
expect(saveChunkPatch).toHaveBeenCalledWith('p1', 'c1', expect.objectContaining({ [field]: 'God gave.', fiveMinutes: [expect.objectContaining({ field })] }));
|
||||
expect(screen.getByRole('status')).toHaveTextContent(/saved to your notes/i);
|
||||
});
|
||||
@@ -24,7 +24,7 @@ const BY_PAGE = {
|
||||
['← →', 'Previous / next chunk'],
|
||||
['1 2 3', 'Write in Observe / Interpret / Apply'],
|
||||
['Esc', 'Leave a note box (then keys work again)'],
|
||||
['C W X P', 'Commentary / Word study / Cross-refs / People & places'],
|
||||
['C W X P R', 'Commentary / Word study / Cross-refs / People & places / Reading list'],
|
||||
['V', 'Compare passages or translations'],
|
||||
],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// A reading list for the whole study: the books, commentaries and articles you plan to consult, with a note on
|
||||
// each and a tick when done. It belongs to the study, not one chunk.
|
||||
import { makeId } from '../../utils/bibleUtils.jsx';
|
||||
|
||||
export default function ReadingListTab({ project, updateProject }) {
|
||||
const items = project?.readingList ?? [];
|
||||
const set = (next) => updateProject((current) => ({ ...current, readingList: next(current.readingList ?? []) }));
|
||||
const add = (title) => { const t = title.trim(); if (t) set((list) => [...list, { id: makeId(), title: t, note: '', done: false }]); };
|
||||
const patch = (id, change) => set((list) => list.map((i) => (i.id === id ? { ...i, ...change } : i)));
|
||||
const open = items.filter((i) => !i.done).length;
|
||||
return (
|
||||
<div className="space-y-3 text-sm">
|
||||
<p className="text-xs text-[var(--r-sub)]">Books and articles to consult for this study{items.length ? ` · ${open} to read, ${items.length - open} done` : ''}. Read the passage and write your own notes first; this list is for afterwards.</p>
|
||||
<ul className="space-y-2">
|
||||
{items.map((i) => (
|
||||
<li key={i.id} className="rounded-xl border border-[var(--r-line)] p-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="checkbox" aria-label={`Read: ${i.title}`} checked={!!i.done} onChange={() => patch(i.id, { done: !i.done })} className="accent-[var(--r-accent)]" />
|
||||
<span className={`min-w-0 flex-1 ${i.done ? 'text-[var(--r-sub)] line-through' : 'font-medium'}`}>{i.title}</span>
|
||||
<button type="button" aria-label={`Remove ${i.title}`} onClick={() => set((list) => list.filter((x) => x.id !== i.id))} className="text-xs text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]">×</button>
|
||||
</div>
|
||||
<input aria-label={`Note on ${i.title}`} value={i.note ?? ''} onChange={(e) => patch(i.id, { note: e.target.value })} placeholder="Where in it, or what to look for"
|
||||
className="mt-1.5 w-full rounded-lg border border-transparent bg-transparent px-2 py-1 text-xs text-[var(--r-text)] placeholder:text-[var(--r-sub)] placeholder:opacity-60 hover:border-[var(--r-line)] focus:border-[var(--r-line)] focus:bg-[var(--r-bg)] focus:outline-none" />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<input aria-label="Add to the reading list" placeholder="Add a book or article, then Enter"
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); add(e.target.value); e.target.value = ''; } }}
|
||||
className="w-full rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm text-[var(--r-text)] focus:outline-none" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import CommentaryChunkTab from './CommentaryChunkTab.jsx';
|
||||
import WordStudyTab from './WordStudyTab.jsx';
|
||||
import CrossRefsTab from './CrossRefsTab.jsx';
|
||||
import PeopleChunkTab from './PeopleChunkTab.jsx';
|
||||
import ReadingListTab from './ReadingListTab.jsx';
|
||||
import IqChunkAids from '../iq/IqChunkAids.jsx';
|
||||
import { useIqAvailable } from '../../hooks/useIq.js';
|
||||
import { OBSERVE_FIRST_MIN, observeFirstDone, ownWords } from '../../utils/ownWork.js';
|
||||
@@ -14,10 +15,11 @@ export const RESOURCE_TABS = [
|
||||
{ id: 'words', label: 'Word study' },
|
||||
{ id: 'refs', label: 'Cross-refs' },
|
||||
{ id: 'people', label: 'People & places' },
|
||||
{ id: 'readings', label: 'Reading list' },
|
||||
{ id: 'aids', label: 'Study aids', needsIq: true }, // shown only when the server has an IQ Bible key
|
||||
];
|
||||
|
||||
export default function ResourcePane({ tab, setTab, chunk, chapter, chapterIndex, project, updateChunk, notify, resources, wordStudy, crossRefs, openWordId, setOpenWordId }) {
|
||||
export default function ResourcePane({ tab, setTab, chunk, chapter, chapterIndex, project, updateChunk, updateProject, notify, resources, wordStudy, crossRefs, openWordId, setOpenWordId }) {
|
||||
const iq = useIqAvailable();
|
||||
const tabs = RESOURCE_TABS.filter((t) => !t.needsIq || iq);
|
||||
if (tab === 'aids' && !iq) tab = 'commentary';
|
||||
@@ -32,7 +34,7 @@ export default function ResourcePane({ tab, setTab, chunk, chapter, chapterIndex
|
||||
if (!seeing || chunk.firstDraft) return;
|
||||
updateChunk(chunk.id, { firstDraft: { at: Date.now(), observation: chunk.observation ?? '', interpretation: chunk.interpretation ?? '', application: chunk.application ?? '' } }, { touch: false });
|
||||
}, [seeing, chunk.id, chunk.firstDraft]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
const counts = { words: chunk.greekWords.length, refs: (chunk.crossReferences ?? []).length };
|
||||
const counts = { words: chunk.greekWords.length, refs: (chunk.crossReferences ?? []).length, readings: (project?.readingList ?? []).filter((i) => !i.done).length };
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div role="tablist" aria-label="Study resources" className="flex shrink-0 flex-wrap gap-1.5 border-b border-[var(--r-line)] px-3 py-2.5">
|
||||
@@ -55,6 +57,7 @@ export default function ResourcePane({ tab, setTab, chunk, chapter, chapterIndex
|
||||
{tab === 'words' && <WordStudyTab wordStudy={wordStudy} chunk={chunk} openWordId={openWordId} setOpenWordId={setOpenWordId} />}
|
||||
{tab === 'refs' && <CrossRefsTab crossRefs={crossRefs} chunk={chunk} />}
|
||||
{tab === 'people' && <PeopleChunkTab book={chapter.bookAbbrev} chapter={chapter.chapter} chunk={chunk} />}
|
||||
{tab === 'readings' && updateProject && <ReadingListTab project={project} updateProject={updateProject} />}
|
||||
{!held && tab === 'aids' && <IqChunkAids chunk={chunk} chapter={chapter} updateChunk={updateChunk} notify={notify} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useToast } from '../components/Toasts.jsx';
|
||||
import { bookOptions, CHAPTER_COUNTS, makeId, parseBibleChapter } from '../utils/bibleUtils.jsx';
|
||||
import { fetchStudyChapter } from '../utils/studyText.js';
|
||||
import { templateStarter } from '../utils/studyTemplates.js';
|
||||
import { rememberTemplate } from '../utils/planStudy.js';
|
||||
|
||||
// Every chunk starts blank; all note fields exist from the start.
|
||||
export function blankChunk(start, end, spilloverEndVerse = null, templateId = 'blank') {
|
||||
@@ -192,7 +193,8 @@ export function useChunkBuilder({ project, setProject, updateProject, allChunks,
|
||||
setShowAddChapterForm(false);
|
||||
setErrorMessage('');
|
||||
} 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 = {
|
||||
id: makeId(),
|
||||
title: setup.title,
|
||||
|
||||
@@ -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.
|
||||
const saveChunkRecall = (id, chunkId, recall) => {
|
||||
// Changes one chunk of a stored study (from Home, without opening it) and syncs it. `change` takes the chunk, returns the new one.
|
||||
const updateStoredChunk = (id, chunkId, change) => {
|
||||
const loaded = loadProjectById(id);
|
||||
if (!loaded) return;
|
||||
const updated = {
|
||||
@@ -230,7 +230,7 @@ export function useProjectLibrary({
|
||||
lastEdited: Date.now(),
|
||||
chapters: loaded.chapters.map((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);
|
||||
@@ -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
|
||||
// removed (and straight away if the tab is closed first, so a deleted project never comes back).
|
||||
const finishDelete = (id) => {
|
||||
@@ -314,6 +319,7 @@ export function useProjectLibrary({
|
||||
renameProjectInStorage,
|
||||
setProjectSeries,
|
||||
saveChunkRecall,
|
||||
saveChunkPatch,
|
||||
deleteProject,
|
||||
pullLatestFromServer,
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useApp } from '../context/AppContext.js';
|
||||
import { filterStudies, groupBySeries, isTagQuery, seriesList, tagCounts } from '../utils/studyFilter.js';
|
||||
import BookCover from '../components/BookCover.jsx';
|
||||
import DailyFive from '../components/DailyFive.jsx';
|
||||
import WeeklyRecall from '../components/WeeklyRecall.jsx';
|
||||
import OfflineBadge from '../components/OfflineBadge.jsx';
|
||||
import { useOfflineSave } from '../hooks/useOfflineSave.js';
|
||||
@@ -155,6 +156,7 @@ export default function HomePage() {
|
||||
</p>
|
||||
);
|
||||
})()}
|
||||
<DailyFive />
|
||||
<WeeklyRecall />
|
||||
<RecallHistory />
|
||||
<IqDailyCard />
|
||||
|
||||
@@ -39,7 +39,7 @@ export default function InsightsPage() {
|
||||
<Stat label="Your own words" value={week.ownShare === null ? '–' : `${week.ownShare}%`} />
|
||||
<Stat label="Recalls done" value={week.recalls} />
|
||||
</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 aria-label="Open follow-ups" className={card}>
|
||||
|
||||
@@ -16,9 +16,9 @@ import ResourcePane, { useResourceTab } from '../components/study/ResourcePane.j
|
||||
import CompareSheet from '../components/study/CompareSheet.jsx';
|
||||
import SuggestWordsSheet from '../components/study/SuggestWordsSheet.jsx';
|
||||
|
||||
// Keyboard-only study: 1 / 2 / 3 jump to Observe / Interpret / Apply; c, w, x, p open the resource tabs; v compares.
|
||||
// Keyboard-only study: 1 / 2 / 3 jump to Observe / Interpret / Apply; c, w, x, p, r open the resource tabs; v compares.
|
||||
const NOTE_KEYS = { 1: 'observation', 2: 'interpretation', 3: 'application' };
|
||||
const TAB_KEYS = { c: 'commentary', w: 'words', x: 'refs', p: 'people' };
|
||||
const TAB_KEYS = { c: 'commentary', w: 'words', x: 'refs', p: 'people', r: 'readings' };
|
||||
|
||||
export default function StudyPage() {
|
||||
const { project, allChunks, projectIndex, updateProject, updateChunk, setCurrentPage, readerCommentary, setReaderCommentary, saveUserTemplate, startPrayerFor } = useApp();
|
||||
@@ -183,7 +183,7 @@ export default function StudyPage() {
|
||||
<div className={`flex min-h-0 flex-1 flex-col border-t border-[var(--r-line)] ${folded.resources ? 'hidden' : ''}`}>
|
||||
<ResourcePane
|
||||
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}
|
||||
openWordId={openWordId} setOpenWordId={setOpenWordId}
|
||||
/>
|
||||
|
||||
@@ -509,6 +509,18 @@ describe('Study workspace', () => {
|
||||
expect(screen.getByRole('textbox', { name: 'Observe' })).toHaveValue('Written beside the translations.');
|
||||
});
|
||||
|
||||
test('reading list: add a book to the study, tick it off, and it is remembered with the study', async () => {
|
||||
const user = await studyOneChunk();
|
||||
await user.click(screen.getByRole('tab', { name: /reading list/i }));
|
||||
await user.type(screen.getByRole('textbox', { name: /add to the reading list/i }), 'Carson on John{Enter}');
|
||||
const tick = screen.getByRole('checkbox', { name: /read: carson on john/i });
|
||||
expect(tick).not.toBeChecked();
|
||||
await user.click(tick);
|
||||
expect(screen.getByRole('checkbox', { name: /read: carson on john/i })).toBeChecked();
|
||||
await user.click(screen.getByRole('tab', { name: /commentary/i }));
|
||||
expect(screen.getByRole('tab', { name: /reading list/i })).toBeInTheDocument(); // none left to read, so no count
|
||||
});
|
||||
|
||||
test('people & places: lists what is tagged in the chunk', async () => {
|
||||
const user = await studyOneChunk([['/d/theographic/TIT/1.json', { chapter: { people: [{ id: 'paul_1', name: 'Paul', verses: [1] }, { id: 'x_2', name: 'Elsewhere', verses: [9] }], places: [], events: [] } }]]);
|
||||
await user.click(screen.getByRole('tab', { name: /people & places/i }));
|
||||
|
||||
@@ -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 }],
|
||||
};
|
||||
}
|
||||
@@ -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\.$/);
|
||||
});
|
||||
@@ -25,6 +25,7 @@ export function weeklySummary(projects, now = Date.now()) {
|
||||
ownWords: ownWordsTotal,
|
||||
ownShare: shares.length ? Math.round(shares.reduce((a, b) => a + b, 0) / shares.length) : null,
|
||||
recalls: all.reduce((n, { chunk }) => n + (chunk.recalls ?? []).filter((r) => now - (r.at ?? 0) < WEEK_MS).length, 0),
|
||||
fiveMinutes: all.reduce((n, { chunk }) => n + (chunk.fiveMinutes ?? []).filter((f) => now - (f.at ?? 0) < WEEK_MS).length, 0),
|
||||
teachBacks: recent.filter(({ chunk }) => (chunk.teachBack ?? '').trim()).length,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -46,3 +46,8 @@ test('slipped lines and follow-ups are collected, open follow-ups first', () =>
|
||||
expect(slippedList(projects)[0].items).toEqual(['the serpent', 'who spoke']);
|
||||
expect(followUpList(projects).map((f) => f.text)).toEqual(['call Sam', 'done thing']);
|
||||
});
|
||||
|
||||
test('five-minute sessions this week are counted', () => {
|
||||
const p = [study([chunk('a', { fiveMinutes: [{ at: NOW - 1000, field: 'observation' }, { at: NOW - 3 * WEEK_MS, field: 'application' }] })])];
|
||||
expect(weeklySummary(p, NOW).fiveMinutes).toBe(1);
|
||||
});
|
||||
@@ -41,6 +41,7 @@ export function mergeChunk(mine, theirs) {
|
||||
const noteKeys = new Set([...Object.keys(newer.verseNotes ?? {}), ...Object.keys(older.verseNotes ?? {})]);
|
||||
out.verseNotes = Object.fromEntries([...noteKeys].map((k) => [k, mergeText(newer.verseNotes?.[k], older.verseNotes?.[k])]));
|
||||
out.firstDraft = [newer.firstDraft, older.firstDraft].filter(Boolean).sort((a, b) => a.at - b.at)[0];
|
||||
out.fiveMinutes = union(newer.fiveMinutes, older.fiveMinutes);
|
||||
out.followUps = mergeById(newer.followUps, older.followUps);
|
||||
out.recalls = union(newer.recalls, older.recalls);
|
||||
out.editedAt = Math.max(mine.editedAt ?? 0, theirs.editedAt ?? 0) || undefined;
|
||||
@@ -77,7 +78,7 @@ export function mergeStudies(mine, theirs) {
|
||||
}
|
||||
const ids = new Set(chapters.flatMap((c) => c.chunks.map((k) => k.id)));
|
||||
return {
|
||||
project: { ...mine, chapters, selectedChunkId: ids.has(mine.selectedChunkId) ? mine.selectedChunkId : [...ids][0] ?? null },
|
||||
project: { ...mine, chapters, ...(mine.readingList || theirs.readingList ? { readingList: mergeById(mine.readingList, theirs.readingList) } : {}), selectedChunkId: ids.has(mine.selectedChunkId) ? mine.selectedChunkId : [...ids][0] ?? null },
|
||||
stats,
|
||||
};
|
||||
}
|
||||
@@ -49,3 +49,15 @@ test('identical copies merge to themselves', () => {
|
||||
expect(project).toEqual(s);
|
||||
expect(stats).toEqual({ added: 0, merged: 0 });
|
||||
});
|
||||
|
||||
import { mergeStudies as mergeBoth } from './mergeStudies.js';
|
||||
|
||||
test('merging two copies keeps every reading-list item, mine winning when both changed one', () => {
|
||||
const base = { id: 'p', title: 'T', chapters: [] };
|
||||
const mine = { ...base, readingList: [{ id: 'a', title: 'Mounce', done: true }, { id: 'b', title: 'Carson', done: false }] };
|
||||
const theirs = { ...base, readingList: [{ id: 'a', title: 'Mounce', done: false }, { id: 'c', title: 'Stott', done: false }] };
|
||||
const { project } = mergeBoth(mine, theirs);
|
||||
expect(project.readingList.map((i) => i.id).sort()).toEqual(['a', 'b', 'c']);
|
||||
expect(project.readingList.find((i) => i.id === 'a').done).toBe(true);
|
||||
expect(mergeBoth(base, base).project.readingList).toBeUndefined();
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -18,6 +18,7 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
test: {
|
||||
exclude: ['e2e/**', 'node_modules/**', 'dist/**'], // e2e/ holds the Playwright browser tests (npm run test:e2e)
|
||||
environment: 'jsdom',
|
||||
environmentOptions: {
|
||||
jsdom: { url: 'http://localhost:3000/' },
|
||||
|
||||
Reference in new issue
Block a user