Compare commits
19
Commits
No files matched your search
@@ -13,3 +13,11 @@ coverage/
|
|||||||
.env
|
.env
|
||||||
.session-secret
|
.session-secret
|
||||||
server/data/backups/
|
server/data/backups/
|
||||||
|
|
||||||
|
# Playwright
|
||||||
|
e2e/.auth.json
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
|
||||||
|
# a local demo video, not part of the app
|
||||||
|
demo/
|
||||||
@@ -151,10 +151,17 @@ Private / personal use.
|
|||||||
## Development
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run test:run # all tests
|
npm run test:run # all unit and component tests
|
||||||
|
npm run test:e2e # real-browser tests (Playwright, in your installed Chrome)
|
||||||
npm run lint # ESLint (errors fail; warnings are advice)
|
npm run lint # ESLint (errors fail; warnings are advice)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The browser tests (`e2e/`) build the app, run it on port 3101 with an empty throwaway database (your real studies are never
|
||||||
|
touched), make an account, and click through the main flows: writing notes and the paste check, the daily five minutes and weekly
|
||||||
|
recall, comparing translations (Bible text is faked, so no internet is needed), and presenting on two screens. They use the
|
||||||
|
Chrome already on the computer, so there is no browser to download; install Chrome, or change `channel` in `playwright.config.js`
|
||||||
|
and run `npx playwright install` to use Playwright's own browser.
|
||||||
|
|
||||||
Who can sign up: the administrator (the `ADMIN_EMAIL` account) chooses Open, Invite only or Closed under Admin → Sign-ups, and makes single-use invite links there. The very first account can always be created.
|
Who can sign up: the administrator (the `ADMIN_EMAIL` account) chooses Open, Invite only or Closed under Admin → Sign-ups, and makes single-use invite links there. The very first account can always be created.
|
||||||
|
|
||||||
Verse of the day push: with `IQBIBLE_API_KEY` set, each person can turn on a daily verse notification in Settings → Reading reminders (it goes to the same ntfy topic).
|
Verse of the day push: with `IQBIBLE_API_KEY` set, each person can turn on a daily verse notification in Settings → Reading reminders (it goes to the same ntfy topic).
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ Ideas for the app that haven't been built yet, roughly in the order I'd do them.
|
|||||||
"sign out other devices"; optionally a login alert through ntfy.
|
"sign out other devices"; optionally a login alert through ntfy.
|
||||||
- [x] **Server health page for the admin.** Last backup, disk use, failed notifications, recent sign-in failures, and a
|
- [x] **Server health page for the admin.** Last backup, disk use, failed notifications, recent sign-in failures, and a
|
||||||
"restore a snapshot" button (snapshots already exist under `DATA_DIR/backups`).
|
"restore a snapshot" button (snapshots already exist under `DATA_DIR/backups`).
|
||||||
- [ ] **Checks on every push.** Run tests and lint on the Gitea server, plus a few real-browser tests (Playwright) for sign-in,
|
- [ ] **Checks on every push.** Run `npm run test:run`, `npm run lint` and `npm run test:e2e` on the Gitea server for every push.
|
||||||
creating a study and syncing; so far everything is tested in simulation.
|
The real-browser tests now exist (`e2e/`, run by hand); what's left is running them automatically, and adding sign-in
|
||||||
|
and cross-device sync to them.
|
||||||
- [x] **Split `server/index.js`** into one file per area (`server/routes/`: auth, account, projects, reader, sharing, admin).
|
- [x] **Split `server/index.js`** into one file per area (`server/routes/`: auth, account, projects, reader, sharing, admin).
|
||||||
|
|
||||||
## Teaching together
|
## Teaching together
|
||||||
|
|||||||
@@ -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 } },
|
languageOptions: { ecmaVersion: 'latest', sourceType: 'module', globals: { ...globals.node } },
|
||||||
rules: { 'no-unused-vars': ['warn', { argsIgnorePattern: '^_', caughtErrors: 'none' }], 'no-empty': ['error', { allowEmptyCatch: true }] },
|
rules: { 'no-unused-vars': ['warn', { argsIgnorePattern: '^_', caughtErrors: 'none' }], 'no-empty': ['error', { allowEmptyCatch: true }] },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// Playwright tests run in Node but hand functions to the browser page, which use browser globals
|
||||||
|
files: ['e2e/**/*.js'],
|
||||||
|
languageOptions: { ecmaVersion: 'latest', sourceType: 'module', globals: { ...globals.node, ...globals.browser } },
|
||||||
|
rules: { 'no-unused-vars': ['warn', { argsIgnorePattern: '^_', caughtErrors: 'none' }] },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
files: ['**/*.test.{js,jsx}', 'src/setupTests.js', 'src/test/**'],
|
files: ['**/*.test.{js,jsx}', 'src/setupTests.js', 'src/test/**'],
|
||||||
languageOptions: { globals: { ...globals.browser, ...globals.node, vi: 'readonly' } },
|
languageOptions: { globals: { ...globals.browser, ...globals.node, vi: 'readonly' } },
|
||||||
|
|||||||
Generated
+46
@@ -27,6 +27,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@playwright/test": "^1.64.1",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
@@ -1635,6 +1636,22 @@
|
|||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.64.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz",
|
||||||
|
"integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.64.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rolldown/binding-android-arm64": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz",
|
||||||
@@ -6190,6 +6207,35 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.64.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz",
|
||||||
|
"integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.64.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.64.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
|
||||||
|
"integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pngjs": {
|
"node_modules/pngjs": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||||
|
|||||||
+3
-1
@@ -15,7 +15,8 @@
|
|||||||
"coverage": "vitest run --coverage",
|
"coverage": "vitest run --coverage",
|
||||||
"backup": "bash scripts/backup-data.sh",
|
"backup": "bash scripts/backup-data.sh",
|
||||||
"restore": "bash scripts/restore-data.sh",
|
"restore": "bash scripts/restore-data.sh",
|
||||||
"lint": "eslint ."
|
"lint": "eslint .",
|
||||||
|
"test:e2e": "playwright test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@simplewebauthn/browser": "^13.3.0",
|
"@simplewebauthn/browser": "^13.3.0",
|
||||||
@@ -37,6 +38,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@playwright/test": "^1.64.1",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
|||||||
@@ -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' } },
|
||||||
|
],
|
||||||
|
});
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 90 KiB |
@@ -28,6 +28,9 @@ export function cleanPrayer(raw, now = Date.now()) {
|
|||||||
createdAt: time(raw.createdAt, updatedAt), updatedAt,
|
createdAt: time(raw.createdAt, updatedAt), updatedAt,
|
||||||
answeredAt: Number.isFinite(raw.answeredAt) && raw.answeredAt > 0 ? raw.answeredAt : null,
|
answeredAt: Number.isFinite(raw.answeredAt) && raw.answeredAt > 0 ? raw.answeredAt : null,
|
||||||
answerNote: text(raw.answerNote, 1000).trim(),
|
answerNote: text(raw.answerNote, 1000).trim(),
|
||||||
|
status: raw.status === 'waiting' || raw.status === 'partial' ? raw.status : '',
|
||||||
|
pinned: raw.pinned === true,
|
||||||
|
rank: int(raw.rank, 0, 100_000) ? raw.rank : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,3 +37,8 @@ test('a deleted prayer is not brought back by an older copy, and old markers are
|
|||||||
test('open prayers are the ones not answered or deleted', () => {
|
test('open prayers are the ones not answered or deleted', () => {
|
||||||
expect(openPrayers([p('a'), p('b', { answeredAt: NOW }), { id: 'c', deleted: true, updatedAt: 1 }]).map((x) => x.id)).toEqual(['a']);
|
expect(openPrayers([p('a'), p('b', { answeredAt: NOW }), { id: 'c', deleted: true, updatedAt: 1 }]).map((x) => x.id)).toEqual(['a']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('pin, rank and status are kept when valid and dropped when not', () => {
|
||||||
|
expect(cleanPrayer(p('a', { pinned: true, rank: 2, status: 'partial' }), NOW)).toMatchObject({ pinned: true, rank: 2, status: 'partial' });
|
||||||
|
expect(cleanPrayer(p('a', { pinned: 'yes', rank: -4, status: 'bogus' }), NOW)).toMatchObject({ pinned: false, rank: null, status: '' });
|
||||||
|
});
|
||||||
+5
-20
@@ -11,6 +11,7 @@ import Tooltips from './components/Tooltips.jsx';
|
|||||||
import { useAudio } from './hooks/useAudio.js';
|
import { useAudio } from './hooks/useAudio.js';
|
||||||
import { useAuth } from './hooks/useAuth.js';
|
import { useAuth } from './hooks/useAuth.js';
|
||||||
import { useBibleReader } from './hooks/useBibleReader.js';
|
import { useBibleReader } from './hooks/useBibleReader.js';
|
||||||
|
import { lastTemplateId, planStudyProject } from './utils/planStudy.js';
|
||||||
import { isAudienceWindow } from './utils/presentSync.js';
|
import { isAudienceWindow } from './utils/presentSync.js';
|
||||||
import { useProjectLibrary } from './hooks/useProjectLibrary.js';
|
import { useProjectLibrary } from './hooks/useProjectLibrary.js';
|
||||||
import { useHistoryNav } from './hooks/useHistoryNav.js';
|
import { useHistoryNav } from './hooks/useHistoryNav.js';
|
||||||
@@ -425,29 +426,13 @@ const AppInner = () => {
|
|||||||
const studyReadingPlanDay = async (dayRefs, title) => {
|
const studyReadingPlanDay = async (dayRefs, title) => {
|
||||||
setStudyBuildBusy(true);
|
setStudyBuildBusy(true);
|
||||||
try {
|
try {
|
||||||
|
const lastTemplate = lastTemplateId(); // the notes template you picked last time you started a study by hand
|
||||||
|
const templateDef = reader.userTemplates?.find((t) => t.id === lastTemplate);
|
||||||
const chapters = await Promise.all(dayRefs.map(async ({ book, chapter }) => {
|
const chapters = await Promise.all(dayRefs.map(async ({ book, chapter }) => {
|
||||||
const data = await fetchStudyChapter('BSB', book, chapter);
|
const data = await fetchStudyChapter('BSB', book, chapter);
|
||||||
const verses = parseBibleChapter(data);
|
return { bookAbbrev: book, bookName: bookOptions.find((b) => b.abbrev === book)?.name ?? book, chapter, verses: parseBibleChapter(data) };
|
||||||
const bookName = bookOptions.find((b) => b.abbrev === book)?.name ?? book;
|
|
||||||
const chunk = {
|
|
||||||
id: makeId(),
|
|
||||||
startVerse: verses[0]?.number ?? 1,
|
|
||||||
endVerse: verses.at(-1)?.number ?? 1,
|
|
||||||
spilloverEndVerse: null,
|
|
||||||
observation: '', interpretation: '', application: '',
|
|
||||||
crossReferences: [], greekWords: [], generalNotes: '',
|
|
||||||
episodeNumber: '', episodeTitle: '', finalScript: '', tags: [],
|
|
||||||
};
|
|
||||||
return { book: bookName, bookAbbrev: book, chapter: String(chapter), verses, chunks: [chunk] };
|
|
||||||
}));
|
}));
|
||||||
const newProject = {
|
const newProject = planStudyProject({ chapters, title, templateId: lastTemplate, templateDef, makeId });
|
||||||
id: makeId(),
|
|
||||||
title: title || 'Reading Plan Study',
|
|
||||||
translation: 'BSB',
|
|
||||||
lastEdited: Date.now(),
|
|
||||||
selectedChunkId: chapters[0]?.chunks[0]?.id ?? null,
|
|
||||||
chapters,
|
|
||||||
};
|
|
||||||
setProject(newProject);
|
setProject(newProject);
|
||||||
setCurrentPage('study');
|
setCurrentPage('study');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
// A person's family: the relatives named in their record (click one to read about them), and a tree of ancestors and
|
||||||
|
// descendants a few generations out. From Theographic Bible Metadata.
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { loadPerson } from '../hooks/useVerseStudy.js';
|
||||||
|
import { relatives, walk } from '../utils/family.js';
|
||||||
|
|
||||||
|
function Branch({ node, onOpen }) {
|
||||||
|
return (
|
||||||
|
<li>
|
||||||
|
<button type="button" onClick={() => onOpen(node.id)} className="text-[var(--r-accent)] hover:underline">{node.name || node.id}</button>
|
||||||
|
{node.more && <span className="ml-1 text-[var(--r-sub)]" title="More people beyond this point">…</span>}
|
||||||
|
{node.kids.length > 0 && <ul className="ml-4 border-l border-[var(--r-line)] pl-3">{node.kids.map((k) => <Branch key={k.id} node={k} onOpen={onOpen} />)}</ul>}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tree({ id, onOpen }) {
|
||||||
|
const [state, setState] = useState({ loading: true });
|
||||||
|
useEffect(() => {
|
||||||
|
let live = true;
|
||||||
|
Promise.all([walk(loadPerson, id, { direction: 'up', depth: 3, max: 30 }), walk(loadPerson, id, { direction: 'down', depth: 2, max: 40 })])
|
||||||
|
.then(([up, down]) => { if (live) setState({ up, down }); })
|
||||||
|
.catch(() => { if (live) setState({ error: true }); });
|
||||||
|
return () => { live = false; };
|
||||||
|
}, [id]);
|
||||||
|
if (state.loading) return <p className="mt-1 text-[var(--r-sub)]">Loading the family…</p>;
|
||||||
|
if (state.error) return <p className="mt-1 text-[var(--r-sub)]">The family tree couldn’t be loaded right now.</p>;
|
||||||
|
const block = (title, node, empty) => (
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-[var(--r-text)]">{title}</p>
|
||||||
|
{node.kids.length ? <ul className="mt-1 space-y-0.5">{node.kids.map((k) => <Branch key={k.id} node={k} onOpen={onOpen} />)}</ul> : <p className="text-[var(--r-sub)]">{empty}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
return <div className="mt-2 grid gap-3 sm:grid-cols-2">{block('Ancestors', state.up, 'None recorded.')}{block('Descendants', state.down, 'None recorded.')}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `person` is the record; `renderPerson(id)` shows another person's details inside this one when a name is clicked.
|
||||||
|
export default function Family({ person, renderPerson }) {
|
||||||
|
const groups = relatives(person);
|
||||||
|
const [shown, setShown] = useState(null); // a relative opened from a name or the tree
|
||||||
|
const [treeOpen, setTreeOpen] = useState(false); // the tree's records are only fetched once it is opened
|
||||||
|
if (groups.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div className="mt-2 space-y-1.5 border-t border-[var(--r-line)] pt-2" aria-label="Family">
|
||||||
|
{groups.map((g) => (
|
||||||
|
<p key={g.label} className="flex flex-wrap items-baseline gap-x-1.5">
|
||||||
|
<span className="w-16 shrink-0 text-[11px] uppercase tracking-wide">{g.label}</span>
|
||||||
|
{g.people.map((p) => (
|
||||||
|
<button key={p.id} type="button" onClick={() => setShown(shown === p.id ? null : p.id)} aria-pressed={shown === p.id}
|
||||||
|
className={`rounded-full border px-2 py-0.5 ${shown === p.id ? 'border-[var(--r-accent)] text-[var(--r-text)]' : 'border-[var(--r-line)] text-[var(--r-accent)] hover:bg-black/5'}`}>{p.name}</button>
|
||||||
|
))}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
<details onToggle={(e) => setTreeOpen(e.currentTarget.open)}>
|
||||||
|
<summary className="cursor-pointer text-[var(--r-accent)]">Family tree</summary>
|
||||||
|
{treeOpen && <Tree id={person.id} onOpen={(id) => setShown(id)} />}
|
||||||
|
</details>
|
||||||
|
{shown && <div className="rounded-lg bg-[var(--r-bar)] p-2">{renderPerson(shown)}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { render, screen, within } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { vi, test, expect, beforeEach } from 'vitest';
|
||||||
|
import Family from './FamilyTree.jsx';
|
||||||
|
|
||||||
|
const ref = (id, name) => ({ id, name, type: 'people' });
|
||||||
|
const RECORDS = {
|
||||||
|
fam_seth: { id: 'fam_seth', name: 'Seth', father: [ref('fam_adam', 'Adam')], mother: [ref('fam_eve', 'Eve')], children: [ref('fam_enos', 'Enos')], siblings: [ref('fam_abel', 'Abel')] },
|
||||||
|
fam_adam: { id: 'fam_adam', name: 'Adam', children: [ref('fam_seth', 'Seth')] },
|
||||||
|
fam_eve: { id: 'fam_eve', name: 'Eve', children: [ref('fam_seth', 'Seth')] },
|
||||||
|
fam_enos: { id: 'fam_enos', name: 'Enos', father: [ref('fam_seth', 'Seth')] },
|
||||||
|
fam_abel: { id: 'fam_abel', name: 'Abel' },
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn(async (url) => {
|
||||||
|
const id = String(url).match(/people\/(.+)\.json/)?.[1];
|
||||||
|
return RECORDS[id] ? { ok: true, json: async () => ({ person: RECORDS[id] }) } : { ok: false, json: async () => ({}) };
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the relatives named in the record are listed, and clicking one opens them', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<Family person={RECORDS.fam_seth} renderPerson={(id) => <div>details for {id}</div>} />);
|
||||||
|
const family = screen.getByLabelText('Family');
|
||||||
|
for (const name of ['Adam', 'Eve', 'Abel', 'Enos']) expect(within(family).getByRole('button', { name })).toBeInTheDocument();
|
||||||
|
await user.click(within(family).getByRole('button', { name: 'Adam' }));
|
||||||
|
expect(screen.getByText('details for fam_adam')).toBeInTheDocument();
|
||||||
|
await user.click(within(family).getByRole('button', { name: 'Adam' }));
|
||||||
|
expect(screen.queryByText('details for fam_adam')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the family tree shows ancestors and descendants', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<Family person={RECORDS.fam_seth} renderPerson={(id) => <div>details for {id}</div>} />);
|
||||||
|
await user.click(screen.getByText('Family tree'));
|
||||||
|
expect(await screen.findByText('Ancestors')).toBeInTheDocument();
|
||||||
|
const tree = screen.getByText('Ancestors').closest('div').parentElement;
|
||||||
|
expect(within(tree).getAllByRole('button', { name: 'Adam' }).length).toBeGreaterThan(0);
|
||||||
|
expect(within(tree).getAllByRole('button', { name: 'Enos' }).length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a person with no recorded family shows nothing', () => {
|
||||||
|
const { container } = render(<Family person={{ id: 'x', name: 'X' }} renderPerson={() => null} />);
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// What an original-language word's grammar label means, in plain English.
|
||||||
|
import { grammarNotes } from '../utils/grammar.js';
|
||||||
|
|
||||||
|
export default function GrammarPanel({ label, className = '' }) {
|
||||||
|
const notes = grammarNotes(label);
|
||||||
|
if (!label) return null;
|
||||||
|
return (
|
||||||
|
<div className={`rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] p-2.5 font-sans text-xs ${className}`} aria-label="Grammar">
|
||||||
|
<p className="text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Grammar · {label}</p>
|
||||||
|
{notes.length > 0 && (
|
||||||
|
<ul className="mt-1.5 space-y-1.5">
|
||||||
|
{notes.map((n) => (
|
||||||
|
<li key={n.term}><span className="font-medium">{n.term}.</span> <span className="text-[var(--r-sub)]">{n.meaning}</span></li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// Home card: the prayers you pinned, in the order you set, with a one-tap "I prayed". Hidden when nothing is pinned.
|
||||||
|
import { useApp } from '../context/AppContext.js';
|
||||||
|
import { STATUSES, pinned } from '../utils/prayers.js';
|
||||||
|
import { localDateString } from '../utils/readingPlans.js';
|
||||||
|
|
||||||
|
export default function PinnedPrayers() {
|
||||||
|
const { prayers, togglePrayed, openPrayer } = useApp();
|
||||||
|
const all = pinned(prayers);
|
||||||
|
const list = all.slice(0, 5);
|
||||||
|
const total = all.length;
|
||||||
|
if (!list.length) return null;
|
||||||
|
const today = localDateString();
|
||||||
|
return (
|
||||||
|
<section aria-label="Pinned prayers" className="rounded-xl border border-[var(--r-accent)] border-l-4 bg-[var(--r-bar)] px-4 py-3 text-sm shadow-sm">
|
||||||
|
<div className="mb-1 flex items-baseline justify-between gap-3">
|
||||||
|
<h2 className="flex items-center gap-1.5 font-semibold text-[var(--r-accent)]"><svg viewBox="0 0 24 24" className="h-4 w-4" fill="currentColor" aria-hidden="true"><path d="M12 20s-7-4.4-7-10a4 4 0 0 1 7-2.6A4 4 0 0 1 19 10c0 5.6-7 10-7 10Z" /></svg>Praying for <span className="text-xs font-normal text-[var(--r-sub)]">{total > list.length ? `${list.length} of ${total}` : total}</span></h2>
|
||||||
|
<button type="button" onClick={openPrayer} className="text-xs text-[var(--r-accent)] hover:underline">Open journal →</button>
|
||||||
|
</div>
|
||||||
|
<ul className="divide-y divide-[var(--r-line)]">
|
||||||
|
{list.map((p) => {
|
||||||
|
const done = p.prayedOn.includes(today);
|
||||||
|
const days = p.prayedOn.length;
|
||||||
|
return (
|
||||||
|
<li key={p.id} className="flex items-start gap-3 py-2.5 first:pt-1.5 last:pb-0.5">
|
||||||
|
<button type="button" onClick={() => togglePrayed(p.id)} aria-pressed={done} aria-label={`${done ? 'Prayed today' : 'I prayed'}: ${p.text.slice(0, 40)}`}
|
||||||
|
title={done ? 'Prayed today (tap to undo)' : 'Mark as prayed today'}
|
||||||
|
className={`mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full border text-xs ${done ? 'border-[var(--r-accent)] bg-[var(--r-accent)] text-[var(--r-on)]' : 'border-[var(--r-line)] text-transparent hover:border-[var(--r-accent)] hover:text-[var(--r-accent)]'}`}>✓</button>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{(p.forWhom || p.status) && (
|
||||||
|
<p className="mb-0.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] font-medium uppercase tracking-wide text-[var(--r-sub)]">
|
||||||
|
{p.forWhom && <span>{p.forWhom}</span>}
|
||||||
|
{p.status && <span className="rounded-full border border-[var(--r-accent)] px-1.5 py-px text-[10px] normal-case tracking-normal text-[var(--r-accent)]">{STATUSES[p.status]}</span>}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="line-clamp-3 leading-snug">{p.text}</p>
|
||||||
|
{p.status === 'partial' && p.answerNote && <p className="mt-1 line-clamp-2 text-xs italic text-[var(--r-sub)]">{p.answerNote}</p>}
|
||||||
|
{days > 0 && <p className="mt-1 text-[11px] text-[var(--r-sub)]">Prayed {days} day{days === 1 ? '' : 's'}</p>}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { vi, test, expect } from 'vitest';
|
||||||
|
import { AppContext } from '../context/AppContext.js';
|
||||||
|
import PinnedPrayers from './PinnedPrayers.jsx';
|
||||||
|
import { markAnswered, newPrayer, setPinned } from '../utils/prayers.js';
|
||||||
|
|
||||||
|
const T0 = Date.UTC(2026, 9, 1);
|
||||||
|
const show = (prayers) => {
|
||||||
|
const ctx = { prayers, togglePrayed: vi.fn(), openPrayer: vi.fn() };
|
||||||
|
const view = render(<AppContext.Provider value={ctx}><PinnedPrayers /></AppContext.Provider>);
|
||||||
|
return { ctx, view };
|
||||||
|
};
|
||||||
|
|
||||||
|
test('shows nothing when no prayer is pinned', () => {
|
||||||
|
const { view } = show([newPrayer({ text: 'Unpinned' }, T0)]);
|
||||||
|
expect(view.container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lists open pinned prayers in order, lets you mark one prayed, and opens the journal', async () => {
|
||||||
|
const a = newPrayer({ text: 'For Sam', forWhom: 'Sam' }, T0);
|
||||||
|
const b = newPrayer({ text: 'Wisdom' }, T0 + 1);
|
||||||
|
const c = newPrayer({ text: 'Done already' }, T0 + 2);
|
||||||
|
let list = [a, b, c];
|
||||||
|
list = setPinned(setPinned(setPinned(list, b.id, true, T0 + 5), a.id, true, T0 + 6), c.id, true, T0 + 7);
|
||||||
|
list = list.map((p) => (p.id === c.id ? markAnswered(p, '', T0 + 9) : p));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { ctx } = show(list);
|
||||||
|
const rows = screen.getAllByRole('listitem');
|
||||||
|
expect(rows).toHaveLength(2);
|
||||||
|
expect(rows[0]).toHaveTextContent('Wisdom');
|
||||||
|
expect(rows[1]).toHaveTextContent('Sam');
|
||||||
|
await user.click(screen.getAllByRole('button', { name: /I prayed/ })[0]);
|
||||||
|
expect(ctx.togglePrayed).toHaveBeenCalledWith(b.id);
|
||||||
|
await user.click(screen.getByRole('button', { name: /Open journal/ }));
|
||||||
|
expect(ctx.openPrayer).toHaveBeenCalled();
|
||||||
|
});
|
||||||
@@ -15,7 +15,7 @@ export default function RecallHistory() {
|
|||||||
const { counts, total, recent } = recallSummary(history);
|
const { counts, total, recent } = recallSummary(history);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section aria-label="Recall history" className="mb-4 rounded-xl border border-[var(--r-line)] bg-[var(--r-bar)] px-4 py-3 text-sm">
|
<section aria-label="Recall history" className="rounded-xl border border-[var(--r-line)] bg-[var(--r-bar)] px-4 py-3 text-sm">
|
||||||
<button type="button" onClick={() => setOpen((o) => !o)} aria-expanded={open} className="flex w-full flex-wrap items-center justify-between gap-3 text-left">
|
<button type="button" onClick={() => setOpen((o) => !o)} aria-expanded={open} className="flex w-full flex-wrap items-center justify-between gap-3 text-left">
|
||||||
<span><span className="font-medium">Recall history</span> <span className="text-xs text-[var(--r-sub)]">{total} recall{total === 1 ? '' : 's'} · {counts.solid} had it · {counts.partly} partly · {counts.forgot} slipped</span></span>
|
<span><span className="font-medium">Recall history</span> <span className="text-xs text-[var(--r-sub)]">{total} recall{total === 1 ? '' : 's'} · {counts.solid} had it · {counts.partly} partly · {counts.forgot} slipped</span></span>
|
||||||
<span className="flex items-center gap-1" aria-label="Last eight weeks">
|
<span className="flex items-center gap-1" aria-label="Last eight weeks">
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// Under a note: each Scripture reference written in it (Rom 8:28, Psalm 23…) as a chip. Click one to read the verse
|
||||||
|
// (BSB) right there, or open it in the Bible reader. The text itself is never changed.
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useApp } from '../context/AppContext.js';
|
||||||
|
import { fetchChapter } from '../utils/bibleText.js';
|
||||||
|
import { labelFor, uniqueReferences } from '../utils/scriptureRefs.js';
|
||||||
|
|
||||||
|
const MAX_VERSES = 12;
|
||||||
|
const CHAPTER_PREVIEW = 6;
|
||||||
|
|
||||||
|
function Preview({ r }) {
|
||||||
|
const { jumpToReaderVerse, openBibleReader } = useApp();
|
||||||
|
const [state, setState] = useState({ loading: true });
|
||||||
|
useEffect(() => {
|
||||||
|
let live = true;
|
||||||
|
setState({ loading: true });
|
||||||
|
fetchChapter('BSB', r.book, r.chapter)
|
||||||
|
.then(({ verses }) => {
|
||||||
|
const from = r.verse ?? 1;
|
||||||
|
const to = r.verse ? (r.endVerse ?? r.verse) : from + CHAPTER_PREVIEW - 1;
|
||||||
|
const picked = verses.filter((v) => v.number >= from && v.number <= to).slice(0, MAX_VERSES);
|
||||||
|
if (live) setState({ verses: picked, more: !r.verse && verses.length > picked.length });
|
||||||
|
})
|
||||||
|
.catch((err) => { if (live) setState({ error: err.message || 'Could not load the verse.' }); });
|
||||||
|
return () => { live = false; };
|
||||||
|
}, [r.book, r.chapter, r.verse, r.endVerse]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div role="region" aria-label={`${labelFor(r)} preview`} className="mt-1.5 rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] p-3 text-[13px]">
|
||||||
|
{state.loading && <p className="text-xs text-[var(--r-sub)]">Loading…</p>}
|
||||||
|
{state.error && <p className="text-xs text-[var(--r-sub)]">{state.error} You can still open it in the Bible.</p>}
|
||||||
|
{state.verses && (state.verses.length === 0
|
||||||
|
? <p className="text-xs text-[var(--r-sub)]">That verse isn’t in the BSB.</p>
|
||||||
|
: <div className="space-y-1 font-serif leading-relaxed">{state.verses.map((v) => <p key={v.number}><sup className="mr-1 font-sans text-[var(--r-accent)]">{v.number}</sup>{v.text}</p>)}{state.more && <p className="text-xs text-[var(--r-sub)]">…</p>}</div>)}
|
||||||
|
<div className="mt-2 flex items-center gap-3 font-sans text-xs">
|
||||||
|
<button type="button" onClick={() => { jumpToReaderVerse(r.book, r.chapter, r.verse ?? 1); openBibleReader(); }} className="text-[var(--r-accent)] hover:underline">Read in the Bible</button>
|
||||||
|
<span className="text-[var(--r-sub)]">Berean Standard Bible</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ScriptureRefs({ text }) {
|
||||||
|
const refs = useMemo(() => uniqueReferences(text), [text]);
|
||||||
|
const [open, setOpen] = useState(null);
|
||||||
|
if (refs.length === 0) return null;
|
||||||
|
const openRef = refs.find((r) => labelFor(r) === open);
|
||||||
|
return (
|
||||||
|
<div className="mt-1.5" aria-label="References in this note">
|
||||||
|
<p className="flex flex-wrap items-center gap-1.5 text-xs">
|
||||||
|
<span className="text-[var(--r-sub)]">Verses mentioned:</span>
|
||||||
|
{refs.map((r) => {
|
||||||
|
const label = labelFor(r);
|
||||||
|
return (
|
||||||
|
<button key={label} type="button" aria-pressed={open === label} onClick={() => setOpen(open === label ? null : label)}
|
||||||
|
className={`rounded-full border px-2 py-0.5 ${open === label ? 'border-[var(--r-accent)] text-[var(--r-text)]' : 'border-[var(--r-line)] text-[var(--r-accent)] hover:bg-black/5'}`}>{label}</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
{openRef && <Preview r={openRef} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { render, screen, within } 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 ScriptureRefs from './ScriptureRefs.jsx';
|
||||||
|
|
||||||
|
const jump = vi.fn();
|
||||||
|
const open = vi.fn();
|
||||||
|
beforeEach(() => {
|
||||||
|
jump.mockClear(); open.mockClear();
|
||||||
|
vi.stubGlobal('fetch', vi.fn(async () => ({
|
||||||
|
ok: true, status: 200,
|
||||||
|
json: async () => ({ book: { numberOfChapters: 16 }, verses: [28, 29, 30].map((n) => ({ number: n, text: `Verse ${n} text.` })) }),
|
||||||
|
})));
|
||||||
|
});
|
||||||
|
const setup = (text) => render(<AppContext.Provider value={{ jumpToReaderVerse: jump, openBibleReader: open }}><ScriptureRefs text={text} /></AppContext.Provider>);
|
||||||
|
|
||||||
|
test('references in a note become chips, and a note with none shows nothing', () => {
|
||||||
|
const { container } = setup('Nothing to see here.');
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking a reference shows the verse, and "Read in the Bible" opens the reader there', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
setup('God works all things together, Rom 8:28-29. See also Romans 8:28.');
|
||||||
|
expect(screen.getAllByRole('button', { name: /Romans 8/ })).toHaveLength(2); // 8:28–29 and 8:28 are different references
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Romans 8:28–29' }));
|
||||||
|
const preview = await screen.findByRole('region', { name: /Romans 8:28–29 preview/ });
|
||||||
|
expect(await within(preview).findByText('Verse 28 text.')).toBeInTheDocument();
|
||||||
|
expect(within(preview).getByText('Verse 29 text.')).toBeInTheDocument();
|
||||||
|
expect(within(preview).queryByText('Verse 30 text.')).not.toBeInTheDocument();
|
||||||
|
await user.click(within(preview).getByRole('button', { name: 'Read in the Bible' }));
|
||||||
|
expect(jump).toHaveBeenCalledWith('ROM', 8, 28);
|
||||||
|
expect(open).toHaveBeenCalled();
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Romans 8:28–29' }));
|
||||||
|
expect(screen.queryByRole('region')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a failure to load the verse still leaves the link to the Bible', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('offline'); }));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
setup('Psalm 23:1');
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Psalms 23:1' }));
|
||||||
|
const preview = await screen.findByRole('region');
|
||||||
|
expect(await within(preview).findByText(/You can still open it in the Bible/)).toBeInTheDocument();
|
||||||
|
expect(within(preview).getByRole('button', { name: 'Read in the Bible' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
@@ -24,7 +24,7 @@ const BY_PAGE = {
|
|||||||
['← →', 'Previous / next chunk'],
|
['← →', 'Previous / next chunk'],
|
||||||
['1 2 3', 'Write in Observe / Interpret / Apply'],
|
['1 2 3', 'Write in Observe / Interpret / Apply'],
|
||||||
['Esc', 'Leave a note box (then keys work again)'],
|
['Esc', 'Leave a note box (then keys work again)'],
|
||||||
['C W X P', 'Commentary / Word study / Cross-refs / People & places'],
|
['C W X P R', 'Commentary / Word study / Cross-refs / People & places / Reading list'],
|
||||||
['V', 'Compare passages or translations'],
|
['V', 'Compare passages or translations'],
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState } from 'react';
|
|||||||
import { useApp } from '../context/AppContext.js';
|
import { useApp } from '../context/AppContext.js';
|
||||||
import { useIqAvailable } from '../hooks/useIq.js';
|
import { useIqAvailable } from '../hooks/useIq.js';
|
||||||
import IqCommentary from './iq/IqCommentary.jsx';
|
import IqCommentary from './iq/IqCommentary.jsx';
|
||||||
|
import Family from './FamilyTree.jsx';
|
||||||
import {
|
import {
|
||||||
COMMENTARIES, loadCommentary, loadTheographic, loadPerson, pickCommentaryEntry, useAsync,
|
COMMENTARIES, loadCommentary, loadTheographic, loadPerson, pickCommentaryEntry, useAsync,
|
||||||
} from '../hooks/useVerseStudy.js';
|
} from '../hooks/useVerseStudy.js';
|
||||||
@@ -71,6 +72,7 @@ function PersonDetails({ id }) {
|
|||||||
<div className="mt-1 space-y-1 text-xs leading-relaxed text-[var(--r-sub,#64748b)]">
|
<div className="mt-1 space-y-1 text-xs leading-relaxed text-[var(--r-sub,#64748b)]">
|
||||||
{(data.description ?? []).map((d, i) => <p key={i}>{d}</p>)}
|
{(data.description ?? []).map((d, i) => <p key={i}>{d}</p>)}
|
||||||
{!data.description?.length && <p>No description available.</p>}
|
{!data.description?.length && <p>No description available.</p>}
|
||||||
|
<Family person={data} renderPerson={(pid) => <PersonDetails key={pid} id={pid} />} />
|
||||||
{data.minYear != null && (
|
{data.minYear != null && (
|
||||||
<p className="text-[var(--r-sub,#64748b)]">
|
<p className="text-[var(--r-sub,#64748b)]">
|
||||||
Lived approx. {era(data.minYear)}{data.maxYear != null && data.maxYear !== data.minYear ? ` – ${era(data.maxYear)}` : ''}
|
Lived approx. {era(data.minYear)}{data.maxYear != null && data.maxYear !== data.minYear ? ` – ${era(data.maxYear)}` : ''}
|
||||||
|
|||||||
@@ -60,8 +60,8 @@ function Card({ iq }) {
|
|||||||
<section className="mb-4 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5" aria-label="Daily verse and devotional">
|
<section className="mb-4 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5" aria-label="Daily verse and devotional">
|
||||||
{verse && (
|
{verse && (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<p className="text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Verse of the day</p>
|
<p className="whitespace-nowrap text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Verse of the day</p>
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<select aria-label="Verse of the day background" value={bgChoice} onChange={(e) => pickBackground(e.target.value)}
|
<select aria-label="Verse of the day background" value={bgChoice} onChange={(e) => pickBackground(e.target.value)}
|
||||||
className="rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2 py-0.5 text-xs">
|
className="rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2 py-0.5 text-xs">
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
// A Bible dictionary lookup (Easton, Hastings, Hitchcock, Schaff, Smith: all public domain, from IQ Bible).
|
||||||
|
// Type a word, pick a dictionary; the entry named for the word comes first and the entries that only mention it
|
||||||
|
// are folded below. Scripture references in an entry open in the Reader.
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useApp } from '../../context/AppContext.js';
|
||||||
|
import { useIq } from '../../hooks/useIq.js';
|
||||||
|
import { pickEntries, preview, refTarget } from '../../utils/dictionary.js';
|
||||||
|
|
||||||
|
const field = '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 focus:ring-2 focus:ring-[var(--r-line)]';
|
||||||
|
|
||||||
|
function Entry({ entry, credit, open: startOpen }) {
|
||||||
|
const { jumpToReaderVerse, openBibleReader } = useApp();
|
||||||
|
const [open, setOpen] = useState(startOpen);
|
||||||
|
const p = preview(entry.definition);
|
||||||
|
const refs = [...new Set(entry.scripture_refs ?? [])].map(refTarget).filter(Boolean).slice(0, 14);
|
||||||
|
return (
|
||||||
|
<article className="rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] p-3">
|
||||||
|
<h4 className="font-medium">{entry.term}</h4>
|
||||||
|
<p className="mt-1 whitespace-pre-line text-[13px] leading-relaxed">{open || !p.cut ? entry.definition : p.text}</p>
|
||||||
|
{p.cut && <button type="button" onClick={() => setOpen((o) => !o)} className="mt-1 text-xs text-[var(--r-accent)] hover:underline">{open ? 'Show less' : 'Read the whole entry'}</button>}
|
||||||
|
{refs.length > 0 && (
|
||||||
|
<p className="mt-2 flex flex-wrap gap-1.5">
|
||||||
|
{refs.map((r) => (
|
||||||
|
<button key={r.label} type="button" onClick={() => { jumpToReaderVerse(r.book, r.chapter, r.verse); openBibleReader(); }} title="Read it in the Bible"
|
||||||
|
className="rounded-full border border-[var(--r-line)] px-2 py-0.5 text-[11px] text-[var(--r-accent)] hover:bg-black/5">{r.label}</button>
|
||||||
|
))}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-1.5 text-[10px] text-[var(--r-sub)]">{credit}</p>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function IqDictionary() {
|
||||||
|
const [dictId, setDictId] = useState(() => { try { return localStorage.getItem('dictionary') || 'easton'; } catch { return 'easton'; } });
|
||||||
|
const [typed, setTyped] = useState('');
|
||||||
|
const [asked, setAsked] = useState('');
|
||||||
|
const list = useIq('dictionaries');
|
||||||
|
const dictionaries = list.data?.data ?? [];
|
||||||
|
const current = dictionaries.find((d) => d.id === dictId) ?? dictionaries[0];
|
||||||
|
const result = useIq(asked && current ? `dictionaries/${current.id}` : null, { q: asked });
|
||||||
|
const { exact, others } = pickEntries(result.data?.data, asked);
|
||||||
|
const credit = current ? `${current.name}, ${current.author}, ${current.year}. ${current.license}.` : '';
|
||||||
|
const choose = (id) => { setDictId(id); try { localStorage.setItem('dictionary', id); } catch { /* storage unavailable */ } };
|
||||||
|
const ask = () => setAsked(typed.trim().slice(0, 100));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<details className="mb-3 rounded-xl border border-[var(--r-line)] p-3" aria-label="Bible dictionary">
|
||||||
|
<summary className="cursor-pointer text-sm font-medium">Bible dictionary</summary>
|
||||||
|
<div className="mt-3 space-y-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<input type="text" aria-label="Look up a word" value={typed} onChange={(e) => setTyped(e.target.value)} placeholder="A name, place or word, e.g. cubit"
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); ask(); } }} className={`${field} min-w-0 flex-1`} />
|
||||||
|
<select aria-label="Dictionary" value={current?.id ?? dictId} onChange={(e) => choose(e.target.value)} className={field}>
|
||||||
|
{dictionaries.length === 0 && <option value={dictId}>{dictId}</option>}
|
||||||
|
{dictionaries.map((d) => <option key={d.id} value={d.id}>{d.name.replace(/ Bible Dictionary| Dictionary of the Bible| Bible Names Dictionary/, '')} ({d.entry_count})</option>)}
|
||||||
|
</select>
|
||||||
|
<button type="button" onClick={ask} disabled={!typed.trim()} className="rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-xs hover:bg-black/5 disabled:opacity-50">Look up</button>
|
||||||
|
</div>
|
||||||
|
{result.status === 'loading' && <p className="text-xs text-[var(--r-sub)]">Looking up…</p>}
|
||||||
|
{result.status === 'error' && <p role="alert" className="text-xs text-[var(--r-danger,#b42318)]">{result.error || 'The dictionary could not be reached.'}</p>}
|
||||||
|
{asked && (result.status === 'ready' || result.status === 'empty') && !exact && others.length === 0 && (
|
||||||
|
<p className="text-xs text-[var(--r-sub)]">Nothing for “{asked}” in this dictionary. Try another dictionary or the singular of the word.</p>
|
||||||
|
)}
|
||||||
|
{exact && <Entry entry={exact} credit={credit} open={false} />}
|
||||||
|
{others.length > 0 && (
|
||||||
|
<details className="text-xs">
|
||||||
|
<summary className="cursor-pointer text-[var(--r-sub)]">{exact ? 'Other entries that mention' : 'Entries that mention'} “{asked}” ({others.length})</summary>
|
||||||
|
<div className="mt-2 space-y-2">{others.slice(0, 8).map((e) => <Entry key={e.slug ?? e.term} entry={e} credit={credit} open={false} />)}</div>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { render, screen, within } 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 IqDictionary from './IqDictionary.jsx';
|
||||||
|
|
||||||
|
const LIST = { count: 2, data: [
|
||||||
|
{ id: 'easton', name: "Easton's Bible Dictionary", author: 'Matthew George Easton', year: '1897', license: 'Public domain', entry_count: 3962 },
|
||||||
|
{ id: 'smith', name: "Smith's Bible Dictionary", author: 'William Smith', year: '1863', license: 'Public domain', entry_count: 4488 },
|
||||||
|
] };
|
||||||
|
const ANSWER = { count: 2, data: [
|
||||||
|
{ term: 'Alabaster', slug: 'alabaster', definition: 'A stone box. It mentions a talent.', scripture_refs: ['Matthew 26:7'] },
|
||||||
|
{ term: 'Talent', slug: 'talent', definition: 'The largest Hebrew weight, about 75 pounds.', scripture_refs: ['Exodus 38:25', 'Matthew 25:15'] },
|
||||||
|
] };
|
||||||
|
|
||||||
|
const jump = vi.fn();
|
||||||
|
const open = vi.fn();
|
||||||
|
const asked = [];
|
||||||
|
beforeEach(() => {
|
||||||
|
asked.length = 0; jump.mockClear(); open.mockClear(); localStorage.clear();
|
||||||
|
vi.stubGlobal('fetch', vi.fn(async (url) => {
|
||||||
|
asked.push(String(url));
|
||||||
|
const body = String(url).includes('/api/iq/dictionaries/') ? ANSWER : LIST;
|
||||||
|
return { ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) };
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('look up a word: the entry named for it comes first, its Scripture references open in the Reader', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<AppContext.Provider value={{ jumpToReaderVerse: jump, openBibleReader: open }}><IqDictionary /></AppContext.Provider>);
|
||||||
|
await user.click(screen.getByText('Bible dictionary'));
|
||||||
|
await user.type(await screen.findByRole('textbox', { name: 'Look up a word' }), 'talent{Enter}');
|
||||||
|
const entry = (await screen.findByRole('heading', { name: 'Talent' })).closest('article');
|
||||||
|
expect(within(entry).getByText(/about 75 pounds/)).toBeInTheDocument();
|
||||||
|
expect(asked.some((u) => /dictionaries\/easton\?q=talent/.test(u))).toBe(true);
|
||||||
|
await user.click(within(entry).getByRole('button', { name: 'Matthew 25:15' }));
|
||||||
|
expect(jump).toHaveBeenCalledWith('MAT', 25, 15);
|
||||||
|
expect(open).toHaveBeenCalled();
|
||||||
|
expect(within(entry).getByText(/Easton's Bible Dictionary, Matthew George Easton, 1897\. Public domain\./)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Entries that mention|Other entries that mention/)).toBeInTheDocument(); // Alabaster only mentions it
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the chosen dictionary is remembered and used for the next lookup', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<AppContext.Provider value={{ jumpToReaderVerse: jump, openBibleReader: open }}><IqDictionary /></AppContext.Provider>);
|
||||||
|
await user.click(screen.getByText('Bible dictionary'));
|
||||||
|
await user.selectOptions(await screen.findByRole('combobox', { name: 'Dictionary' }), 'smith');
|
||||||
|
expect(localStorage.getItem('dictionary')).toBe('smith');
|
||||||
|
await user.type(screen.getByRole('textbox', { name: 'Look up a word' }), 'cubit{Enter}');
|
||||||
|
await screen.findByRole('heading', { name: 'Talent' });
|
||||||
|
expect(asked.some((u) => /dictionaries\/smith\?q=cubit/.test(u))).toBe(true);
|
||||||
|
});
|
||||||
@@ -81,7 +81,7 @@ export default function DataSection() {
|
|||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<button type="button" onClick={download} className={btn}>Download backup</button>
|
<button type="button" onClick={download} className={btn}>Download backup</button>
|
||||||
{authUser && (
|
{authUser && (
|
||||||
<a href="/api/account/export" download className={`${btn} inline-block`} title="Everything saved to your account on the server, including studies from other devices">Download from my account</a>
|
<a href="/api/account/export" download onClick={() => markBackedUp()} className={`${btn} inline-block`} title="Everything saved to your account on the server, including studies from other devices">Download from my account</a>
|
||||||
)}
|
)}
|
||||||
<button type="button" onClick={() => fileRef.current?.click()} className={btn}>Restore from a backup…</button>
|
<button type="button" onClick={() => fileRef.current?.click()} className={btn}>Restore from a backup…</button>
|
||||||
<input ref={fileRef} type="file" accept="application/json,.json" onChange={choose} className="hidden" aria-label="Backup file" />
|
<input ref={fileRef} type="file" accept="application/json,.json" onChange={choose} className="hidden" aria-label="Backup file" />
|
||||||
@@ -106,7 +106,7 @@ export default function DataSection() {
|
|||||||
<p className="mb-3 mt-1 text-sm text-[var(--r-sub)]">Chapters you read are kept automatically. To read anywhere, save a whole Bible while you’re connected (about 4 MB). The ESV, NLT, CSB and NASB can’t be saved: their licences don’t allow it.</p>
|
<p className="mb-3 mt-1 text-sm text-[var(--r-sub)]">Chapters you read are kept automatically. To read anywhere, save a whole Bible while you’re connected (about 4 MB). The ESV, NLT, CSB and NASB can’t be saved: their licences don’t allow it.</p>
|
||||||
{offline.job ? <DownloadProgress job={offline.job} onCancel={offline.cancel} /> : (
|
{offline.job ? <DownloadProgress job={offline.job} onCancel={offline.cancel} /> : (
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
{['BSB', 'NET'].map((t) => (
|
{['BSB', 'NET', 'KJV', 'ASV', 'LSV'].map((t) => (
|
||||||
<button key={t} type="button" onClick={() => offline.saveBible(t)} className={btn}>
|
<button key={t} type="button" onClick={() => offline.saveBible(t)} className={btn}>
|
||||||
{(offline.saved[t] ?? []).length >= 66 ? `✓ The whole ${t} is saved` : `Save the whole ${t}`}
|
{(offline.saved[t] ?? []).length >= 66 ? `✓ The whole ${t} is saved` : `Save the whole ${t}`}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { render, screen } 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 { ToastProvider } from '../Toasts.jsx';
|
||||||
|
import { lastBackupAt } from '../../utils/backup.js';
|
||||||
|
import DataSection from './DataSection.jsx';
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
authUser: { email: 'a@b.test' }, readerBookmarks: {}, readerBookmarkNotes: {}, readerTextHighlights: [], readerColorLabels: {}, readerInkByPage: {},
|
||||||
|
readingPlansState: { progress: [], customPlans: [], activity: [] }, importReaderMarks: vi.fn(), importReadingPlans: vi.fn(), refreshProjectIndex: vi.fn(), prayers: [], importPrayers: vi.fn(),
|
||||||
|
};
|
||||||
|
const setup = () => {
|
||||||
|
render(<AppContext.Provider value={value}><ToastProvider><DataSection /></ToastProvider></AppContext.Provider>);
|
||||||
|
return userEvent.setup();
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
URL.createObjectURL = vi.fn(() => 'blob:x');
|
||||||
|
URL.revokeObjectURL = vi.fn();
|
||||||
|
document.addEventListener('click', (e) => { if (e.target.closest?.('a[download]')) e.preventDefault(); }); // no real navigation in the test
|
||||||
|
});
|
||||||
|
|
||||||
|
test('downloading the backup file is remembered, so Home stops asking', async () => {
|
||||||
|
const user = setup();
|
||||||
|
expect(lastBackupAt()).toBeNull();
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Download backup' }));
|
||||||
|
expect(lastBackupAt()).toBeGreaterThan(Date.now() - 5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('downloading from the account is remembered too', async () => {
|
||||||
|
const user = setup();
|
||||||
|
expect(lastBackupAt()).toBeNull();
|
||||||
|
await user.click(screen.getByRole('link', { name: 'Download from my account' }));
|
||||||
|
expect(lastBackupAt()).toBeGreaterThan(Date.now() - 5000);
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import SessionNotes from './SessionNotes.jsx';
|
import SessionNotes from './SessionNotes.jsx';
|
||||||
import FirstDraft from './FirstDraft.jsx';
|
import FirstDraft from './FirstDraft.jsx';
|
||||||
|
import ScriptureRefs from '../ScriptureRefs.jsx';
|
||||||
import { usePasteCheck } from './PastePrompt.jsx';
|
import { usePasteCheck } from './PastePrompt.jsx';
|
||||||
import { listNoteVersions, recordNoteVersion } from '../../utils/noteHistory.js';
|
import { listNoteVersions, recordNoteVersion } from '../../utils/noteHistory.js';
|
||||||
import DictateButton, { appendSpoken } from '../DictateButton.jsx';
|
import DictateButton, { appendSpoken } from '../DictateButton.jsx';
|
||||||
@@ -108,6 +109,7 @@ export default function NotesPane({ chunk, updateChunk, allTags, templateId, sav
|
|||||||
<div className="px-4 pb-4">
|
<div className="px-4 pb-4">
|
||||||
<textarea ref={(el) => { areas.current[field] = el; }} aria-label={label} value={chunk[field] ?? ''} rows={7} placeholder={templatePrompt(templateId, field) ?? placeholder}
|
<textarea ref={(el) => { areas.current[field] = el; }} aria-label={label} value={chunk[field] ?? ''} rows={7} placeholder={templatePrompt(templateId, field) ?? placeholder}
|
||||||
onFocus={() => keepVersion(field)} onBlur={() => keepVersion(field)} onPaste={pasteCheck.onPaste(field)} onChange={(e) => updateChunk(chunk.id, { [field]: e.target.value })} className={areaCls} />
|
onFocus={() => keepVersion(field)} onBlur={() => keepVersion(field)} onPaste={pasteCheck.onPaste(field)} onChange={(e) => updateChunk(chunk.id, { [field]: e.target.value })} className={areaCls} />
|
||||||
|
<ScriptureRefs text={chunk[field]} />
|
||||||
{pasteCheck.prompt(field)}
|
{pasteCheck.prompt(field)}
|
||||||
<button type="button" onClick={() => openHistory(field)} aria-expanded={historyField === field} className="mt-1.5 mr-3 text-xs text-[var(--r-sub)] hover:text-[var(--r-text)]">History</button>
|
<button type="button" onClick={() => openHistory(field)} aria-expanded={historyField === field} className="mt-1.5 mr-3 text-xs text-[var(--r-sub)] hover:text-[var(--r-text)]">History</button>
|
||||||
{historyField === field && (
|
{historyField === field && (
|
||||||
@@ -150,6 +152,7 @@ export default function NotesPane({ chunk, updateChunk, allTags, templateId, sav
|
|||||||
<textarea value={chunk.generalNotes ?? ''} rows={5} onChange={(e) => updateChunk(chunk.id, { generalNotes: e.target.value })}
|
<textarea value={chunk.generalNotes ?? ''} rows={5} onChange={(e) => updateChunk(chunk.id, { generalNotes: e.target.value })}
|
||||||
placeholder="e.g. author, date, audience, historical context, key themes…" className={`mt-1 ${areaCls}`} />
|
placeholder="e.g. author, date, audience, historical context, key themes…" className={`mt-1 ${areaCls}`} />
|
||||||
<span className="mt-1 block"><DictateButton label="Dictate background" onText={(said) => updateChunk(chunk.id, { generalNotes: appendSpoken(chunk.generalNotes, said) })} /></span>
|
<span className="mt-1 block"><DictateButton label="Dictate background" onText={(said) => updateChunk(chunk.id, { generalNotes: appendSpoken(chunk.generalNotes, said) })} /></span>
|
||||||
|
<ScriptureRefs text={chunk.generalNotes} />
|
||||||
</label>
|
</label>
|
||||||
<label className="block text-xs text-[var(--r-sub)]">
|
<label className="block text-xs text-[var(--r-sub)]">
|
||||||
Discussion questions <span className="opacity-70">(one per line; printed on the lesson handout)</span>
|
Discussion questions <span className="opacity-70">(one per line; printed on the lesson handout)</span>
|
||||||
|
|||||||
@@ -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 WordStudyTab from './WordStudyTab.jsx';
|
||||||
import CrossRefsTab from './CrossRefsTab.jsx';
|
import CrossRefsTab from './CrossRefsTab.jsx';
|
||||||
import PeopleChunkTab from './PeopleChunkTab.jsx';
|
import PeopleChunkTab from './PeopleChunkTab.jsx';
|
||||||
|
import ReadingListTab from './ReadingListTab.jsx';
|
||||||
import IqChunkAids from '../iq/IqChunkAids.jsx';
|
import IqChunkAids from '../iq/IqChunkAids.jsx';
|
||||||
import { useIqAvailable } from '../../hooks/useIq.js';
|
import { useIqAvailable } from '../../hooks/useIq.js';
|
||||||
import { OBSERVE_FIRST_MIN, observeFirstDone, ownWords } from '../../utils/ownWork.js';
|
import { OBSERVE_FIRST_MIN, observeFirstDone, ownWords } from '../../utils/ownWork.js';
|
||||||
@@ -14,10 +15,11 @@ export const RESOURCE_TABS = [
|
|||||||
{ id: 'words', label: 'Word study' },
|
{ id: 'words', label: 'Word study' },
|
||||||
{ id: 'refs', label: 'Cross-refs' },
|
{ id: 'refs', label: 'Cross-refs' },
|
||||||
{ id: 'people', label: 'People & places' },
|
{ id: 'people', label: 'People & places' },
|
||||||
|
{ id: 'readings', label: 'Reading list' },
|
||||||
{ id: 'aids', label: 'Study aids', needsIq: true }, // shown only when the server has an IQ Bible key
|
{ id: 'aids', label: 'Study aids', needsIq: true }, // shown only when the server has an IQ Bible key
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function ResourcePane({ tab, setTab, chunk, chapter, chapterIndex, project, updateChunk, notify, resources, wordStudy, crossRefs, openWordId, setOpenWordId }) {
|
export default function ResourcePane({ tab, setTab, chunk, chapter, chapterIndex, project, updateChunk, updateProject, notify, resources, wordStudy, crossRefs, openWordId, setOpenWordId }) {
|
||||||
const iq = useIqAvailable();
|
const iq = useIqAvailable();
|
||||||
const tabs = RESOURCE_TABS.filter((t) => !t.needsIq || iq);
|
const tabs = RESOURCE_TABS.filter((t) => !t.needsIq || iq);
|
||||||
if (tab === 'aids' && !iq) tab = 'commentary';
|
if (tab === 'aids' && !iq) tab = 'commentary';
|
||||||
@@ -32,7 +34,7 @@ export default function ResourcePane({ tab, setTab, chunk, chapter, chapterIndex
|
|||||||
if (!seeing || chunk.firstDraft) return;
|
if (!seeing || chunk.firstDraft) return;
|
||||||
updateChunk(chunk.id, { firstDraft: { at: Date.now(), observation: chunk.observation ?? '', interpretation: chunk.interpretation ?? '', application: chunk.application ?? '' } }, { touch: false });
|
updateChunk(chunk.id, { firstDraft: { at: Date.now(), observation: chunk.observation ?? '', interpretation: chunk.interpretation ?? '', application: chunk.application ?? '' } }, { touch: false });
|
||||||
}, [seeing, chunk.id, chunk.firstDraft]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [seeing, chunk.id, chunk.firstDraft]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
const counts = { words: chunk.greekWords.length, refs: (chunk.crossReferences ?? []).length };
|
const counts = { words: chunk.greekWords.length, refs: (chunk.crossReferences ?? []).length, readings: (project?.readingList ?? []).filter((i) => !i.done).length };
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-0 flex-1 flex-col">
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
<div role="tablist" aria-label="Study resources" className="flex shrink-0 flex-wrap gap-1.5 border-b border-[var(--r-line)] px-3 py-2.5">
|
<div role="tablist" aria-label="Study resources" className="flex shrink-0 flex-wrap gap-1.5 border-b border-[var(--r-line)] px-3 py-2.5">
|
||||||
@@ -55,6 +57,7 @@ export default function ResourcePane({ tab, setTab, chunk, chapter, chapterIndex
|
|||||||
{tab === 'words' && <WordStudyTab wordStudy={wordStudy} chunk={chunk} openWordId={openWordId} setOpenWordId={setOpenWordId} />}
|
{tab === 'words' && <WordStudyTab wordStudy={wordStudy} chunk={chunk} openWordId={openWordId} setOpenWordId={setOpenWordId} />}
|
||||||
{tab === 'refs' && <CrossRefsTab crossRefs={crossRefs} chunk={chunk} />}
|
{tab === 'refs' && <CrossRefsTab crossRefs={crossRefs} chunk={chunk} />}
|
||||||
{tab === 'people' && <PeopleChunkTab book={chapter.bookAbbrev} chapter={chapter.chapter} chunk={chunk} />}
|
{tab === 'people' && <PeopleChunkTab book={chapter.bookAbbrev} chapter={chapter.chapter} chunk={chunk} />}
|
||||||
|
{tab === 'readings' && updateProject && <ReadingListTab project={project} updateProject={updateProject} />}
|
||||||
{!held && tab === 'aids' && <IqChunkAids chunk={chunk} chapter={chapter} updateChunk={updateChunk} notify={notify} />}
|
{!held && tab === 'aids' && <IqChunkAids chunk={chunk} chapter={chapter} updateChunk={updateChunk} notify={notify} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,10 +2,13 @@
|
|||||||
// interlinear words; click an original word to add it to the chunk's word study.
|
// interlinear words; click an original word to add it to the chunk's word study.
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { NT_BOOK_NUMBER } from '../../utils/bibleUtils.jsx';
|
import { NT_BOOK_NUMBER } from '../../utils/bibleUtils.jsx';
|
||||||
|
import GrammarPanel from '../GrammarPanel.jsx';
|
||||||
|
import { markUnits } from '../../utils/biblicalUnits.js';
|
||||||
|
|
||||||
export default function ScripturePane({ verses, chapter, interlinear, interlinearLoading, addedStrongs, onPickWord, verseNotes = {}, onVerseNote }) {
|
export default function ScripturePane({ verses, chapter, interlinear, interlinearLoading, addedStrongs, onPickWord, verseNotes = {}, onVerseNote }) {
|
||||||
const [openVerse, setOpenVerse] = useState(null);
|
const [openVerse, setOpenVerse] = useState(null);
|
||||||
const [editingNote, setEditingNote] = useState(null);
|
const [editingNote, setEditingNote] = useState(null);
|
||||||
|
const [inspect, setInspect] = useState(null); // the original word whose grammar is showing: { key, index }
|
||||||
const isHebrew = chapter && !NT_BOOK_NUMBER[chapter.bookAbbrev];
|
const isHebrew = chapter && !NT_BOOK_NUMBER[chapter.bookAbbrev];
|
||||||
return (
|
return (
|
||||||
<section aria-label="Scripture" className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] px-5 py-4">
|
<section aria-label="Scripture" className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] px-5 py-4">
|
||||||
@@ -20,7 +23,9 @@ export default function ScripturePane({ verses, chapter, interlinear, interlinea
|
|||||||
className={`mr-1.5 align-super font-sans text-[0.6em] ${words ? 'text-[var(--r-accent)] hover:underline' : 'cursor-default text-[var(--r-sub)]'} ${open ? 'font-bold' : ''}`}>
|
className={`mr-1.5 align-super font-sans text-[0.6em] ${words ? 'text-[var(--r-accent)] hover:underline' : 'cursor-default text-[var(--r-sub)]'} ${open ? 'font-bold' : ''}`}>
|
||||||
{v.chapter !== chapter?.chapter ? `${v.chapter}:` : ''}{v.number}
|
{v.chapter !== chapter?.chapter ? `${v.chapter}:` : ''}{v.number}
|
||||||
</button>
|
</button>
|
||||||
<span>{v.text}</span>
|
<span>{markUnits(v.text).map((part, i) => (part.note
|
||||||
|
? <abbr key={i} title={part.note} tabIndex={0} className="cursor-help underline decoration-dotted decoration-[var(--r-accent)] underline-offset-4">{part.text}</abbr>
|
||||||
|
: <span key={i}>{part.text}</span>))}</span>
|
||||||
{onVerseNote && (
|
{onVerseNote && (
|
||||||
<button type="button" onClick={() => setEditingNote(editingNote === `${v.chapter}:${v.number}` ? null : `${v.chapter}:${v.number}`)}
|
<button type="button" onClick={() => setEditingNote(editingNote === `${v.chapter}:${v.number}` ? null : `${v.chapter}:${v.number}`)}
|
||||||
aria-label={`Note on verse ${v.number}`} title="Note on this verse"
|
aria-label={`Note on verse ${v.number}`} title="Note on this verse"
|
||||||
@@ -38,6 +43,7 @@ export default function ScripturePane({ verses, chapter, interlinear, interlinea
|
|||||||
const added = w.s && addedStrongs.has(w.s.toUpperCase());
|
const added = w.s && addedStrongs.has(w.s.toUpperCase());
|
||||||
return (
|
return (
|
||||||
<button key={i} type="button" disabled={!w.s} onClick={() => onPickWord(w)}
|
<button key={i} type="button" disabled={!w.s} onClick={() => onPickWord(w)}
|
||||||
|
onMouseEnter={() => setInspect({ key: `${v.chapter}:${v.number}`, index: i })} onFocus={() => setInspect({ key: `${v.chapter}:${v.number}`, index: i })}
|
||||||
title={[w.t, w.s, w.p].filter(Boolean).join(' · ') + (w.s ? (added ? ' — already in word study' : ' — click to add') : '')}
|
title={[w.t, w.s, w.p].filter(Boolean).join(' · ') + (w.s ? (added ? ' — already in word study' : ' — click to add') : '')}
|
||||||
className={`flex flex-col items-center rounded-lg border px-2 py-1 text-center ${added ? 'border-[var(--r-accent)]' : 'border-[var(--r-line)] hover:bg-black/5'} disabled:cursor-default`}>
|
className={`flex flex-col items-center rounded-lg border px-2 py-1 text-center ${added ? 'border-[var(--r-accent)]' : 'border-[var(--r-line)] hover:bg-black/5'} disabled:cursor-default`}>
|
||||||
<span className={`text-base ${isHebrew ? 'font-serif' : ''}`}>{w.o}</span>
|
<span className={`text-base ${isHebrew ? 'font-serif' : ''}`}>{w.o}</span>
|
||||||
@@ -45,6 +51,9 @@ export default function ScripturePane({ verses, chapter, interlinear, interlinea
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{inspect?.key === `${v.chapter}:${v.number}` && words[inspect.index]?.p && (
|
||||||
|
<GrammarPanel label={words[inspect.index].p} className="basis-full" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { externalLookupLinks, speakOriginalWord } from '../../utils/wordStudy.js
|
|||||||
import { useIqAvailable } from '../../hooks/useIq.js';
|
import { useIqAvailable } from '../../hooks/useIq.js';
|
||||||
import { normalizeStrongs } from '../../utils/iqbible.js';
|
import { normalizeStrongs } from '../../utils/iqbible.js';
|
||||||
import IqLexicon from '../iq/IqLexicon.jsx';
|
import IqLexicon from '../iq/IqLexicon.jsx';
|
||||||
|
import GrammarPanel from '../GrammarPanel.jsx';
|
||||||
|
import IqDictionary from '../iq/IqDictionary.jsx';
|
||||||
|
|
||||||
const inputCls = 'mt-1 block 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 focus:ring-2 focus:ring-[var(--r-line)]';
|
const inputCls = 'mt-1 block 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 focus:ring-2 focus:ring-[var(--r-line)]';
|
||||||
const btn = 'rounded-lg border border-[var(--r-line)] px-2.5 py-1.5 text-xs hover:bg-black/5 disabled:opacity-50';
|
const btn = 'rounded-lg border border-[var(--r-line)] px-2.5 py-1.5 text-xs hover:bg-black/5 disabled:opacity-50';
|
||||||
@@ -50,6 +52,7 @@ function WordCard({ word, open, onToggle, chunkId, wordStudy }) {
|
|||||||
{field('Part of speech', 'partOfSpeech', 'e.g. Noun')}
|
{field('Part of speech', 'partOfSpeech', 'e.g. Noun')}
|
||||||
{field('English word', 'englishGloss', 'e.g. grace')}
|
{field('English word', 'englishGloss', 'e.g. grace')}
|
||||||
</div>
|
</div>
|
||||||
|
{word.parsing && <GrammarPanel label={word.parsing} />}
|
||||||
{field('Short definition', 'shortDefinition')}
|
{field('Short definition', 'shortDefinition')}
|
||||||
{word.definitionHtml && (
|
{word.definitionHtml && (
|
||||||
<details className="rounded-lg border border-[var(--r-line)] p-2.5">
|
<details className="rounded-lg border border-[var(--r-line)] p-2.5">
|
||||||
@@ -76,8 +79,10 @@ function WordCard({ word, open, onToggle, chunkId, wordStudy }) {
|
|||||||
|
|
||||||
export default function WordStudyTab({ wordStudy, chunk, openWordId, setOpenWordId }) {
|
export default function WordStudyTab({ wordStudy, chunk, openWordId, setOpenWordId }) {
|
||||||
const { suggestingGreekForChunkId, suggestingHebrewForChunkId, suggestGreekWordsForChunk, suggestHebrewWordsForChunk, addGreekWord } = wordStudy;
|
const { suggestingGreekForChunkId, suggestingHebrewForChunkId, suggestGreekWordsForChunk, suggestHebrewWordsForChunk, addGreekWord } = wordStudy;
|
||||||
|
const iq = useIqAvailable(); // the dictionaries come from IQ Bible, so they appear only when the server has a key
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
{iq && <IqDictionary />}
|
||||||
<div className="mb-3 flex flex-wrap gap-2">
|
<div className="mb-3 flex flex-wrap gap-2">
|
||||||
<button type="button" onClick={() => suggestGreekWordsForChunk(chunk.id)} disabled={suggestingGreekForChunkId === chunk.id} className={btn}
|
<button type="button" onClick={() => suggestGreekWordsForChunk(chunk.id)} disabled={suggestingGreekForChunkId === chunk.id} className={btn}
|
||||||
title="Auto-populate Greek words that actually appear in this passage">{suggestingGreekForChunkId === chunk.id ? 'Suggesting…' : 'Suggest Greek'}</button>
|
title="Auto-populate Greek words that actually appear in this passage">{suggestingGreekForChunkId === chunk.id ? 'Suggesting…' : 'Suggest Greek'}</button>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useToast } from '../components/Toasts.jsx';
|
|||||||
import { bookOptions, CHAPTER_COUNTS, makeId, parseBibleChapter } from '../utils/bibleUtils.jsx';
|
import { bookOptions, CHAPTER_COUNTS, makeId, parseBibleChapter } from '../utils/bibleUtils.jsx';
|
||||||
import { fetchStudyChapter } from '../utils/studyText.js';
|
import { fetchStudyChapter } from '../utils/studyText.js';
|
||||||
import { templateStarter } from '../utils/studyTemplates.js';
|
import { templateStarter } from '../utils/studyTemplates.js';
|
||||||
|
import { rememberTemplate } from '../utils/planStudy.js';
|
||||||
|
|
||||||
// Every chunk starts blank; all note fields exist from the start.
|
// Every chunk starts blank; all note fields exist from the start.
|
||||||
export function blankChunk(start, end, spilloverEndVerse = null, templateId = 'blank') {
|
export function blankChunk(start, end, spilloverEndVerse = null, templateId = 'blank') {
|
||||||
@@ -192,7 +193,8 @@ export function useChunkBuilder({ project, setProject, updateProject, allChunks,
|
|||||||
setShowAddChapterForm(false);
|
setShowAddChapterForm(false);
|
||||||
setErrorMessage('');
|
setErrorMessage('');
|
||||||
} else {
|
} else {
|
||||||
// Creating a brand-new project
|
// Creating a brand-new project; remember the template so studies started from a reading plan can use it too
|
||||||
|
rememberTemplate(setup.template);
|
||||||
const newProject = {
|
const newProject = {
|
||||||
id: makeId(),
|
id: makeId(),
|
||||||
title: setup.title,
|
title: setup.title,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// added on your phone appears on your computer. Signing out clears this device's copy.
|
// added on your phone appears on your computer. Signing out clears this device's copy.
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { getRemotePrayers, saveRemotePrayers } from '../syncService.js';
|
import { getRemotePrayers, saveRemotePrayers } from '../syncService.js';
|
||||||
import { markAnswered, markDeleted, mergePrayers, newPrayer, prayersEqual, reopen, touch, togglePrayedToday } from '../utils/prayers.js';
|
import { markAnswered, markDeleted, mergePrayers, movePinned, newPrayer, prayersEqual, reopen, setPinned, setStatus, touch, togglePrayedToday } from '../utils/prayers.js';
|
||||||
import { localDateString } from '../utils/readingPlans.js';
|
import { localDateString } from '../utils/readingPlans.js';
|
||||||
|
|
||||||
const KEY = 'prayers';
|
const KEY = 'prayers';
|
||||||
@@ -42,6 +42,9 @@ export function usePrayers() {
|
|||||||
const togglePrayed = (id) => replace(id, (p) => togglePrayedToday(p, localDateString()));
|
const togglePrayed = (id) => replace(id, (p) => togglePrayedToday(p, localDateString()));
|
||||||
const answerPrayer = (id, note) => replace(id, (p) => markAnswered(p, note));
|
const answerPrayer = (id, note) => replace(id, (p) => markAnswered(p, note));
|
||||||
const reopenPrayer = (id) => replace(id, (p) => reopen(p));
|
const reopenPrayer = (id) => replace(id, (p) => reopen(p));
|
||||||
|
const pinPrayer = (id, on) => set(setPinned(ref.current, id, on));
|
||||||
|
const movePrayer = (id, dir) => set(movePinned(ref.current, id, dir));
|
||||||
|
const setPrayerStatus = (id, status, note) => replace(id, (p) => setStatus(p, status, note));
|
||||||
// Deleting leaves a marker so the other devices drop it too; the returned function puts it back
|
// Deleting leaves a marker so the other devices drop it too; the returned function puts it back
|
||||||
const deletePrayer = (id) => {
|
const deletePrayer = (id) => {
|
||||||
const before = ref.current.find((p) => p.id === id);
|
const before = ref.current.find((p) => p.id === id);
|
||||||
@@ -70,5 +73,5 @@ export function usePrayers() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => () => window.clearTimeout(timerRef.current), []);
|
useEffect(() => () => window.clearTimeout(timerRef.current), []);
|
||||||
return { prayers, addPrayer, editPrayer, togglePrayed, answerPrayer, reopenPrayer, deletePrayer, importPrayers, pullPrayers: pull, stopPrayers: stop };
|
return { prayers, addPrayer, editPrayer, togglePrayed, answerPrayer, reopenPrayer, pinPrayer, movePrayer, setPrayerStatus, deletePrayer, importPrayers, pullPrayers: pull, stopPrayers: stop };
|
||||||
}
|
}
|
||||||
@@ -200,17 +200,16 @@ export function useProjectLibrary({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Puts a study in a series ("Romans", "Advent 2026"), or takes it out (an empty name).
|
// Changes one top-level field of a stored study (from Home, without opening it) and syncs it.
|
||||||
const setProjectSeries = (id, series) => {
|
const patchStoredStudy = (id, patch) => {
|
||||||
const loaded = loadProjectById(id);
|
const loaded = loadProjectById(id);
|
||||||
if (!loaded) return;
|
if (!loaded) return;
|
||||||
const name = String(series ?? '').trim().slice(0, 80);
|
if (Object.entries(patch).every(([k, v]) => (loaded[k] ?? (typeof v === 'boolean' ? false : '')) === v)) return;
|
||||||
if ((loaded.series ?? '') === name) return;
|
const updated = { ...loaded, ...patch, lastEdited: Date.now() };
|
||||||
const updated = { ...loaded, series: name, lastEdited: Date.now() };
|
|
||||||
saveProjectToStorage(updated);
|
saveProjectToStorage(updated);
|
||||||
unsyncedStudies.add(id);
|
unsyncedStudies.add(id);
|
||||||
setProjectIndex(loadProjectIndex());
|
setProjectIndex(loadProjectIndex());
|
||||||
if (project?.id === id) setProject((current) => (current ? { ...current, series: name } : current));
|
if (project?.id === id) setProject((current) => (current ? { ...current, ...patch } : current));
|
||||||
saveRemoteProject(updated, { baseEdited: getSyncBase(id) }).then((result) => {
|
saveRemoteProject(updated, { baseEdited: getSyncBase(id) }).then((result) => {
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
const serverEdited = result.data?.lastEdited ?? updated.lastEdited;
|
const serverEdited = result.data?.lastEdited ?? updated.lastEdited;
|
||||||
@@ -221,8 +220,14 @@ export function useProjectLibrary({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Saves a weekly recall (what you remembered, and how it went) on one chunk of a stored study.
|
// Puts a study in a series ("Romans", "Advent 2026"), or takes it out (an empty name).
|
||||||
const saveChunkRecall = (id, chunkId, recall) => {
|
const setProjectSeries = (id, series) => patchStoredStudy(id, { series: String(series ?? '').trim().slice(0, 80) });
|
||||||
|
|
||||||
|
// Moves a study to (or back from) the "Finished & archived" section on Home.
|
||||||
|
const setProjectArchived = (id, archived) => patchStoredStudy(id, { archived: !!archived });
|
||||||
|
|
||||||
|
// 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);
|
const loaded = loadProjectById(id);
|
||||||
if (!loaded) return;
|
if (!loaded) return;
|
||||||
const updated = {
|
const updated = {
|
||||||
@@ -230,7 +235,7 @@ export function useProjectLibrary({
|
|||||||
lastEdited: Date.now(),
|
lastEdited: Date.now(),
|
||||||
chapters: loaded.chapters.map((ch) => ({
|
chapters: loaded.chapters.map((ch) => ({
|
||||||
...ch,
|
...ch,
|
||||||
chunks: ch.chunks.map((c) => (c.id === chunkId ? { ...c, recalls: [...(c.recalls ?? []), recall] } : c)),
|
chunks: ch.chunks.map((c) => (c.id === chunkId ? change(c) : c)),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
saveProjectToStorage(updated);
|
saveProjectToStorage(updated);
|
||||||
@@ -247,6 +252,11 @@ export function useProjectLibrary({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// A weekly recall (what you remembered, and how it went) on one chunk.
|
||||||
|
const saveChunkRecall = (id, chunkId, recall) => updateStoredChunk(id, chunkId, (c) => ({ ...c, recalls: [...(c.recalls ?? []), recall] }));
|
||||||
|
// Notes written from Home (the daily five minutes): counts as editing the chunk.
|
||||||
|
const saveChunkPatch = (id, chunkId, patch) => updateStoredChunk(id, chunkId, (c) => ({ ...c, ...patch, editedAt: Date.now() }));
|
||||||
|
|
||||||
// Deleting is immediate here, with an Undo for a few seconds; only after that is the server copy
|
// Deleting is immediate here, with an Undo for a few seconds; only after that is the server copy
|
||||||
// removed (and straight away if the tab is closed first, so a deleted project never comes back).
|
// removed (and straight away if the tab is closed first, so a deleted project never comes back).
|
||||||
const finishDelete = (id) => {
|
const finishDelete = (id) => {
|
||||||
@@ -313,7 +323,9 @@ export function useProjectLibrary({
|
|||||||
openStudyChunk,
|
openStudyChunk,
|
||||||
renameProjectInStorage,
|
renameProjectInStorage,
|
||||||
setProjectSeries,
|
setProjectSeries,
|
||||||
|
setProjectArchived,
|
||||||
saveChunkRecall,
|
saveChunkRecall,
|
||||||
|
saveChunkPatch,
|
||||||
deleteProject,
|
deleteProject,
|
||||||
pullLatestFromServer,
|
pullLatestFromServer,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -105,3 +105,17 @@ test('studies only on the server are pulled down', async () => {
|
|||||||
setup();
|
setup();
|
||||||
await waitFor(() => expect(loadProjectById('far')?.title).toBe('From elsewhere'));
|
await waitFor(() => expect(loadProjectById('far')?.title).toBe('From elsewhere'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('archiving a study keeps it, flags it in the index, syncs it, and restoring clears the flag', async () => {
|
||||||
|
saveProjectToStorage(study('s1'));
|
||||||
|
const hook = setup();
|
||||||
|
act(() => hook.result.current.setProjectArchived('s1', true));
|
||||||
|
expect(loadProjectById('s1').archived).toBe(true);
|
||||||
|
expect(loadProjectIndex().find((e) => e.id === 's1').archived).toBe(true);
|
||||||
|
await waitFor(() => expect(sync.saveRemoteProject).toHaveBeenCalledWith(expect.objectContaining({ id: 's1', archived: true }), expect.anything()));
|
||||||
|
sync.saveRemoteProject.mockClear();
|
||||||
|
act(() => hook.result.current.setProjectArchived('s1', true)); // already archived: nothing to do
|
||||||
|
expect(sync.saveRemoteProject).not.toHaveBeenCalled();
|
||||||
|
act(() => hook.result.current.setProjectArchived('s1', false));
|
||||||
|
expect(loadProjectIndex().find((e) => e.id === 's1').archived).toBe(false);
|
||||||
|
});
|
||||||
@@ -94,6 +94,7 @@ export function useWordStudy({ project, allChunks, updateProject, notify }) {
|
|||||||
lexeme: w.o || entry?.lemma || '',
|
lexeme: w.o || entry?.lemma || '',
|
||||||
transliteration: w.t || entry?.translit || entry?.xlit || '',
|
transliteration: w.t || entry?.translit || entry?.xlit || '',
|
||||||
partOfSpeech: '',
|
partOfSpeech: '',
|
||||||
|
parsing: w.p || '', // the grammar label from the interlinear, explained in the word's card
|
||||||
shortDefinition: entry?.kjv_def || w.g || '',
|
shortDefinition: entry?.kjv_def || w.g || '',
|
||||||
definitionHtml: entry ? buildGreekDefinitionHtml(key, entry) : '',
|
definitionHtml: entry ? buildGreekDefinitionHtml(key, entry) : '',
|
||||||
loading: false,
|
loading: false,
|
||||||
|
|||||||
+126
-80
@@ -2,6 +2,8 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import { useApp } from '../context/AppContext.js';
|
import { useApp } from '../context/AppContext.js';
|
||||||
import { filterStudies, groupBySeries, isTagQuery, seriesList, tagCounts } from '../utils/studyFilter.js';
|
import { filterStudies, groupBySeries, isTagQuery, seriesList, tagCounts } from '../utils/studyFilter.js';
|
||||||
import BookCover from '../components/BookCover.jsx';
|
import BookCover from '../components/BookCover.jsx';
|
||||||
|
import DailyFive from '../components/DailyFive.jsx';
|
||||||
|
import PinnedPrayers from '../components/PinnedPrayers.jsx';
|
||||||
import WeeklyRecall from '../components/WeeklyRecall.jsx';
|
import WeeklyRecall from '../components/WeeklyRecall.jsx';
|
||||||
import OfflineBadge from '../components/OfflineBadge.jsx';
|
import OfflineBadge from '../components/OfflineBadge.jsx';
|
||||||
import { useOfflineSave } from '../hooks/useOfflineSave.js';
|
import { useOfflineSave } from '../hooks/useOfflineSave.js';
|
||||||
@@ -48,6 +50,7 @@ export default function HomePage() {
|
|||||||
resumeProject,
|
resumeProject,
|
||||||
renameProjectInStorage,
|
renameProjectInStorage,
|
||||||
setProjectSeries,
|
setProjectSeries,
|
||||||
|
setProjectArchived,
|
||||||
deleteProject,
|
deleteProject,
|
||||||
handlePlayBookAudio,
|
handlePlayBookAudio,
|
||||||
handleStopBookAudio,
|
handleStopBookAudio,
|
||||||
@@ -66,6 +69,7 @@ export default function HomePage() {
|
|||||||
}); // 'recent' | 'title' | 'passage'
|
}); // 'recent' | 'title' | 'passage'
|
||||||
const [homeTags, setHomeTags] = useState([]); // tag chips switched on: a study must have all of them
|
const [homeTags, setHomeTags] = useState([]); // tag chips switched on: a study must have all of them
|
||||||
const [showAllTags, setShowAllTags] = useState(false);
|
const [showAllTags, setShowAllTags] = useState(false);
|
||||||
|
const [showArchived, setShowArchived] = useState(false); // the "Finished & archived" section is folded away until opened
|
||||||
const [seriesFilter, setSeriesFilter] = useState(''); // '' = all, '__none' = not in a series, or a series name
|
const [seriesFilter, setSeriesFilter] = useState(''); // '' = all, '__none' = not in a series, or a series name
|
||||||
const [seriesEditingId, setSeriesEditingId] = useState(null);
|
const [seriesEditingId, setSeriesEditingId] = useState(null);
|
||||||
const [seriesValue, setSeriesValue] = useState('');
|
const [seriesValue, setSeriesValue] = useState('');
|
||||||
@@ -117,25 +121,95 @@ export default function HomePage() {
|
|||||||
}, 300);
|
}, 300);
|
||||||
}, [homeSearch]);
|
}, [homeSearch]);
|
||||||
|
|
||||||
const tagList = tagCounts(projectIndex);
|
// Finished studies are set aside in their own section; the tag and series filters follow the active ones
|
||||||
|
const activeIndex = projectIndex.filter((e) => !e.archived);
|
||||||
|
const tagList = tagCounts(activeIndex);
|
||||||
const shownTags = showAllTags ? tagList : tagList.slice(0, 12);
|
const shownTags = showAllTags ? tagList : tagList.slice(0, 12);
|
||||||
const seriesNames = seriesList(projectIndex);
|
const seriesNames = seriesList(activeIndex);
|
||||||
const visible = filterStudies(projectIndex, { query: homeSearch, tags: homeTags, series: seriesFilter })
|
const byHomeSort = (a, b) => {
|
||||||
.sort((a, b) => {
|
if (homeSort === 'title') return (a.title ?? '').localeCompare(b.title ?? '');
|
||||||
if (homeSort === 'title') return (a.title ?? '').localeCompare(b.title ?? '');
|
if (homeSort === 'passage') return (a.chapterSummary ?? '').localeCompare(b.chapterSummary ?? '');
|
||||||
if (homeSort === 'passage') return (a.chapterSummary ?? '').localeCompare(b.chapterSummary ?? '');
|
return (b.lastEdited ?? 0) - (a.lastEdited ?? 0);
|
||||||
return (b.lastEdited ?? 0) - (a.lastEdited ?? 0);
|
};
|
||||||
});
|
const visible = filterStudies(projectIndex, { query: homeSearch, tags: homeTags, series: seriesFilter }).sort(byHomeSort);
|
||||||
|
const archivedStudies = filterStudies(projectIndex, { query: homeSearch, tags: homeTags, archived: true }).sort(byHomeSort);
|
||||||
|
const archivedCount = projectIndex.length - activeIndex.length;
|
||||||
|
const filtering = !!homeSearch.trim() || homeTags.length > 0 || seriesFilter !== '';
|
||||||
|
const archivedOpen = showArchived || ((!!homeSearch.trim() || homeTags.length > 0) && archivedStudies.length > 0);
|
||||||
// With series in use (and no single series chosen) the studies are grouped under their series name
|
// With series in use (and no single series chosen) the studies are grouped under their series name
|
||||||
const sections = seriesFilter === '' && seriesNames.length > 0 ? groupBySeries(visible) : [{ name: '', items: visible, flat: true }];
|
const sections = seriesFilter === '' && seriesNames.length > 0 ? groupBySeries(visible) : [{ name: '', items: visible, flat: true }];
|
||||||
const fieldCls = 'rounded-lg border border-[var(--r-line)] bg-[var(--r-bar)] px-2.5 py-1.5 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]';
|
const fieldCls = 'rounded-lg border border-[var(--r-line)] bg-[var(--r-bar)] px-2.5 py-1.5 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]';
|
||||||
const linkBtn = 'rounded-lg px-3 py-1.5 text-sm text-[var(--r-sub)] hover:bg-black/5';
|
const linkBtn = 'rounded-lg px-3 py-1.5 text-sm text-[var(--r-sub)] hover:bg-black/5';
|
||||||
const notice = 'mb-4 rounded-xl border border-[var(--r-line)] bg-[var(--r-bar)] px-4 py-3 text-sm';
|
const notice = 'mb-4 rounded-xl border border-[var(--r-line)] bg-[var(--r-bar)] px-4 py-3 text-sm';
|
||||||
|
|
||||||
|
const renderEntry = (entry) => (
|
||||||
|
<div key={entry.id} className={homeView === 'list'
|
||||||
|
? 'flex flex-col gap-3 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-3 sm:flex-row sm:items-center sm:gap-4'
|
||||||
|
: 'flex flex-col gap-3 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5'}>
|
||||||
|
{homeView === 'list'
|
||||||
|
? <BookCover summary={entry.chapterSummary} compact className="h-14 w-full sm:w-28 sm:shrink-0" />
|
||||||
|
: <BookCover summary={entry.chapterSummary} className="h-24" />}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{renamingId === entry.id ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input type="text" autoFocus value={renameValue} onChange={(e) => setRenameValue(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') { e.preventDefault(); const t = renameValue.trim(); if (t) renameProjectInStorage(entry.id, t); setRenamingId(null); }
|
||||||
|
else if (e.key === 'Escape') setRenamingId(null);
|
||||||
|
}}
|
||||||
|
className={`${fieldCls} flex-1`} />
|
||||||
|
<button type="button" onClick={() => { const t = renameValue.trim(); if (t) renameProjectInStorage(entry.id, t); setRenamingId(null); }} className="text-sm text-[var(--r-accent)] hover:underline">Save</button>
|
||||||
|
<button type="button" onClick={() => setRenamingId(null)} className="text-sm text-[var(--r-sub)]">×</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={`flex items-start gap-2 ${homeView === 'list' ? 'flex-wrap items-baseline' : 'justify-between'}`}>
|
||||||
|
<h2 className="text-base font-medium">{entry.title}</h2>
|
||||||
|
{sharedStudies?.[entry.id] !== undefined && (
|
||||||
|
<span className="rounded-full border border-[var(--r-line)] px-2 py-0.5 text-[11px] text-[var(--r-sub)]" title="Another account invited you to edit this study">Shared{sharedStudies[entry.id] ? ` by ${sharedStudies[entry.id]}` : ''}</span>
|
||||||
|
)}
|
||||||
|
<button type="button" onClick={() => { setRenamingId(entry.id); setRenameValue(entry.title ?? ''); }} className="shrink-0 text-xs text-[var(--r-sub)] hover:text-[var(--r-text)]" title="Rename project">✎ Rename</button>
|
||||||
|
<button type="button" onClick={() => { setSeriesEditingId(entry.id); setSeriesValue(entry.series ?? ''); }} className="shrink-0 text-xs text-[var(--r-sub)] hover:text-[var(--r-text)]" title="Put this study in a series">{entry.series ? '⧉ Series' : '⧉ Add to series'}</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{seriesEditingId === entry.id && (
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<input type="text" autoFocus list="series-names" aria-label="Series name" placeholder="Series name (blank to remove)" value={seriesValue} onChange={(e) => setSeriesValue(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); setProjectSeries(entry.id, seriesValue); setSeriesEditingId(null); } else if (e.key === 'Escape') setSeriesEditingId(null); }}
|
||||||
|
className={`${fieldCls} flex-1`} />
|
||||||
|
<button type="button" onClick={() => { setProjectSeries(entry.id, seriesValue); setSeriesEditingId(null); }} className="text-sm text-[var(--r-accent)] hover:underline">Save</button>
|
||||||
|
<button type="button" onClick={() => setSeriesEditingId(null)} aria-label="Cancel" className="text-sm text-[var(--r-sub)]">×</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{entry.chapterSummary && <p className="mt-1 text-sm text-[var(--r-sub)]" title={entry.chapterSummary}>{compactPassages(entry.chapterSummary)}</p>}
|
||||||
|
{entry.tags?.length > 0 && (
|
||||||
|
// A few tags and a "+N" for the rest (hover for all; the Tag filter above finds any of them)
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1" title={entry.tags.length > 3 ? entry.tags.join(', ') : undefined}>
|
||||||
|
{entry.tags.slice(0, 3).map((tag) => (
|
||||||
|
<button key={tag} type="button" onClick={() => toggleTag(tag)} aria-pressed={homeTags.includes(tag)} title={`Show studies tagged “${tag}”`}
|
||||||
|
className={`rounded-full border px-2 py-0.5 text-[11px] ${homeTags.includes(tag) ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>{tag}</button>
|
||||||
|
))}
|
||||||
|
{entry.tags.length > 3 && <span className="rounded-full px-1.5 py-0.5 text-[11px] text-[var(--r-sub)]">+{entry.tags.length - 3} more</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="mt-1 text-xs text-[var(--r-sub)]">{formatRelativeDate(entry.lastEdited)}</p>
|
||||||
|
<OfflineBadge project={studyBooks[entry.id]} offline={offline} />
|
||||||
|
</div>
|
||||||
|
<div className={`flex items-center gap-2 ${homeView === 'list' ? 'sm:w-56 sm:shrink-0' : ''}`}>
|
||||||
|
<button type="button" onClick={() => resumeProject(entry.id)} className="flex-1 rounded-xl bg-[var(--r-text)] px-4 py-2 text-sm font-medium text-[var(--r-bg)] hover:opacity-90">Resume</button>
|
||||||
|
{sharedStudies?.[entry.id] === undefined && (
|
||||||
|
<button type="button" onClick={() => setProjectArchived(entry.id, !entry.archived)} title={entry.archived ? 'Move back to your studies' : 'Set this study aside as finished'}
|
||||||
|
className="rounded-xl border border-[var(--r-line)] px-3 py-2 text-sm text-[var(--r-sub)] hover:bg-black/5">{entry.archived ? 'Restore' : 'Archive'}</button>
|
||||||
|
)}
|
||||||
|
<button type="button" onClick={() => deleteProject(entry.id)} className="rounded-xl border border-[var(--r-line)] px-4 py-2 text-sm text-[var(--r-danger,#b42318)] hover:bg-black/5">{sharedStudies?.[entry.id] !== undefined ? 'Leave' : 'Delete'}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell
|
<PageShell
|
||||||
title="My Studies"
|
title="My Studies"
|
||||||
active="studies"
|
active="studies"
|
||||||
|
maxWidth="max-w-6xl xl:max-w-[88rem]"
|
||||||
actions={(
|
actions={(
|
||||||
<>
|
<>
|
||||||
<button type="button" onClick={() => setCurrentPage('insights')} className={linkBtn} title="This week, themes, words, what slipped, follow-ups">Insights</button>
|
<button type="button" onClick={() => setCurrentPage('insights')} className={linkBtn} title="This week, themes, words, what slipped, follow-ups">Insights</button>
|
||||||
@@ -144,20 +218,15 @@ export default function HomePage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<main className="mx-auto grid max-w-6xl gap-6 px-4 py-6 lg:grid-cols-[minmax(0,1fr)_21rem]">
|
<main className="mx-auto grid max-w-6xl gap-y-6 px-4 py-6 lg:grid-cols-[minmax(0,1fr)_21rem] xl:max-w-[88rem] xl:grid-cols-[auto_minmax(0,1fr)_21rem]">
|
||||||
<section className="min-w-0">
|
{/* Left column: the recall and daily-verse cards (collapses when neither has anything to show) */}
|
||||||
{projectIndex.length > 0 && (() => {
|
<aside className="empty:hidden lg:col-span-2 xl:sticky xl:top-16 xl:col-span-1 xl:max-h-[calc(100vh-5rem)] xl:w-80 xl:self-start xl:overflow-y-auto xl:pr-6">
|
||||||
const age = backupAge(lastBackupAt());
|
|
||||||
return (
|
|
||||||
<p className={`mb-3 flex flex-wrap items-center gap-2 px-1 text-xs ${age.stale ? 'text-[var(--r-danger,#b42318)]' : 'text-[var(--r-sub)]'}`}>
|
|
||||||
{age.text}.
|
|
||||||
<button type="button" onClick={openBackup} className="text-[var(--r-accent)] hover:underline">Back up now</button>
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
<WeeklyRecall />
|
<WeeklyRecall />
|
||||||
<RecallHistory />
|
|
||||||
<IqDailyCard />
|
<IqDailyCard />
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section className="min-w-0 lg:pr-6">
|
||||||
|
<DailyFive />
|
||||||
{autoRestoredCount !== null && (
|
{autoRestoredCount !== null && (
|
||||||
<div className={notice}>Synced {autoRestoredCount} project{autoRestoredCount > 1 ? 's' : ''} from another device.</div>
|
<div className={notice}>Synced {autoRestoredCount} project{autoRestoredCount > 1 ? 's' : ''} from another device.</div>
|
||||||
)}
|
)}
|
||||||
@@ -234,7 +303,7 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{visible.length === 0 && (
|
{visible.length === 0 && archivedStudies.length === 0 && (
|
||||||
<p role="status" className="mb-4 rounded-xl border border-dashed border-[var(--r-line)] px-4 py-6 text-center text-sm text-[var(--r-sub)]">
|
<p role="status" className="mb-4 rounded-xl border border-dashed border-[var(--r-line)] px-4 py-6 text-center text-sm text-[var(--r-sub)]">
|
||||||
No studies match{homeTags.length ? ` ${homeTags.map((t) => `“${t}”`).join(' + ')}` : ''}{homeSearch.trim() ? ` “${homeSearch.trim()}”` : ''}.{' '}
|
No studies match{homeTags.length ? ` ${homeTags.map((t) => `“${t}”`).join(' + ')}` : ''}{homeSearch.trim() ? ` “${homeSearch.trim()}”` : ''}.{' '}
|
||||||
<button type="button" onClick={() => { setHomeTags([]); setHomeSearch(''); }} className="text-[var(--r-accent)] hover:underline">Clear filters</button>
|
<button type="button" onClick={() => { setHomeTags([]); setHomeSearch(''); }} className="text-[var(--r-accent)] hover:underline">Clear filters</button>
|
||||||
@@ -275,75 +344,42 @@ export default function HomePage() {
|
|||||||
</h3>
|
</h3>
|
||||||
)}
|
)}
|
||||||
<div className={homeView === 'list' ? 'flex flex-col gap-2' : 'grid gap-3 sm:grid-cols-2'}>
|
<div className={homeView === 'list' ? 'flex flex-col gap-2' : 'grid gap-3 sm:grid-cols-2'}>
|
||||||
{section.items.map((entry) => (
|
{section.items.map(renderEntry)}
|
||||||
<div key={entry.id} className={homeView === 'list'
|
|
||||||
? 'flex flex-col gap-3 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-3 sm:flex-row sm:items-center sm:gap-4'
|
|
||||||
: 'flex flex-col gap-3 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5'}>
|
|
||||||
{homeView === 'list'
|
|
||||||
? <BookCover summary={entry.chapterSummary} compact className="h-14 w-full sm:w-28 sm:shrink-0" />
|
|
||||||
: <BookCover summary={entry.chapterSummary} className="h-24" />}
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
{renamingId === entry.id ? (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input type="text" autoFocus value={renameValue} onChange={(e) => setRenameValue(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') { e.preventDefault(); const t = renameValue.trim(); if (t) renameProjectInStorage(entry.id, t); setRenamingId(null); }
|
|
||||||
else if (e.key === 'Escape') setRenamingId(null);
|
|
||||||
}}
|
|
||||||
className={`${fieldCls} flex-1`} />
|
|
||||||
<button type="button" onClick={() => { const t = renameValue.trim(); if (t) renameProjectInStorage(entry.id, t); setRenamingId(null); }} className="text-sm text-[var(--r-accent)] hover:underline">Save</button>
|
|
||||||
<button type="button" onClick={() => setRenamingId(null)} className="text-sm text-[var(--r-sub)]">×</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className={`flex items-start gap-2 ${homeView === 'list' ? 'flex-wrap items-baseline' : 'justify-between'}`}>
|
|
||||||
<h2 className="text-base font-medium">{entry.title}</h2>
|
|
||||||
{sharedStudies?.[entry.id] !== undefined && (
|
|
||||||
<span className="rounded-full border border-[var(--r-line)] px-2 py-0.5 text-[11px] text-[var(--r-sub)]" title="Another account invited you to edit this study">Shared{sharedStudies[entry.id] ? ` by ${sharedStudies[entry.id]}` : ''}</span>
|
|
||||||
)}
|
|
||||||
<button type="button" onClick={() => { setRenamingId(entry.id); setRenameValue(entry.title ?? ''); }} className="shrink-0 text-xs text-[var(--r-sub)] hover:text-[var(--r-text)]" title="Rename project">✎ Rename</button>
|
|
||||||
<button type="button" onClick={() => { setSeriesEditingId(entry.id); setSeriesValue(entry.series ?? ''); }} className="shrink-0 text-xs text-[var(--r-sub)] hover:text-[var(--r-text)]" title="Put this study in a series">{entry.series ? '⧉ Series' : '⧉ Add to series'}</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{seriesEditingId === entry.id && (
|
|
||||||
<div className="mt-2 flex items-center gap-2">
|
|
||||||
<input type="text" autoFocus list="series-names" aria-label="Series name" placeholder="Series name (blank to remove)" value={seriesValue} onChange={(e) => setSeriesValue(e.target.value)}
|
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); setProjectSeries(entry.id, seriesValue); setSeriesEditingId(null); } else if (e.key === 'Escape') setSeriesEditingId(null); }}
|
|
||||||
className={`${fieldCls} flex-1`} />
|
|
||||||
<button type="button" onClick={() => { setProjectSeries(entry.id, seriesValue); setSeriesEditingId(null); }} className="text-sm text-[var(--r-accent)] hover:underline">Save</button>
|
|
||||||
<button type="button" onClick={() => setSeriesEditingId(null)} aria-label="Cancel" className="text-sm text-[var(--r-sub)]">×</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{entry.chapterSummary && <p className="mt-1 text-sm text-[var(--r-sub)]" title={entry.chapterSummary}>{compactPassages(entry.chapterSummary)}</p>}
|
|
||||||
{entry.tags?.length > 0 && (
|
|
||||||
// A few tags and a "+N" for the rest (hover for all; the Tag filter above finds any of them)
|
|
||||||
<div className="mt-2 flex flex-wrap gap-1" title={entry.tags.length > 3 ? entry.tags.join(', ') : undefined}>
|
|
||||||
{entry.tags.slice(0, 3).map((tag) => (
|
|
||||||
<button key={tag} type="button" onClick={() => toggleTag(tag)} aria-pressed={homeTags.includes(tag)} title={`Show studies tagged “${tag}”`}
|
|
||||||
className={`rounded-full border px-2 py-0.5 text-[11px] ${homeTags.includes(tag) ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>{tag}</button>
|
|
||||||
))}
|
|
||||||
{entry.tags.length > 3 && <span className="rounded-full px-1.5 py-0.5 text-[11px] text-[var(--r-sub)]">+{entry.tags.length - 3} more</span>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<p className="mt-1 text-xs text-[var(--r-sub)]">{formatRelativeDate(entry.lastEdited)}</p>
|
|
||||||
<OfflineBadge project={studyBooks[entry.id]} offline={offline} />
|
|
||||||
</div>
|
|
||||||
<div className={`flex items-center gap-2 ${homeView === 'list' ? 'sm:w-56 sm:shrink-0' : ''}`}>
|
|
||||||
<button type="button" onClick={() => resumeProject(entry.id)} className="flex-1 rounded-xl bg-[var(--r-text)] px-4 py-2 text-sm font-medium text-[var(--r-bg)] hover:opacity-90">Resume</button>
|
|
||||||
<button type="button" onClick={() => deleteProject(entry.id)} className="rounded-xl border border-[var(--r-line)] px-4 py-2 text-sm text-[var(--r-danger,#b42318)] hover:bg-black/5">{sharedStudies?.[entry.id] !== undefined ? 'Leave' : 'Delete'}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{visible.length === 0 && archivedStudies.length > 0 && !filtering && (
|
||||||
|
<p role="status" className="mb-4 rounded-xl border border-dashed border-[var(--r-line)] px-4 py-6 text-center text-sm text-[var(--r-sub)]">Everything is finished or archived. Open the section below to bring a study back.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{archivedCount > 0 && (
|
||||||
|
<div className="mt-6 border-t border-[var(--r-line)] pt-4">
|
||||||
|
<button type="button" onClick={() => setShowArchived((o) => !o)} aria-expanded={archivedOpen} aria-controls="archived-studies"
|
||||||
|
className="flex w-full items-center gap-2 text-left text-sm font-medium">
|
||||||
|
<span aria-hidden="true" className="text-xs text-[var(--r-sub)]">{archivedOpen ? '▾' : '▸'}</span>
|
||||||
|
Finished & archived <span className="text-xs font-normal text-[var(--r-sub)]">{filtering && archivedStudies.length !== archivedCount ? `${archivedStudies.length} of ${archivedCount}` : archivedCount}</span>
|
||||||
|
</button>
|
||||||
|
{archivedOpen && (
|
||||||
|
<div id="archived-studies" className={`mt-3 ${homeView === 'list' ? 'flex flex-col gap-2' : 'grid gap-3 sm:grid-cols-2'}`}>
|
||||||
|
{archivedStudies.length === 0
|
||||||
|
? <p className="text-sm text-[var(--r-sub)]">No archived studies match.</p>
|
||||||
|
: archivedStudies.map(renderEntry)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<aside className="space-y-4">
|
<aside className="space-y-4 lg:sticky lg:top-16 lg:max-h-[calc(100vh-5rem)] lg:self-start lg:overflow-y-auto">
|
||||||
<TodayCard planProgress={planProgress} readingActivity={readingActivity} openReadingPlans={openReadingPlans}
|
<TodayCard planProgress={planProgress} readingActivity={readingActivity} openReadingPlans={openReadingPlans}
|
||||||
openPlanReading={openPlanReading} studyReadingPlanDay={studyReadingPlanDay} studyBuildBusy={studyBuildBusy} />
|
openPlanReading={openPlanReading} studyReadingPlanDay={studyReadingPlanDay} studyBuildBusy={studyBuildBusy} />
|
||||||
|
|
||||||
|
<PinnedPrayers />
|
||||||
|
<RecallHistory />
|
||||||
|
|
||||||
{/* BSB audio player — always shown, so it's easy to find */}
|
{/* BSB audio player — always shown, so it's easy to find */}
|
||||||
<AudioPlayer
|
<AudioPlayer
|
||||||
title="Listen to BSB Audio"
|
title="Listen to BSB Audio"
|
||||||
@@ -376,6 +412,16 @@ export default function HomePage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{projectIndex.length > 0 && (() => {
|
||||||
|
const age = backupAge(lastBackupAt());
|
||||||
|
return (
|
||||||
|
<p className={`flex flex-wrap items-center gap-2 px-1 text-xs ${age.stale ? 'text-[var(--r-danger,#b42318)]' : 'text-[var(--r-sub)]'}`}>
|
||||||
|
{age.text}.
|
||||||
|
<button type="button" onClick={openBackup} className="text-[var(--r-accent)] hover:underline">Back up now</button>
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</aside>
|
</aside>
|
||||||
</main>
|
</main>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export default function InsightsPage() {
|
|||||||
<Stat label="Your own words" value={week.ownShare === null ? '–' : `${week.ownShare}%`} />
|
<Stat label="Your own words" value={week.ownShare === null ? '–' : `${week.ownShare}%`} />
|
||||||
<Stat label="Recalls done" value={week.recalls} />
|
<Stat label="Recalls done" value={week.recalls} />
|
||||||
</div>
|
</div>
|
||||||
{week.teachBacks > 0 && <p className="mt-2 text-xs text-[var(--r-sub)]">{week.teachBacks} teach-it-back summar{week.teachBacks === 1 ? 'y' : 'ies'} written.</p>}
|
{(week.teachBacks > 0 || week.fiveMinutes > 0) && <p className="mt-2 text-xs text-[var(--r-sub)]">{[week.fiveMinutes > 0 && `${week.fiveMinutes} five-minute session${week.fiveMinutes === 1 ? '' : 's'}`, week.teachBacks > 0 && `${week.teachBacks} teach-it-back summar${week.teachBacks === 1 ? 'y' : 'ies'}`].filter(Boolean).join(' · ')} this week.</p>}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section aria-label="Open follow-ups" className={card}>
|
<section aria-label="Open follow-ups" className={card}>
|
||||||
|
|||||||
+68
-13
@@ -1,10 +1,10 @@
|
|||||||
// The prayer journal: what you are praying about (and for whom), the verses tied to each request, the days you
|
// The prayer journal: what you are praying about (and for whom), the verses tied to each request, the days you
|
||||||
// prayed, and the answers when they come.
|
// prayed, and the answers when they come.
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useApp } from '../context/AppContext.js';
|
import { useApp } from '../context/AppContext.js';
|
||||||
import PageShell from '../components/study/PageShell.jsx';
|
import PageShell from '../components/study/PageShell.jsx';
|
||||||
import { useToast } from '../components/Toasts.jsx';
|
import { useToast } from '../components/Toasts.jsx';
|
||||||
import { filterPrayers, daysToAnswer, labelFor, live, parseVerses, prayersToMarkdown } from '../utils/prayers.js';
|
import { STATUSES, filterPrayers, daysToAnswer, labelFor, live, parsePrayerImport, parseVerses, pinned as pinnedList, prayersToJson, prayersToMarkdown } from '../utils/prayers.js';
|
||||||
import { localDateString } from '../utils/readingPlans.js';
|
import { localDateString } from '../utils/readingPlans.js';
|
||||||
|
|
||||||
const field = 'rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]';
|
const field = 'rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]';
|
||||||
@@ -23,11 +23,12 @@ function VerseChips({ verses, onOpen }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PrayerCard({ prayer, today, actions, onOpenVerse }) {
|
function PrayerCard({ prayer, today, actions, onOpenVerse, pinPos }) {
|
||||||
const [mode, setMode] = useState(null); // null | 'edit' | 'answer'
|
const [mode, setMode] = useState(null); // null | 'edit' | 'answer' | 'partial'
|
||||||
const [text, setText] = useState('');
|
const [text, setText] = useState('');
|
||||||
const [note, setNote] = useState('');
|
const [note, setNote] = useState('');
|
||||||
const prayedToday = prayer.prayedOn.includes(today);
|
const prayedToday = prayer.prayedOn.includes(today);
|
||||||
|
const isPinned = !!pinPos;
|
||||||
const days = daysToAnswer(prayer);
|
const days = daysToAnswer(prayer);
|
||||||
return (
|
return (
|
||||||
<li className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-4">
|
<li className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-4">
|
||||||
@@ -41,11 +42,23 @@ function PrayerCard({ prayer, today, actions, onOpenVerse }) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
{prayer.forWhom && <p className="text-xs font-medium uppercase tracking-wide text-[var(--r-sub)]">For {prayer.forWhom}</p>}
|
{(prayer.forWhom || isPinned || prayer.status) && (
|
||||||
|
<p className="flex flex-wrap items-center gap-2 text-xs font-medium uppercase tracking-wide text-[var(--r-sub)]">
|
||||||
|
{isPinned && <span className="text-[var(--r-accent)]">Pinned</span>}
|
||||||
|
{prayer.status && <span className="rounded-full border border-[var(--r-line)] px-2 py-0.5 normal-case tracking-normal">{STATUSES[prayer.status]}</span>}
|
||||||
|
{prayer.forWhom && <span>For {prayer.forWhom}</span>}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<p className="whitespace-pre-wrap text-[15px] leading-relaxed">{prayer.text}</p>
|
<p className="whitespace-pre-wrap text-[15px] leading-relaxed">{prayer.text}</p>
|
||||||
<VerseChips verses={prayer.verses} onOpen={onOpenVerse} />
|
<VerseChips verses={prayer.verses} onOpen={onOpenVerse} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{prayer.status === 'partial' && prayer.answerNote && mode !== 'partial' && (
|
||||||
|
<div className="mt-3 rounded-xl bg-[var(--r-bg)] px-3 py-2 text-sm" role="note">
|
||||||
|
<p className="text-xs font-medium text-[var(--r-accent)]">Partly answered</p>
|
||||||
|
<p className="mt-0.5 whitespace-pre-wrap">{prayer.answerNote}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{prayer.answeredAt && (
|
{prayer.answeredAt && (
|
||||||
<div className="mt-3 rounded-xl bg-[var(--r-bg)] px-3 py-2 text-sm" role="note">
|
<div className="mt-3 rounded-xl bg-[var(--r-bg)] px-3 py-2 text-sm" role="note">
|
||||||
<p className="text-xs font-medium text-[var(--r-accent)]">Answered {new Date(prayer.answeredAt).toLocaleDateString()}{days != null ? ` · after ${days} day${days === 1 ? '' : 's'}` : ''}</p>
|
<p className="text-xs font-medium text-[var(--r-accent)]">Answered {new Date(prayer.answeredAt).toLocaleDateString()}{days != null ? ` · after ${days} day${days === 1 ? '' : 's'}` : ''}</p>
|
||||||
@@ -61,6 +74,15 @@ function PrayerCard({ prayer, today, actions, onOpenVerse }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{mode === 'partial' && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<textarea aria-label="What has been answered so far?" autoFocus value={note} onChange={(e) => setNote(e.target.value)} rows={2} maxLength={1000} placeholder="What has been answered so far? (optional)" className={`${field} w-full`} />
|
||||||
|
<div className="mt-2 flex gap-3 text-xs">
|
||||||
|
<button type="button" onClick={() => { actions.status(prayer.id, 'partial', note); setMode(null); }} className="font-medium text-[var(--r-accent)] hover:underline">Save</button>
|
||||||
|
<button type="button" onClick={() => setMode(null)} className="text-[var(--r-sub)] hover:underline">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||||
{!prayer.answeredAt && (
|
{!prayer.answeredAt && (
|
||||||
<button type="button" onClick={() => actions.toggle(prayer.id)} aria-pressed={prayedToday} className={`${small} ${prayedToday ? 'border-[var(--r-accent)] text-[var(--r-accent)]' : ''}`}>
|
<button type="button" onClick={() => actions.toggle(prayer.id)} aria-pressed={prayedToday} className={`${small} ${prayedToday ? 'border-[var(--r-accent)] text-[var(--r-accent)]' : ''}`}>
|
||||||
@@ -68,7 +90,20 @@ function PrayerCard({ prayer, today, actions, onOpenVerse }) {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{prayer.prayedOn.length > 0 && <span className="text-xs text-[var(--r-sub)]">Prayed on {prayer.prayedOn.length} day{prayer.prayedOn.length === 1 ? '' : 's'}</span>}
|
{prayer.prayedOn.length > 0 && <span className="text-xs text-[var(--r-sub)]">Prayed on {prayer.prayedOn.length} day{prayer.prayedOn.length === 1 ? '' : 's'}</span>}
|
||||||
<span className="ml-auto flex gap-2">
|
<span className="ml-auto flex flex-wrap gap-2">
|
||||||
|
{!prayer.answeredAt && isPinned && (
|
||||||
|
<>
|
||||||
|
<button type="button" onClick={() => actions.move(prayer.id, -1)} disabled={pinPos.index === 0} aria-label="Move up" title="Move up" className={`${small} disabled:opacity-40`}>↑</button>
|
||||||
|
<button type="button" onClick={() => actions.move(prayer.id, 1)} disabled={pinPos.index === pinPos.count - 1} aria-label="Move down" title="Move down" className={`${small} disabled:opacity-40`}>↓</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!prayer.answeredAt && <button type="button" onClick={() => actions.pin(prayer.id, !isPinned)} aria-pressed={isPinned} className={`${small} ${isPinned ? 'border-[var(--r-accent)] text-[var(--r-accent)]' : ''}`}>{isPinned ? 'Unpin' : 'Pin'}</button>}
|
||||||
|
{!prayer.answeredAt && (
|
||||||
|
<>
|
||||||
|
<button type="button" onClick={() => actions.status(prayer.id, prayer.status === 'waiting' ? '' : 'waiting')} aria-pressed={prayer.status === 'waiting'} className={`${small} ${prayer.status === 'waiting' ? 'border-[var(--r-accent)] text-[var(--r-accent)]' : ''}`}>Waiting</button>
|
||||||
|
<button type="button" onClick={() => { setNote(prayer.status === 'partial' ? prayer.answerNote : ''); setMode('partial'); }} aria-pressed={prayer.status === 'partial'} className={`${small} ${prayer.status === 'partial' ? 'border-[var(--r-accent)] text-[var(--r-accent)]' : ''}`}>Partly answered</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{prayer.answeredAt
|
{prayer.answeredAt
|
||||||
? <button type="button" onClick={() => actions.reopen(prayer.id)} className={small}>Pray again</button>
|
? <button type="button" onClick={() => actions.reopen(prayer.id)} className={small}>Pray again</button>
|
||||||
: <button type="button" onClick={() => { setNote(''); setMode('answer'); }} className={small}>Answered</button>}
|
: <button type="button" onClick={() => { setNote(''); setMode('answer'); }} className={small}>Answered</button>}
|
||||||
@@ -81,12 +116,13 @@ function PrayerCard({ prayer, today, actions, onOpenVerse }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function PrayerPage() {
|
export default function PrayerPage() {
|
||||||
const { prayers, addPrayer, editPrayer, togglePrayed, answerPrayer, reopenPrayer, deletePrayer, prayerDraft, clearPrayerDraft, jumpToReaderVerse, openBibleReader } = useApp();
|
const { prayers, addPrayer, editPrayer, togglePrayed, answerPrayer, reopenPrayer, deletePrayer, pinPrayer, movePrayer, setPrayerStatus, importPrayers, prayerDraft, clearPrayerDraft, jumpToReaderVerse, openBibleReader } = useApp();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [tab, setTab] = useState('open');
|
const [tab, setTab] = useState('open');
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [form, setForm] = useState({ text: '', forWhom: '', verses: '' });
|
const [form, setForm] = useState({ text: '', forWhom: '', verses: '' });
|
||||||
const today = localDateString();
|
const today = localDateString();
|
||||||
|
const fileRef = useRef(null);
|
||||||
|
|
||||||
// "Pray about this verse" from the Reader arrives with the verse filled in
|
// "Pray about this verse" from the Reader arrives with the verse filled in
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -105,19 +141,31 @@ export default function PrayerPage() {
|
|||||||
setTab('open');
|
setTab('open');
|
||||||
};
|
};
|
||||||
const actions = {
|
const actions = {
|
||||||
edit: editPrayer, toggle: togglePrayed, reopen: reopenPrayer,
|
edit: editPrayer, toggle: togglePrayed, reopen: reopenPrayer, pin: pinPrayer, move: movePrayer, status: setPrayerStatus,
|
||||||
answer: (id, note) => { answerPrayer(id, note); toast('Marked answered. Thank God for it.'); },
|
answer: (id, note) => { answerPrayer(id, note); toast('Marked answered. Thank God for it.'); },
|
||||||
remove: (id) => { const undo = deletePrayer(id); toast('Prayer deleted', { action: { label: 'Undo', onClick: undo } }); },
|
remove: (id) => { const undo = deletePrayer(id); toast('Prayer deleted', { action: { label: 'Undo', onClick: undo } }); },
|
||||||
};
|
};
|
||||||
const openVerse = (book, chapter, verse) => { jumpToReaderVerse(book, chapter, verse); openBibleReader(); };
|
const openVerse = (book, chapter, verse) => { jumpToReaderVerse(book, chapter, verse); openBibleReader(); };
|
||||||
const download = () => {
|
const pinOrder = useMemo(() => pinnedList(prayers).map((p) => p.id), [prayers]);
|
||||||
const url = URL.createObjectURL(new Blob([prayersToMarkdown(prayers)], { type: 'text/markdown;charset=utf-8' }));
|
const download = (json = false) => {
|
||||||
|
const url = URL.createObjectURL(new Blob([json ? prayersToJson(prayers) : prayersToMarkdown(prayers)], { type: json ? 'application/json' : 'text/markdown;charset=utf-8' }));
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url; a.download = 'prayer-journal.md';
|
a.href = url; a.download = json ? 'prayer-journal.json' : 'prayer-journal.md';
|
||||||
document.body.appendChild(a); a.click(); a.remove();
|
document.body.appendChild(a); a.click(); a.remove();
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onImportFile = async (e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
e.target.value = '';
|
||||||
|
if (!file) return;
|
||||||
|
let result;
|
||||||
|
try { result = parsePrayerImport(await file.text(), prayers); } catch { result = { prayers: [], skipped: 0, error: 'Couldn’t read that file.' }; }
|
||||||
|
if (result.error) { toast(result.error); return; }
|
||||||
|
if (result.prayers.length) importPrayers(result.prayers);
|
||||||
|
toast(`Imported ${result.prayers.length} prayer${result.prayers.length === 1 ? '' : 's'}${result.skipped ? ` (${result.skipped} already here or skipped)` : ''}`);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell title="Prayer journal" active="prayer" maxWidth="max-w-3xl">
|
<PageShell title="Prayer journal" active="prayer" maxWidth="max-w-3xl">
|
||||||
<main className="mx-auto max-w-3xl space-y-5 px-4 py-6">
|
<main className="mx-auto max-w-3xl space-y-5 px-4 py-6">
|
||||||
@@ -149,7 +197,14 @@ export default function PrayerPage() {
|
|||||||
<button type="button" role="tab" aria-selected={tab === 'all'} onClick={() => setTab('all')} className={tabBtn(tab === 'all')}>All</button>
|
<button type="button" role="tab" aria-selected={tab === 'all'} onClick={() => setTab('all')} className={tabBtn(tab === 'all')}>All</button>
|
||||||
</div>
|
</div>
|
||||||
<input type="search" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search prayers…" aria-label="Search prayers" className={`${field} ml-auto w-48 py-1.5`} />
|
<input type="search" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search prayers…" aria-label="Search prayers" className={`${field} ml-auto w-48 py-1.5`} />
|
||||||
{live(prayers).length > 0 && <button type="button" onClick={download} className={small}>Download</button>}
|
<button type="button" onClick={() => fileRef.current?.click()} className={small}>Import</button>
|
||||||
|
<input ref={fileRef} type="file" accept=".json,.md,.txt,text/markdown,application/json,text/plain" onChange={onImportFile} className="hidden" aria-label="Import prayers file" />
|
||||||
|
{live(prayers).length > 0 && (
|
||||||
|
<>
|
||||||
|
<button type="button" onClick={() => download(false)} className={small}>Download</button>
|
||||||
|
<button type="button" onClick={() => download(true)} className={small} title="Keeps every detail, including the days you prayed">Download JSON</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{shown.length === 0 ? (
|
{shown.length === 0 ? (
|
||||||
@@ -159,7 +214,7 @@ export default function PrayerPage() {
|
|||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-3" aria-label="Prayers">
|
<ul className="space-y-3" aria-label="Prayers">
|
||||||
{shown.map((p) => <PrayerCard key={p.id} prayer={p} today={today} actions={actions} onOpenVerse={openVerse} />)}
|
{shown.map((p) => <PrayerCard key={p.id} prayer={p} today={today} actions={actions} onOpenVerse={openVerse} pinPos={pinOrder.includes(p.id) ? { index: pinOrder.indexOf(p.id), count: pinOrder.length } : null} />)}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ import { vi, test, expect } from 'vitest';
|
|||||||
import { AppContext } from '../context/AppContext.js';
|
import { AppContext } from '../context/AppContext.js';
|
||||||
import { ToastProvider } from '../components/Toasts.jsx';
|
import { ToastProvider } from '../components/Toasts.jsx';
|
||||||
import PrayerPage from './PrayerPage.jsx';
|
import PrayerPage from './PrayerPage.jsx';
|
||||||
import { markAnswered, newPrayer } from '../utils/prayers.js';
|
import { markAnswered, newPrayer, setPinned } from '../utils/prayers.js';
|
||||||
|
|
||||||
vi.mock('../components/study/PageShell.jsx', () => ({ default: ({ children }) => <div>{children}</div> }));
|
vi.mock('../components/study/PageShell.jsx', () => ({ default: ({ children }) => <div>{children}</div> }));
|
||||||
|
|
||||||
const T0 = Date.UTC(2026, 9, 1);
|
const T0 = Date.UTC(2026, 9, 1);
|
||||||
function setup(initial = [], extra = {}) {
|
function setup(initial = [], extra = {}) {
|
||||||
const ctx = {
|
const ctx = {
|
||||||
prayers: initial, addPrayer: vi.fn(), editPrayer: vi.fn(), togglePrayed: vi.fn(), answerPrayer: vi.fn(), reopenPrayer: vi.fn(), deletePrayer: vi.fn(() => vi.fn()),
|
prayers: initial, addPrayer: vi.fn(), editPrayer: vi.fn(), togglePrayed: vi.fn(), answerPrayer: vi.fn(), reopenPrayer: vi.fn(), pinPrayer: vi.fn(), movePrayer: vi.fn(), setPrayerStatus: vi.fn(), importPrayers: vi.fn(), deletePrayer: vi.fn(() => vi.fn()),
|
||||||
prayerDraft: null, clearPrayerDraft: vi.fn(), jumpToReaderVerse: vi.fn(), openBibleReader: vi.fn(), ...extra,
|
prayerDraft: null, clearPrayerDraft: vi.fn(), jumpToReaderVerse: vi.fn(), openBibleReader: vi.fn(), ...extra,
|
||||||
};
|
};
|
||||||
render(<AppContext.Provider value={ctx}><ToastProvider><PrayerPage /></ToastProvider></AppContext.Provider>);
|
render(<AppContext.Provider value={ctx}><ToastProvider><PrayerPage /></ToastProvider></AppContext.Provider>);
|
||||||
@@ -78,3 +78,33 @@ test('search narrows the list', async () => {
|
|||||||
expect(screen.getByText('Rain for the farm')).toBeInTheDocument();
|
expect(screen.getByText('Rain for the farm')).toBeInTheDocument();
|
||||||
expect(screen.queryByText('Peace at home')).toBeNull();
|
expect(screen.queryByText('Peace at home')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('prayers can be pinned, reordered, and marked waiting or partly answered', async () => {
|
||||||
|
const a = newPrayer({ text: 'First request' }, T0);
|
||||||
|
const b = newPrayer({ text: 'Second request' }, T0 + 1);
|
||||||
|
const list = setPinned(setPinned([a, b], a.id, true, T0 + 5), b.id, true, T0 + 6);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const ctx = setup(list);
|
||||||
|
const items = screen.getAllByRole('listitem');
|
||||||
|
expect(items[0]).toHaveTextContent('First request');
|
||||||
|
expect(within(items[0]).getByRole('button', { name: 'Move up' })).toBeDisabled();
|
||||||
|
await user.click(within(items[0]).getByRole('button', { name: 'Move down' }));
|
||||||
|
expect(ctx.movePrayer).toHaveBeenCalledWith(a.id, 1);
|
||||||
|
await user.click(within(items[1]).getByRole('button', { name: 'Unpin' }));
|
||||||
|
expect(ctx.pinPrayer).toHaveBeenCalledWith(b.id, false);
|
||||||
|
await user.click(within(items[0]).getByRole('button', { name: 'Waiting' }));
|
||||||
|
expect(ctx.setPrayerStatus).toHaveBeenCalledWith(a.id, 'waiting');
|
||||||
|
await user.click(within(items[0]).getByRole('button', { name: 'Partly answered' }));
|
||||||
|
await user.type(screen.getByLabelText('What has been answered so far?'), 'Job interview came');
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||||
|
expect(ctx.setPrayerStatus).toHaveBeenCalledWith(a.id, 'partial', 'Job interview came');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('importing a file adds only new prayers', async () => {
|
||||||
|
const have = newPrayer({ text: 'Already here' }, T0);
|
||||||
|
const file = new File([JSON.stringify([{ text: 'Already here' }, { text: 'Brand new' }])], 'p.json', { type: 'application/json' });
|
||||||
|
const ctx = setup([have]);
|
||||||
|
await userEvent.setup().upload(screen.getByLabelText('Import prayers file'), file);
|
||||||
|
expect(ctx.importPrayers).toHaveBeenCalledWith([expect.objectContaining({ text: 'Brand new' })]);
|
||||||
|
expect(await screen.findByText(/Imported 1 prayer/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
// write the final script beside your study notes. Claude prompts and the
|
// write the final script beside your study notes. Claude prompts and the
|
||||||
// pronunciation guide live here because this is where they're needed.
|
// pronunciation guide live here because this is where they're needed.
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import ScriptureRefs from '../components/ScriptureRefs.jsx';
|
||||||
import PresentMode from '../components/PresentMode.jsx';
|
import PresentMode from '../components/PresentMode.jsx';
|
||||||
import DictateButton, { appendSpoken } from '../components/DictateButton.jsx';
|
import DictateButton, { appendSpoken } from '../components/DictateButton.jsx';
|
||||||
import { useIqAvailable } from '../hooks/useIq.js';
|
import { useIqAvailable } from '../hooks/useIq.js';
|
||||||
@@ -174,6 +175,7 @@ export default function ScriptPage() {
|
|||||||
<textarea aria-label="Final script" value={script} onChange={(e) => set({ finalScript: e.target.value })}
|
<textarea aria-label="Final script" value={script} onChange={(e) => set({ finalScript: e.target.value })}
|
||||||
placeholder="Write or paste the final script here. “Build outline from notes” starts you from your study notes."
|
placeholder="Write or paste the final script here. “Build outline from notes” starts you from your study notes."
|
||||||
className="min-h-[24rem] flex-1 resize-y rounded-2xl border border-[var(--r-line)] bg-[var(--r-bg)] px-5 py-4 font-serif text-[16px] leading-8 text-[var(--r-text)] placeholder:font-sans placeholder:text-sm placeholder:text-[var(--r-sub)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]" />
|
className="min-h-[24rem] flex-1 resize-y rounded-2xl border border-[var(--r-line)] bg-[var(--r-bg)] px-5 py-4 font-serif text-[16px] leading-8 text-[var(--r-text)] placeholder:font-sans placeholder:text-sm placeholder:text-[var(--r-sub)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]" />
|
||||||
|
<ScriptureRefs text={script} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ import ResourcePane, { useResourceTab } from '../components/study/ResourcePane.j
|
|||||||
import CompareSheet from '../components/study/CompareSheet.jsx';
|
import CompareSheet from '../components/study/CompareSheet.jsx';
|
||||||
import SuggestWordsSheet from '../components/study/SuggestWordsSheet.jsx';
|
import SuggestWordsSheet from '../components/study/SuggestWordsSheet.jsx';
|
||||||
|
|
||||||
// Keyboard-only study: 1 / 2 / 3 jump to Observe / Interpret / Apply; c, w, x, p open the resource tabs; v compares.
|
// Keyboard-only study: 1 / 2 / 3 jump to Observe / Interpret / Apply; c, w, x, p, r open the resource tabs; v compares.
|
||||||
const NOTE_KEYS = { 1: 'observation', 2: 'interpretation', 3: 'application' };
|
const NOTE_KEYS = { 1: 'observation', 2: 'interpretation', 3: 'application' };
|
||||||
const TAB_KEYS = { c: 'commentary', w: 'words', x: 'refs', p: 'people' };
|
const TAB_KEYS = { c: 'commentary', w: 'words', x: 'refs', p: 'people', r: 'readings' };
|
||||||
|
|
||||||
export default function StudyPage() {
|
export default function StudyPage() {
|
||||||
const { project, allChunks, projectIndex, updateProject, updateChunk, setCurrentPage, readerCommentary, setReaderCommentary, saveUserTemplate, startPrayerFor } = useApp();
|
const { project, allChunks, projectIndex, updateProject, updateChunk, setCurrentPage, readerCommentary, setReaderCommentary, saveUserTemplate, startPrayerFor } = useApp();
|
||||||
@@ -183,7 +183,7 @@ export default function StudyPage() {
|
|||||||
<div className={`flex min-h-0 flex-1 flex-col border-t border-[var(--r-line)] ${folded.resources ? 'hidden' : ''}`}>
|
<div className={`flex min-h-0 flex-1 flex-col border-t border-[var(--r-line)] ${folded.resources ? 'hidden' : ''}`}>
|
||||||
<ResourcePane
|
<ResourcePane
|
||||||
tab={tab} setTab={setTab} chunk={selectedChunk} chapter={chapter} chapterIndex={chapterIndex} project={project}
|
tab={tab} setTab={setTab} chunk={selectedChunk} chapter={chapter} chapterIndex={chapterIndex} project={project}
|
||||||
updateChunk={updateChunk} notify={notify}
|
updateChunk={updateChunk} updateProject={updateProject} notify={notify}
|
||||||
resources={resources} wordStudy={wordStudy} crossRefs={crossRefs}
|
resources={resources} wordStudy={wordStudy} crossRefs={crossRefs}
|
||||||
openWordId={openWordId} setOpenWordId={setOpenWordId}
|
openWordId={openWordId} setOpenWordId={setOpenWordId}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -509,6 +509,18 @@ describe('Study workspace', () => {
|
|||||||
expect(screen.getByRole('textbox', { name: 'Observe' })).toHaveValue('Written beside the translations.');
|
expect(screen.getByRole('textbox', { name: 'Observe' })).toHaveValue('Written beside the translations.');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('reading list: add a book to the study, tick it off, and it is remembered with the study', async () => {
|
||||||
|
const user = await studyOneChunk();
|
||||||
|
await user.click(screen.getByRole('tab', { name: /reading list/i }));
|
||||||
|
await user.type(screen.getByRole('textbox', { name: /add to the reading list/i }), 'Carson on John{Enter}');
|
||||||
|
const tick = screen.getByRole('checkbox', { name: /read: carson on john/i });
|
||||||
|
expect(tick).not.toBeChecked();
|
||||||
|
await user.click(tick);
|
||||||
|
expect(screen.getByRole('checkbox', { name: /read: carson on john/i })).toBeChecked();
|
||||||
|
await user.click(screen.getByRole('tab', { name: /commentary/i }));
|
||||||
|
expect(screen.getByRole('tab', { name: /reading list/i })).toBeInTheDocument(); // none left to read, so no count
|
||||||
|
});
|
||||||
|
|
||||||
test('people & places: lists what is tagged in the chunk', async () => {
|
test('people & places: lists what is tagged in the chunk', async () => {
|
||||||
const user = await studyOneChunk([['/d/theographic/TIT/1.json', { chapter: { people: [{ id: 'paul_1', name: 'Paul', verses: [1] }, { id: 'x_2', name: 'Elsewhere', verses: [9] }], places: [], events: [] } }]]);
|
const user = await studyOneChunk([['/d/theographic/TIT/1.json', { chapter: { people: [{ id: 'paul_1', name: 'Paul', verses: [1] }, { id: 'x_2', name: 'Elsewhere', verses: [9] }], places: [], events: [] } }]]);
|
||||||
await user.click(screen.getByRole('tab', { name: /people & places/i }));
|
await user.click(screen.getByRole('tab', { name: /people & places/i }));
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ export const READER_TRANSLATIONS = [
|
|||||||
notice: 'Scripture quotations are from the ESV® Bible (The Holy Bible, English Standard Version®), © 2001 by Crossway, a publishing ministry of Good News Publishers. Used by permission. All rights reserved.' },
|
notice: 'Scripture quotations are from the ESV® Bible (The Holy Bible, English Standard Version®), © 2001 by Crossway, a publishing ministry of Good News Publishers. Used by permission. All rights reserved.' },
|
||||||
{ id: 'NET', name: 'NET Bible', helloao: 'eng_net',
|
{ id: 'NET', name: 'NET Bible', helloao: 'eng_net',
|
||||||
notice: 'Scripture quoted by permission. Quotations designated (NET) are from the NET Bible® copyright ©1996-2017 by Biblical Studies Press, L.L.C. http://netbible.com All rights reserved.' },
|
notice: 'Scripture quoted by permission. Quotations designated (NET) are from the NET Bible® copyright ©1996-2017 by Biblical Studies Press, L.L.C. http://netbible.com All rights reserved.' },
|
||||||
|
{ id: 'KJV', name: 'King James Version', helloao: 'eng_kjv', notice: 'The King James Version is in the public domain.' },
|
||||||
|
{ id: 'ASV', name: 'American Standard Version (1901)', helloao: 'eng_asv', notice: 'The American Standard Version (1901) is in the public domain.' },
|
||||||
|
{ id: 'LSV', name: 'Literal Standard Version', helloao: 'eng_lsv', notice: 'Literal Standard Version © 2020 Covenant Press and the Covenant Christian Coalition. Free to read and share. lsvbible.com' },
|
||||||
{ id: 'NLT', name: 'New Living Translation', licensed: true,
|
{ id: 'NLT', name: 'New Living Translation', licensed: true,
|
||||||
notice: 'Holy Bible, New Living Translation, Copyright © 2014, Tyndale House Publishers. All rights reserved. tyndale.com' },
|
notice: 'Holy Bible, New Living Translation, Copyright © 2014, Tyndale House Publishers. All rights reserved. tyndale.com' },
|
||||||
{ id: 'CSB', name: 'Christian Standard Bible', licensed: true,
|
{ id: 'CSB', name: 'Christian Standard Bible', licensed: true,
|
||||||
@@ -16,6 +19,8 @@ export const READER_TRANSLATIONS = [
|
|||||||
notice: 'New American Standard Bible®, Copyright © 1995, The Lockman Foundation. All rights reserved. lockman.org' },
|
notice: 'New American Standard Bible®, Copyright © 1995, The Lockman Foundation. All rights reserved. lockman.org' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// The address code helloao uses for a translation (NET is 'eng_net', the KJV 'eng_kjv'; BSB is its own)
|
||||||
|
export const helloaoCode = (id) => READER_TRANSLATIONS.find((t) => t.id === id)?.helloao ?? id ?? 'BSB';
|
||||||
export const translationName = (id) => READER_TRANSLATIONS.find((t) => t.id === id)?.name ?? id;
|
export const translationName = (id) => READER_TRANSLATIONS.find((t) => t.id === id)?.name ?? id;
|
||||||
export const isLicensed = (id) => READER_TRANSLATIONS.find((t) => t.id === id)?.licensed === true;
|
export const isLicensed = (id) => READER_TRANSLATIONS.find((t) => t.id === id)?.licensed === true;
|
||||||
|
|
||||||
|
|||||||
@@ -485,7 +485,7 @@ export function parseBibleChapter(data) {
|
|||||||
.filter((item) => item?.type === 'verse')
|
.filter((item) => item?.type === 'verse')
|
||||||
.map((item) => ({
|
.map((item) => ({
|
||||||
number: item.number,
|
number: item.number,
|
||||||
text: renderVerseContent(item.content).replace(/\s+/g, ' ').trim(),
|
text: renderVerseContent(item.content).replace(/¶\s*/g, '').replace(/\s+/g, ' ').trim(),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// Ancient weights, measures and money, with a modern guide. Hover a unit in the passage to see it. The sizes are the
|
||||||
|
// usual round figures; the real ones varied by place and period, so every note says "about".
|
||||||
|
const UNITS = [
|
||||||
|
// length
|
||||||
|
{ words: ['cubit', 'cubits'], note: 'A cubit: about 18 inches (45 cm), the length from elbow to fingertip.' },
|
||||||
|
{ words: ['span', 'spans'], note: 'A span: about 9 inches (23 cm), a hand stretched wide. Half a cubit.' },
|
||||||
|
{ words: ['handbreadth', 'handbreadths', 'handsbreadth'], note: 'A handbreadth: about 3 inches (7.5 cm), the width of four fingers.' },
|
||||||
|
{ words: ['fathom', 'fathoms'], note: 'A fathom: about 6 feet (1.8 m), the span of outstretched arms.' },
|
||||||
|
{ words: ['furlong', 'furlongs', 'stadia', 'stadion'], note: 'A stadion (furlong): about 607 feet (185 m), an eighth of a Roman mile.' },
|
||||||
|
{ words: ['mile', 'miles'], note: 'A Roman mile: about 4,860 feet (1.48 km), a little shorter than a modern mile.' },
|
||||||
|
// weight
|
||||||
|
{ words: ['talent', 'talents'], note: 'A talent: about 75 pounds (34 kg) of silver or gold. Worth around 6,000 days of a labourer’s wages.' },
|
||||||
|
{ words: ['mina', 'minas', 'pound', 'pounds'], note: 'A mina: about 1¼ pounds (0.57 kg). A hundred days’ wages. (Older versions call it a "pound".)', only: ['mina', 'minas'] },
|
||||||
|
{ words: ['shekel', 'shekels'], note: 'A shekel: about 0.4 ounce (11 g). The usual weight of silver for payments.' },
|
||||||
|
{ words: ['beka'], note: 'A beka: about 0.2 ounce (5.7 g). Half a shekel.' },
|
||||||
|
{ words: ['gerah', 'gerahs'], note: 'A gerah: about 0.02 ounce (0.6 g). A twentieth of a shekel.' },
|
||||||
|
// money
|
||||||
|
{ words: ['denarius', 'denarii'], note: 'A denarius: a silver Roman coin, about a day’s wage for a labourer.' },
|
||||||
|
{ words: ['drachma', 'drachmas'], note: 'A drachma: a Greek silver coin, about a day’s wage.' },
|
||||||
|
{ words: ['penny', 'pennies', 'pence'], note: 'In older versions, a "penny" is usually a denarius, about a day’s wage.' },
|
||||||
|
{ words: ['farthing', 'farthings'], note: 'A farthing: a small Roman copper coin, a fraction of a day’s wage.' },
|
||||||
|
{ words: ['lepton', 'mite', 'mites'], note: 'A lepton ("mite"): the smallest Greek coin, a fraction of a day’s wage.' },
|
||||||
|
{ words: ['quadrans'], note: 'A quadrans: a small Roman copper coin worth a fraction of a day’s wage.' },
|
||||||
|
// volume
|
||||||
|
{ words: ['ephah', 'ephahs'], note: 'An ephah: about 22 liters (⅗ bushel) of grain. Ten omers.' },
|
||||||
|
{ words: ['omer', 'omers'], note: 'An omer: about 2.2 liters (2 quarts) of grain. A tenth of an ephah.' },
|
||||||
|
{ words: ['homer', 'homers'], note: 'A homer: about 220 liters (6 bushels), a donkey load. Ten ephahs.' },
|
||||||
|
{ words: ['seah', 'seahs'], note: 'A seah: about 7 liters (7½ quarts) of grain. A third of an ephah.' },
|
||||||
|
{ words: ['bath', 'baths'], note: 'A bath: about 22 liters (6 gallons) of liquid. The same size as an ephah.' },
|
||||||
|
{ words: ['hin'], note: 'A hin: about 3.7 liters (4 quarts) of liquid.' },
|
||||||
|
{ words: ['cor', 'cors', 'kor'], note: 'A cor: about 220 liters (6 bushels). The same as a homer.' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// word -> note, for the words that are safe to mark in running text ("pound" is left out: it is a common word and only the mina is meant in a few verses)
|
||||||
|
const INDEX = new Map();
|
||||||
|
for (const u of UNITS) for (const w of u.words) {
|
||||||
|
if (u.only && !u.only.includes(w)) continue; // "pounds" and "penny" are common words with other meanings; "bath" too
|
||||||
|
INDEX.set(w, u.note);
|
||||||
|
}
|
||||||
|
const RE = new RegExp(`\\b(${[...INDEX.keys()].sort((a, b) => b.length - a.length).join('|')})\\b`, 'gi');
|
||||||
|
|
||||||
|
export const unitNote = (word) => INDEX.get(String(word).toLowerCase()) ?? null;
|
||||||
|
|
||||||
|
// Splits a verse into plain pieces and unit words: [{ text, note? }]. Text that has no unit is a single piece.
|
||||||
|
export function markUnits(text) {
|
||||||
|
const parts = [];
|
||||||
|
let at = 0;
|
||||||
|
for (const m of String(text ?? '').matchAll(RE)) {
|
||||||
|
if (m.index > at) parts.push({ text: text.slice(at, m.index) });
|
||||||
|
parts.push({ text: m[0], note: INDEX.get(m[0].toLowerCase()) });
|
||||||
|
at = m.index + m[0].length;
|
||||||
|
}
|
||||||
|
if (at < text.length) parts.push({ text: text.slice(at) });
|
||||||
|
return parts.length ? parts : [{ text: text ?? '' }];
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { test, expect } from 'vitest';
|
||||||
|
import { markUnits, unitNote } from './biblicalUnits.js';
|
||||||
|
|
||||||
|
const noted = (text) => markUnits(text).filter((p) => p.note).map((p) => p.text);
|
||||||
|
|
||||||
|
test('units in a verse are found with their meaning, in any form', () => {
|
||||||
|
expect(noted('The ark was two and a half cubits long and a handbreadth thick.')).toEqual(['cubits', 'handbreadth']);
|
||||||
|
expect(noted('He gave him five talents, and she lost one drachma; it cost a denarius.')).toEqual(['talents', 'drachma', 'denarius']);
|
||||||
|
expect(noted('An ephah of flour, an omer of manna, and a hin of oil.')).toEqual(['ephah', 'omer', 'hin']);
|
||||||
|
expect(noted('about twenty-five or thirty stadia')).toEqual(['stadia']);
|
||||||
|
expect(unitNote('Talent')).toMatch(/75 pounds/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the pieces rejoin to the original text, and a verse with no units is one piece', () => {
|
||||||
|
const text = 'five talents of silver (a talent is heavy)';
|
||||||
|
expect(markUnits(text).map((p) => p.text).join('')).toBe(text);
|
||||||
|
expect(markUnits('For God so loved the world')).toEqual([{ text: 'For God so loved the world' }]);
|
||||||
|
expect(markUnits('')).toEqual([{ text: '' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ordinary words are not mistaken for units', () => {
|
||||||
|
expect(noted('He took a pound of flesh; a span of the river; the mile stone')).toEqual(['span', 'mile']);
|
||||||
|
expect(noted('Bathsheba and Cornelius, the stadium crowd, a corner and a hinge')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a mina is a mina, and the older word pound is left alone', () => {
|
||||||
|
expect(noted('ten minas and a mina')).toEqual(['minas', 'mina']);
|
||||||
|
expect(unitNote('pound')).toBeNull();
|
||||||
|
});
|
||||||
@@ -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\.$/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// Bible dictionary lookups (IQ Bible: Easton, Hastings, Hitchcock, Schaff, Smith). The route is
|
||||||
|
// /dictionaries/{id}?q=term and answers { count, data: [{ term, slug, definition, scripture_refs?, citations? }] },
|
||||||
|
// matching the word anywhere in an entry, so the entry actually named for the word is put first.
|
||||||
|
import { parsePassageRef } from './passageRef.js';
|
||||||
|
|
||||||
|
const norm = (s) => String(s ?? '').trim().toLowerCase().replace(/[^a-z0-9 ]/g, '');
|
||||||
|
|
||||||
|
// rows -> { exact: the entry whose heading is the word (or null), others: the rest }
|
||||||
|
export function pickEntries(rows, q) {
|
||||||
|
const list = Array.isArray(rows) ? rows.filter((r) => r && typeof r.definition === 'string') : [];
|
||||||
|
const want = norm(q);
|
||||||
|
const exact = list.find((r) => norm(r.term) === want) ?? null;
|
||||||
|
return { exact, others: list.filter((r) => r !== exact) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Exodus 4:27-30", "Psalms 106:19", "Leviticus 8" -> { book, chapter, verse } for the Reader, or null
|
||||||
|
export function refTarget(text) {
|
||||||
|
const cleaned = String(text ?? '').replace(/\s*[-–,].*$/, '').trim();
|
||||||
|
const hit = parsePassageRef(cleaned)[0];
|
||||||
|
return hit ? { book: hit.book, chapter: hit.chapter, verse: hit.verse ?? 1, label: String(text).trim() } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A long definition is cut at a sentence end near `max` characters for the preview; the full text stays one click away.
|
||||||
|
export function preview(text, max = 420) {
|
||||||
|
const t = String(text ?? '').trim();
|
||||||
|
if (t.length <= max) return { text: t, cut: false };
|
||||||
|
const upTo = t.slice(0, max);
|
||||||
|
const end = Math.max(upTo.lastIndexOf('. '), upTo.lastIndexOf('; '));
|
||||||
|
return { text: `${end > max * 0.5 ? upTo.slice(0, end + 1) : upTo.replace(/\s+\S*$/, '')}…`, cut: true };
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { test, expect } from 'vitest';
|
||||||
|
import { pickEntries, preview, refTarget } from './dictionary.js';
|
||||||
|
|
||||||
|
const rows = [
|
||||||
|
{ term: 'Alabaster', slug: 'alabaster', definition: 'A stone… a denarius…' },
|
||||||
|
{ term: 'Denarius', slug: 'denarius', definition: 'A Roman silver coin.', scripture_refs: ['Matthew 20:2'] },
|
||||||
|
{ term: 'Farthing', slug: 'farthing', definition: 'mentions the denarius' },
|
||||||
|
];
|
||||||
|
|
||||||
|
test('the entry named for the word comes first, the entries that only mention it follow', () => {
|
||||||
|
const { exact, others } = pickEntries(rows, ' denarius ');
|
||||||
|
expect(exact.term).toBe('Denarius');
|
||||||
|
expect(others.map((o) => o.term)).toEqual(['Alabaster', 'Farthing']);
|
||||||
|
expect(pickEntries(rows, 'penny').exact).toBeNull();
|
||||||
|
expect(pickEntries(rows, 'penny').others).toHaveLength(3);
|
||||||
|
expect(pickEntries(null, 'x')).toEqual({ exact: null, others: [] });
|
||||||
|
expect(pickEntries([{ term: 'Bad' }], 'bad').exact).toBeNull(); // an entry without a definition is ignored
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Scripture references in an entry are turned into places in the Reader', () => {
|
||||||
|
expect(refTarget('Exodus 4:27-30')).toMatchObject({ book: 'EXO', chapter: 4, verse: 27, label: 'Exodus 4:27-30' });
|
||||||
|
expect(refTarget('Psalms 106:19')).toMatchObject({ book: 'PSA', chapter: 106, verse: 19 });
|
||||||
|
expect(refTarget('Leviticus 8')).toMatchObject({ book: 'LEV', chapter: 8, verse: 1 });
|
||||||
|
expect(refTarget('1 Chronicles 2:10')).toMatchObject({ book: '1CH', chapter: 2, verse: 10 });
|
||||||
|
expect(refTarget('nonsense')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a long definition is previewed up to a sentence end', () => {
|
||||||
|
const long = `${'First sentence is here. '.repeat(30)}`;
|
||||||
|
const p = preview(long, 100);
|
||||||
|
expect(p.cut).toBe(true);
|
||||||
|
expect(p.text.endsWith('…')).toBe(true);
|
||||||
|
expect(p.text.length).toBeLessThan(110);
|
||||||
|
expect(preview('Short.', 100)).toEqual({ text: 'Short.', cut: false });
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// Family connections from the Theographic person records (father, mother, siblings, partners, children).
|
||||||
|
const GROUPS = [['father', 'Father'], ['mother', 'Mother'], ['partners', 'Partner'], ['siblings', 'Siblings'], ['children', 'Children']];
|
||||||
|
|
||||||
|
const asList = (v) => (Array.isArray(v) ? v : v ? [v] : []).filter((p) => p && p.id && p.name);
|
||||||
|
|
||||||
|
// -> [{ label: 'Children', people: [{ id, name }] }] for the relations this person has
|
||||||
|
export function relatives(person) {
|
||||||
|
return GROUPS.map(([key, label]) => ({ label: key === 'partners' && asList(person?.partners).length > 1 ? 'Partners' : label, people: asList(person?.[key]).map(({ id, name }) => ({ id, name })) }))
|
||||||
|
.filter((g) => g.people.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walks up (parents) or down (children) from one person, `depth` generations, never visiting anyone twice and
|
||||||
|
// stopping after `max` people so a long line like Adam's doesn't fetch the whole Bible.
|
||||||
|
// load(id) -> person record or null. -> { id, name, more: bool, kids: [node…] } (kids are the parents when going up)
|
||||||
|
export async function walk(load, id, { direction = 'down', depth = 2, max = 40 } = {}) {
|
||||||
|
const seen = new Set([id]);
|
||||||
|
let budget = max;
|
||||||
|
const links = (p) => (direction === 'down' ? asList(p?.children) : [...asList(p?.father), ...asList(p?.mother)]);
|
||||||
|
async function visit(personId, name, level) {
|
||||||
|
const person = await load(personId);
|
||||||
|
const next = links(person).filter((l) => !seen.has(l.id));
|
||||||
|
const node = { id: personId, name: person?.name ?? name, more: false, kids: [] };
|
||||||
|
if (level >= depth) { node.more = next.length > 0; return node; }
|
||||||
|
const taking = next.filter(() => budget > 0 && (budget -= 1) >= 0);
|
||||||
|
taking.forEach((l) => seen.add(l.id));
|
||||||
|
node.more = taking.length < next.length;
|
||||||
|
node.kids = await Promise.all(taking.map((l) => visit(l.id, l.name, level + 1)));
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
const root = await load(id);
|
||||||
|
return visit(id, root?.name ?? '', 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Counts the people below a node (not counting it)
|
||||||
|
export const countKids = (node) => node.kids.reduce((n, k) => n + 1 + countKids(k), 0);
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { test, expect } from 'vitest';
|
||||||
|
import { countKids, relatives, walk } from './family.js';
|
||||||
|
|
||||||
|
const p = (id, name, extra = {}) => ({ id, name, ...extra });
|
||||||
|
const ref = (id, name) => ({ id, name, type: 'people' });
|
||||||
|
const people = {
|
||||||
|
adam: p('adam', 'Adam', { partners: [ref('eve', 'Eve')], children: [ref('cain', 'Cain'), ref('abel', 'Abel'), ref('seth', 'Seth')] }),
|
||||||
|
eve: p('eve', 'Eve', { partners: [ref('adam', 'Adam')], children: [ref('cain', 'Cain'), ref('abel', 'Abel'), ref('seth', 'Seth')] }),
|
||||||
|
cain: p('cain', 'Cain', { father: [ref('adam', 'Adam')], mother: [ref('eve', 'Eve')], children: [ref('enoch', 'Enoch')] }),
|
||||||
|
abel: p('abel', 'Abel', { father: [ref('adam', 'Adam')], mother: [ref('eve', 'Eve')] }),
|
||||||
|
seth: p('seth', 'Seth', { father: [ref('adam', 'Adam')], mother: [ref('eve', 'Eve')], children: [ref('enos', 'Enos')], siblings: [ref('cain', 'Cain'), ref('abel', 'Abel')] }),
|
||||||
|
enoch: p('enoch', 'Enoch', { father: [ref('cain', 'Cain')], children: [ref('irad', 'Irad')] }),
|
||||||
|
enos: p('enos', 'Enos', { father: [ref('seth', 'Seth')] }),
|
||||||
|
};
|
||||||
|
const load = async (id) => people[id] ?? null;
|
||||||
|
const names = (n) => ({ [n.name]: n.kids.map(names) });
|
||||||
|
|
||||||
|
test('relations are grouped and named, and empty ones are left out', () => {
|
||||||
|
expect(relatives(people.seth).map((g) => [g.label, g.people.map((x) => x.name)])).toEqual([
|
||||||
|
['Father', ['Adam']], ['Mother', ['Eve']], ['Siblings', ['Cain', 'Abel']], ['Children', ['Enos']],
|
||||||
|
]);
|
||||||
|
expect(relatives(people.adam).map((g) => g.label)).toEqual(['Partner', 'Children']);
|
||||||
|
expect(relatives({ id: 'x', name: 'X', partners: [ref('a', 'A'), ref('b', 'B')] })[0].label).toBe('Partners');
|
||||||
|
expect(relatives(null)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('descendants are walked a few generations down', async () => {
|
||||||
|
const tree = await walk(load, 'adam', { direction: 'down', depth: 2 });
|
||||||
|
expect(names(tree)).toEqual({ Adam: [{ Cain: [{ Enoch: [] }] }, { Abel: [] }, { Seth: [{ Enos: [] }] }] });
|
||||||
|
expect(countKids(tree)).toBe(5);
|
||||||
|
const shallow = await walk(load, 'adam', { direction: 'down', depth: 1 });
|
||||||
|
expect(shallow.kids.find((k) => k.name === 'Cain').more).toBe(true); // Cain has children not shown
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ancestors go up through both parents, without visiting anyone twice', async () => {
|
||||||
|
const up = await walk(load, 'enoch', { direction: 'up', depth: 3 });
|
||||||
|
expect(names(up)).toEqual({ Enoch: [{ Cain: [{ Adam: [] }, { Eve: [] }] }] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a long line stops at the limit and says there is more', async () => {
|
||||||
|
const tree = await walk(load, 'adam', { direction: 'down', depth: 3, max: 2 });
|
||||||
|
expect(countKids(tree)).toBeLessThanOrEqual(2 + 1); // two taken at the top, each may take nothing more
|
||||||
|
expect(tree.more).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('someone missing from the data still appears by name', async () => {
|
||||||
|
const tree = await walk(load, 'adam', { direction: 'down', depth: 3 });
|
||||||
|
const enoch = tree.kids[0].kids[0];
|
||||||
|
expect(enoch.kids[0].name).toBe('Irad'); // Enoch's child Irad has no record but is named by the link
|
||||||
|
});
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
// Plain-English meanings for the grammar labels on the original-language words ("Verb - Aorist Indicative Active -
|
||||||
|
// 3rd Person Singular"). The labels come with the interlinear text; this only explains them, so a reader who never
|
||||||
|
// studied Greek or Hebrew can see what "aorist" or "Hiphil" tells them. Meanings are the usual textbook ones and
|
||||||
|
// are a guide, not a rule: the context decides.
|
||||||
|
|
||||||
|
// Hebrew verbs name a "stem" (how the action is done); when one is present, Perfect, Imperfect and the rest
|
||||||
|
// mean the Hebrew thing, not the Greek one.
|
||||||
|
const HEBREW_STEMS = [
|
||||||
|
['qal', 'The basic, simple action ("he kept").'],
|
||||||
|
['niphal', 'Often passive or reflexive ("it was kept", "he kept himself").'],
|
||||||
|
['piel', 'Intensive or repeated action ("he kept carefully").'],
|
||||||
|
['pual', 'The passive of the Piel ("it was kept carefully").'],
|
||||||
|
['hithpael', 'Reflexive or repeated action ("he kept himself busy").'],
|
||||||
|
['hiphil', 'Causing the action ("he made someone keep").'],
|
||||||
|
['hophal', 'The passive of the Hiphil ("he was made to keep").'],
|
||||||
|
];
|
||||||
|
const HEBREW_FORMS = [
|
||||||
|
['perfect', 'Action seen as complete, often past ("he kept").'],
|
||||||
|
['imperfect', 'Action seen as incomplete: ongoing, repeated or still to come ("he keeps", "he will keep").'],
|
||||||
|
['imperative', 'A command ("keep!").'],
|
||||||
|
['infinitive construct', 'The verb as a noun, linked to what follows ("keeping of…").'],
|
||||||
|
['infinitive absolute', 'The verb as a noun on its own, often to stress the action ("keeping, keep").'],
|
||||||
|
['participle', 'A verb used like an adjective or noun ("keeping", "the one who keeps").'],
|
||||||
|
];
|
||||||
|
const GREEK_TENSES = [
|
||||||
|
['present', 'Ongoing or repeated action ("is doing").'],
|
||||||
|
['imperfect', 'Past action that was ongoing or repeated ("was doing").'],
|
||||||
|
['future', 'Action that will happen ("will do").'],
|
||||||
|
['aorist', 'Action seen as a whole, with no focus on how long it took. Often just "did".'],
|
||||||
|
['perfect', 'A finished action whose results still stand ("has done").'],
|
||||||
|
['pluperfect', 'A finished action whose results stood at some past time ("had done").'],
|
||||||
|
];
|
||||||
|
const MOODS = [
|
||||||
|
['indicative', 'States something as a fact or asks a question.'],
|
||||||
|
['subjunctive', 'Possibility, purpose or doubt ("may", "might", "let us").'],
|
||||||
|
['imperative', 'A command or request.'],
|
||||||
|
['optative', 'A wish or a remote possibility ("may it be").'],
|
||||||
|
['infinitive', 'The verb as a noun ("to do").'],
|
||||||
|
['participle', 'A verb used like an adjective ("-ing", "the one who…").'],
|
||||||
|
];
|
||||||
|
const VOICES = [
|
||||||
|
['middle or passive', 'The form can mean the subject acts on itself, or receives the action; context decides.'],
|
||||||
|
['middle', 'The subject acts on itself or for its own benefit.'],
|
||||||
|
['passive', 'The subject receives the action ("was done").'],
|
||||||
|
['active', 'The subject does the action.'],
|
||||||
|
];
|
||||||
|
const CASES = [
|
||||||
|
['nominative', 'The subject of the sentence, or a name for something.'],
|
||||||
|
['genitive', 'Belonging, source or description. Often "of".'],
|
||||||
|
['dative', 'The one the action is to, for or by. Often "to", "for", "in", "with".'],
|
||||||
|
['accusative', 'The direct object, the thing acted on.'],
|
||||||
|
['vocative', 'The one being spoken to.'],
|
||||||
|
];
|
||||||
|
const PARTS = [
|
||||||
|
['noun', 'A person, place or thing.'],
|
||||||
|
['verb', 'An action or state.'],
|
||||||
|
['adjective', 'Describes a noun.'],
|
||||||
|
['adverb', 'Describes a verb, adjective or another adverb.'],
|
||||||
|
['article', 'The word "the".'],
|
||||||
|
['conjunction', 'Joins words or clauses ("and", "but", "for").'],
|
||||||
|
['preposition', 'Shows how a noun relates to something else ("in", "to", "from").'],
|
||||||
|
['personal pronoun', 'Stands in for a person ("he", "you").'],
|
||||||
|
['relative pronoun', 'Links a clause back to a noun ("who", "which").'],
|
||||||
|
['demonstrative pronoun', 'Points something out ("this", "that").'],
|
||||||
|
['reflexive pronoun', 'Points back to the subject ("himself").'],
|
||||||
|
['interrogative pronoun', 'Asks a question ("who?", "what?").'],
|
||||||
|
['pronoun', 'Stands in for a noun.'],
|
||||||
|
['particle', 'A small word that adds emphasis or links ideas.'],
|
||||||
|
['interjection', 'An exclamation.'],
|
||||||
|
['numeral', 'A number.'],
|
||||||
|
];
|
||||||
|
const PREFIXES = [
|
||||||
|
['conjunctive waw', '"And" (or "but", "then") joined to the front of the next word.'],
|
||||||
|
['preposition-b', '"In", "with" or "by", joined to the front of the next word.'],
|
||||||
|
['preposition-l', '"To" or "for", joined to the front of the next word.'],
|
||||||
|
['preposition-k', '"Like" or "as", joined to the front of the next word.'],
|
||||||
|
['preposition-m', '"From", joined to the front of the next word.'],
|
||||||
|
['construct', 'Linked to the next word, usually "of".'],
|
||||||
|
];
|
||||||
|
const PERSONS = { '1st': 'I / we', first: 'I / we', '2nd': 'you', second: 'you', '3rd': 'he / she / it / they', third: 'he / she / it / they' };
|
||||||
|
|
||||||
|
const NUMBER_WORDS = { singular: 'one', plural: 'more than one', dual: 'two' };
|
||||||
|
|
||||||
|
// -> [{ term: 'Aorist', meaning: '…' }] in the order the terms appear in the label; [] when there is nothing to say.
|
||||||
|
export function grammarNotes(label) {
|
||||||
|
const text = String(label ?? '').toLowerCase();
|
||||||
|
if (!text.trim()) return [];
|
||||||
|
const isHebrewVerb = HEBREW_STEMS.some(([t]) => new RegExp(`\\b${t}\\b`).test(text));
|
||||||
|
const found = [];
|
||||||
|
const add = (table, tableName) => {
|
||||||
|
for (const [term, meaning] of table) {
|
||||||
|
const m = new RegExp(`(?:^|[^a-z-])${term.replace(/[-]/g, '[- ]?')}(?![a-z])`).exec(text);
|
||||||
|
if (m) found.push({ at: m.index, term, meaning, tableName });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
add(PARTS, 'part');
|
||||||
|
if (isHebrewVerb) { add(HEBREW_STEMS, 'stem'); add(HEBREW_FORMS, 'form'); } else { add(GREEK_TENSES, 'tense'); }
|
||||||
|
add(MOODS.filter(([t]) => !isHebrewVerb || (t !== 'indicative' && t !== 'subjunctive' && t !== 'optative' && !['imperative', 'infinitive', 'participle'].includes(t))), 'mood');
|
||||||
|
if (!isHebrewVerb) add(VOICES, 'voice'); else add([['active', VOICES[3][1]], ['passive', VOICES[2][1]]], 'voice');
|
||||||
|
add(CASES, 'case');
|
||||||
|
add(PREFIXES, 'prefix');
|
||||||
|
// the same label can match a short term inside a longer one ("middle" inside "middle or passive", "pronoun" inside "personal pronoun")
|
||||||
|
const covered = (a, b) => b !== a && b.term.length > a.term.length && b.term.replace(/-/g, ' ').includes(a.term.replace(/-/g, ' ')) && a.at >= b.at - 1 && a.at < b.at + b.term.length + 1;
|
||||||
|
const kept = found.filter((a) => !found.some((b) => covered(a, b) && (b.tableName === a.tableName || b.tableName === 'prefix')));
|
||||||
|
const person = text.match(/\b(1st|2nd|3rd|first|second|third)\s+person\b/);
|
||||||
|
const number = text.match(/\b(singular|plural|dual)\b/);
|
||||||
|
const first = (t) => t.charAt(0).toUpperCase() + t.slice(1);
|
||||||
|
const notes = kept.sort((a, b) => a.at - b.at).map(({ term, meaning }) => ({ term: first(term), meaning }));
|
||||||
|
if (person) notes.push({ term: `${person[1]} person${number ? ` ${number[1]}` : ''}`.replace(/^\w/, (c) => c.toUpperCase()), meaning: `${PERSONS[person[1]]}${number ? ` (${NUMBER_WORDS[number[1]]})` : ''}.` });
|
||||||
|
return notes;
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { test, expect } from 'vitest';
|
||||||
|
import { grammarNotes } from './grammar.js';
|
||||||
|
|
||||||
|
const terms = (label) => grammarNotes(label).map((n) => n.term);
|
||||||
|
|
||||||
|
test('a Greek verb is explained part by part', () => {
|
||||||
|
const notes = grammarNotes('Verb - Aorist Indicative Active - 3rd Person Singular');
|
||||||
|
expect(notes.map((n) => n.term)).toEqual(['Verb', 'Aorist', 'Indicative', 'Active', '3rd person singular']);
|
||||||
|
expect(notes.find((n) => n.term === 'Aorist').meaning).toMatch(/as a whole/);
|
||||||
|
expect(notes.at(-1).meaning).toBe('he / she / it / they (one).');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nouns and articles give their case', () => {
|
||||||
|
expect(terms('Noun - Nominative Masculine Singular')).toEqual(['Noun', 'Nominative']);
|
||||||
|
expect(terms('Article - Genitive Neuter Plural')).toEqual(['Article', 'Genitive']);
|
||||||
|
expect(terms('Personal / Relative Pronoun - Accusative 1st Person Singular')).toContain('Accusative');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('moods, participles and voices that share words are not double counted', () => {
|
||||||
|
expect(terms('Verb - Present Participle Middle or Passive - Nominative Masculine Singular')).toEqual(['Verb', 'Present', 'Participle', 'Middle or passive', 'Nominative']);
|
||||||
|
expect(terms('Verb - Perfect Infinitive Active')).toEqual(['Verb', 'Perfect', 'Infinitive', 'Active']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a Hebrew verb is read with Hebrew meanings, not Greek ones', () => {
|
||||||
|
const notes = grammarNotes('Verb - Qal - Perfect - third person masculine singular');
|
||||||
|
expect(notes.map((n) => n.term)).toEqual(['Verb', 'Qal', 'Perfect', 'Third person singular']);
|
||||||
|
expect(notes.find((n) => n.term === 'Perfect').meaning).toMatch(/complete/);
|
||||||
|
expect(grammarNotes('Verb - Hiphil - Imperfect - third person masculine singular').find((n) => n.term === 'Hiphil').meaning).toMatch(/Causing/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Hebrew words with attached prefixes explain the prefix', () => {
|
||||||
|
const notes = grammarNotes('Preposition-b | Noun - feminine singular');
|
||||||
|
expect(notes.map((n) => n.term)).toEqual(expect.arrayContaining(['Noun', 'Preposition-b']));
|
||||||
|
expect(notes.find((n) => n.term === 'Preposition-b').meaning).toMatch(/joined to the front/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nothing to explain gives an empty list', () => {
|
||||||
|
expect(grammarNotes('')).toEqual([]);
|
||||||
|
expect(grammarNotes(null)).toEqual([]);
|
||||||
|
expect(grammarNotes('???')).toEqual([]);
|
||||||
|
});
|
||||||
@@ -25,6 +25,7 @@ export function weeklySummary(projects, now = Date.now()) {
|
|||||||
ownWords: ownWordsTotal,
|
ownWords: ownWordsTotal,
|
||||||
ownShare: shares.length ? Math.round(shares.reduce((a, b) => a + b, 0) / shares.length) : null,
|
ownShare: shares.length ? Math.round(shares.reduce((a, b) => a + b, 0) / shares.length) : null,
|
||||||
recalls: all.reduce((n, { chunk }) => n + (chunk.recalls ?? []).filter((r) => now - (r.at ?? 0) < WEEK_MS).length, 0),
|
recalls: all.reduce((n, { chunk }) => n + (chunk.recalls ?? []).filter((r) => now - (r.at ?? 0) < WEEK_MS).length, 0),
|
||||||
|
fiveMinutes: all.reduce((n, { chunk }) => n + (chunk.fiveMinutes ?? []).filter((f) => now - (f.at ?? 0) < WEEK_MS).length, 0),
|
||||||
teachBacks: recent.filter(({ chunk }) => (chunk.teachBack ?? '').trim()).length,
|
teachBacks: recent.filter(({ chunk }) => (chunk.teachBack ?? '').trim()).length,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,3 +46,8 @@ test('slipped lines and follow-ups are collected, open follow-ups first', () =>
|
|||||||
expect(slippedList(projects)[0].items).toEqual(['the serpent', 'who spoke']);
|
expect(slippedList(projects)[0].items).toEqual(['the serpent', 'who spoke']);
|
||||||
expect(followUpList(projects).map((f) => f.text)).toEqual(['call Sam', 'done thing']);
|
expect(followUpList(projects).map((f) => f.text)).toEqual(['call Sam', 'done thing']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('five-minute sessions this week are counted', () => {
|
||||||
|
const p = [study([chunk('a', { fiveMinutes: [{ at: NOW - 1000, field: 'observation' }, { at: NOW - 3 * WEEK_MS, field: 'application' }] })])];
|
||||||
|
expect(weeklySummary(p, NOW).fiveMinutes).toBe(1);
|
||||||
|
});
|
||||||
@@ -41,6 +41,7 @@ export function mergeChunk(mine, theirs) {
|
|||||||
const noteKeys = new Set([...Object.keys(newer.verseNotes ?? {}), ...Object.keys(older.verseNotes ?? {})]);
|
const noteKeys = new Set([...Object.keys(newer.verseNotes ?? {}), ...Object.keys(older.verseNotes ?? {})]);
|
||||||
out.verseNotes = Object.fromEntries([...noteKeys].map((k) => [k, mergeText(newer.verseNotes?.[k], older.verseNotes?.[k])]));
|
out.verseNotes = Object.fromEntries([...noteKeys].map((k) => [k, mergeText(newer.verseNotes?.[k], older.verseNotes?.[k])]));
|
||||||
out.firstDraft = [newer.firstDraft, older.firstDraft].filter(Boolean).sort((a, b) => a.at - b.at)[0];
|
out.firstDraft = [newer.firstDraft, older.firstDraft].filter(Boolean).sort((a, b) => a.at - b.at)[0];
|
||||||
|
out.fiveMinutes = union(newer.fiveMinutes, older.fiveMinutes);
|
||||||
out.followUps = mergeById(newer.followUps, older.followUps);
|
out.followUps = mergeById(newer.followUps, older.followUps);
|
||||||
out.recalls = union(newer.recalls, older.recalls);
|
out.recalls = union(newer.recalls, older.recalls);
|
||||||
out.editedAt = Math.max(mine.editedAt ?? 0, theirs.editedAt ?? 0) || undefined;
|
out.editedAt = Math.max(mine.editedAt ?? 0, theirs.editedAt ?? 0) || undefined;
|
||||||
@@ -77,7 +78,7 @@ export function mergeStudies(mine, theirs) {
|
|||||||
}
|
}
|
||||||
const ids = new Set(chapters.flatMap((c) => c.chunks.map((k) => k.id)));
|
const ids = new Set(chapters.flatMap((c) => c.chunks.map((k) => k.id)));
|
||||||
return {
|
return {
|
||||||
project: { ...mine, chapters, selectedChunkId: ids.has(mine.selectedChunkId) ? mine.selectedChunkId : [...ids][0] ?? null },
|
project: { ...mine, chapters, ...(mine.readingList || theirs.readingList ? { readingList: mergeById(mine.readingList, theirs.readingList) } : {}), selectedChunkId: ids.has(mine.selectedChunkId) ? mine.selectedChunkId : [...ids][0] ?? null },
|
||||||
stats,
|
stats,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -49,3 +49,15 @@ test('identical copies merge to themselves', () => {
|
|||||||
expect(project).toEqual(s);
|
expect(project).toEqual(s);
|
||||||
expect(stats).toEqual({ added: 0, merged: 0 });
|
expect(stats).toEqual({ added: 0, merged: 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
import { mergeStudies as mergeBoth } from './mergeStudies.js';
|
||||||
|
|
||||||
|
test('merging two copies keeps every reading-list item, mine winning when both changed one', () => {
|
||||||
|
const base = { id: 'p', title: 'T', chapters: [] };
|
||||||
|
const mine = { ...base, readingList: [{ id: 'a', title: 'Mounce', done: true }, { id: 'b', title: 'Carson', done: false }] };
|
||||||
|
const theirs = { ...base, readingList: [{ id: 'a', title: 'Mounce', done: false }, { id: 'c', title: 'Stott', done: false }] };
|
||||||
|
const { project } = mergeBoth(mine, theirs);
|
||||||
|
expect(project.readingList.map((i) => i.id).sort()).toEqual(['a', 'b', 'c']);
|
||||||
|
expect(project.readingList.find((i) => i.id === 'a').done).toBe(true);
|
||||||
|
expect(mergeBoth(base, base).project.readingList).toBeUndefined();
|
||||||
|
});
|
||||||
@@ -57,3 +57,21 @@ test('a study works offline once every book in it is saved in its translation',
|
|||||||
test('licensed translations cannot be saved offline', () => {
|
test('licensed translations cannot be saved offline', () => {
|
||||||
expect(offlineStatus({ translation: 'ESV', chapters: [{ book: 'John', bookAbbrev: 'JHN' }] }, {}).state).toBe('licensed');
|
expect(offlineStatus({ translation: 'ESV', chapters: [{ book: 'John', bookAbbrev: 'JHN' }] }, {}).state).toBe('licensed');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
import { READER_TRANSLATIONS, helloaoCode } from './bibleText.js';
|
||||||
|
import { parseBibleChapter } from './bibleUtils.jsx';
|
||||||
|
|
||||||
|
test('the KJV, ASV and LSV are free translations that can be saved offline and are fetched by their helloao code', () => {
|
||||||
|
for (const [id, code] of [['KJV', 'eng_kjv'], ['ASV', 'eng_asv'], ['LSV', 'eng_lsv'], ['NET', 'eng_net'], ['BSB', 'BSB']]) {
|
||||||
|
expect(READER_TRANSLATIONS.some((t) => t.id === id)).toBe(true);
|
||||||
|
expect(helloaoCode(id)).toBe(code);
|
||||||
|
expect(canSaveOffline(id)).toBe(true);
|
||||||
|
}
|
||||||
|
expect(canSaveOffline('ESV')).toBe(false);
|
||||||
|
expect(READER_TRANSLATIONS.find((t) => t.id === 'LSV').notice).toMatch(/Covenant Press/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a paragraph mark in the KJV text is dropped from the verse', () => {
|
||||||
|
const verses = parseBibleChapter({ chapter: { content: [{ type: 'verse', number: 16, content: [{ text: '¶ For God so loved the world' }] }] } });
|
||||||
|
expect(verses[0].text).toBe('For God so loved the world');
|
||||||
|
});
|
||||||
@@ -33,6 +33,14 @@ export function matchBooks(typed) {
|
|||||||
return BOOKS.filter((b) => b.key.startsWith(key));
|
return BOOKS.filter((b) => b.key.startsWith(key));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The one book a typed name means when it is the book's full name or a known short form ("Rom", "1 Cor", "Jn"), never a
|
||||||
|
// mere beginning of a name: "Number" is not Numbers, "Act" is not Acts. For finding references inside prose.
|
||||||
|
export function exactBook(typed) {
|
||||||
|
const key = squash(typed);
|
||||||
|
if (!key) return null;
|
||||||
|
return BOOKS.find((b) => b.key === key) ?? (ALIASES[key] ? BOOKS.find((b) => b.abbrev === ALIASES[key]) : null) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
// "John 3:16" -> [{ book: 'JHN', bookName: 'John', chapter: 3, verse: 16 }]; ambiguous names ("jo")
|
// "John 3:16" -> [{ book: 'JHN', bookName: 'John', chapter: 3, verse: 16 }]; ambiguous names ("jo")
|
||||||
// give several. A bare book ("romans") opens chapter 1. Chapters past the end are rejected.
|
// give several. A bare book ("romans") opens chapter 1. Chapters past the end are rejected.
|
||||||
export function parsePassageRef(text) {
|
export function parsePassageRef(text) {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
+127
-5
@@ -1,7 +1,7 @@
|
|||||||
// The prayer journal: requests you are praying about, the verses tied to them, the days you prayed, and how
|
// The prayer journal: requests you are praying about, the verses tied to them, the days you prayed, and how
|
||||||
// God answered. Pure helpers for making, changing, merging and finding prayers. A prayer is
|
// God answered. Pure helpers for making, changing, merging and finding prayers. A prayer is
|
||||||
// { id, text, forWhom, verses: [{ book, chapter, verse, label }], prayedOn: ['2026-10-06', ...], createdAt, updatedAt,
|
// { id, text, forWhom, verses: [{ book, chapter, verse, label }], prayedOn: ['2026-10-06', ...], createdAt, updatedAt,
|
||||||
// answeredAt, answerNote }; a deleted one is just { id, deleted: true, updatedAt } so other devices drop it too.
|
// answeredAt, answerNote, status: '' | 'waiting' | 'partial', pinned, rank }; a deleted one is just { id, deleted: true, updatedAt } so other devices drop it too.
|
||||||
import { formatPassage, parsePassageRef } from './passageRef.js';
|
import { formatPassage, parsePassageRef } from './passageRef.js';
|
||||||
|
|
||||||
const DAY_MS = 86_400_000;
|
const DAY_MS = 86_400_000;
|
||||||
@@ -10,7 +10,7 @@ const MAX_DAYS = 400;
|
|||||||
const makeId = () => (globalThis.crypto?.randomUUID?.() ?? `p-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`);
|
const makeId = () => (globalThis.crypto?.randomUUID?.() ?? `p-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`);
|
||||||
|
|
||||||
export function newPrayer({ text, forWhom = '', verses = [] }, now = Date.now()) {
|
export function newPrayer({ text, forWhom = '', verses = [] }, now = Date.now()) {
|
||||||
return { id: makeId(), text: String(text).trim().slice(0, 2000), forWhom: String(forWhom).trim().slice(0, 80), verses: verses.slice(0, 8), prayedOn: [], createdAt: now, updatedAt: now, answeredAt: null, answerNote: '' };
|
return { id: makeId(), text: String(text).trim().slice(0, 2000), forWhom: String(forWhom).trim().slice(0, 80), verses: verses.slice(0, 8), prayedOn: [], createdAt: now, updatedAt: now, answeredAt: null, answerNote: '', status: '', pinned: false, rank: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
export const touch = (prayer, patch, now = Date.now()) => ({ ...prayer, ...patch, updatedAt: Math.max(now, (prayer.updatedAt ?? 0) + 1) });
|
export const touch = (prayer, patch, now = Date.now()) => ({ ...prayer, ...patch, updatedAt: Math.max(now, (prayer.updatedAt ?? 0) + 1) });
|
||||||
@@ -21,8 +21,33 @@ export function togglePrayedToday(prayer, today, now = Date.now()) {
|
|||||||
return touch(prayer, { prayedOn: has ? prayer.prayedOn.filter((d) => d !== today) : [...prayer.prayedOn, today].sort().slice(-MAX_DAYS) }, now);
|
return touch(prayer, { prayedOn: has ? prayer.prayedOn.filter((d) => d !== today) : [...prayer.prayedOn, today].sort().slice(-MAX_DAYS) }, now);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const markAnswered = (prayer, note, now = Date.now()) => touch(prayer, { answeredAt: now, answerNote: String(note ?? '').trim().slice(0, 1000) }, now);
|
// Answering takes a prayer off the pinned list and clears any "waiting" / "partly answered" mark
|
||||||
export const reopen = (prayer, now = Date.now()) => touch(prayer, { answeredAt: null, answerNote: '' }, now);
|
export const markAnswered = (prayer, note, now = Date.now()) => touch(prayer, { answeredAt: now, answerNote: String(note ?? '').trim().slice(0, 1000), status: '', pinned: false, rank: null }, now);
|
||||||
|
export const reopen = (prayer, now = Date.now()) => touch(prayer, { answeredAt: null, answerNote: '', status: '' }, now);
|
||||||
|
|
||||||
|
// Not a clean yes: "waiting" (still waiting on God) or "partial" (partly answered; the note says how). Both stay open.
|
||||||
|
export const STATUSES = { waiting: 'Waiting', partial: 'Partly answered' };
|
||||||
|
export function setStatus(prayer, status, note = '', now = Date.now()) {
|
||||||
|
const next = STATUSES[status] ? status : '';
|
||||||
|
return touch(prayer, { status: next, answerNote: next === 'partial' ? String(note ?? '').trim().slice(0, 1000) : '' }, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pinned prayers keep an order (`rank`, lowest first). Pinning puts one at the end; these return a new list.
|
||||||
|
export const pinned = (list) => live(list).filter((p) => p.pinned && !p.answeredAt).sort((a, b) => (a.rank ?? 0) - (b.rank ?? 0) || (a.createdAt ?? 0) - (b.createdAt ?? 0));
|
||||||
|
export function setPinned(list, id, on, now = Date.now()) {
|
||||||
|
const top = pinned(list).reduce((m, p) => Math.max(m, p.rank ?? 0), 0);
|
||||||
|
return list.map((p) => (p.id === id && !p.deleted ? touch(p, on ? { pinned: true, rank: top + 1 } : { pinned: false, rank: null }, now) : p));
|
||||||
|
}
|
||||||
|
// dir: -1 moves the prayer up the pinned list, 1 moves it down
|
||||||
|
export function movePinned(list, id, dir, now = Date.now()) {
|
||||||
|
const order = pinned(list).map((p) => p.id);
|
||||||
|
const at = order.indexOf(id);
|
||||||
|
const to = at + dir;
|
||||||
|
if (at < 0 || to < 0 || to >= order.length) return list;
|
||||||
|
[order[at], order[to]] = [order[to], order[at]];
|
||||||
|
const rank = new Map(order.map((pid, i) => [pid, i + 1]));
|
||||||
|
return list.map((p) => (rank.has(p.id) && p.rank !== rank.get(p.id) ? touch(p, { rank: rank.get(p.id) }, now) : p));
|
||||||
|
}
|
||||||
|
|
||||||
// Same rules as the server's merge: the newer version of each prayer wins, days prayed are combined, deletions stick.
|
// Same rules as the server's merge: the newer version of each prayer wins, days prayed are combined, deletions stick.
|
||||||
export function mergePrayers(a, b, now = Date.now()) {
|
export function mergePrayers(a, b, now = Date.now()) {
|
||||||
@@ -59,7 +84,14 @@ export function filterPrayers(list, { tab = 'open', query = '' } = {}) {
|
|||||||
return live(list)
|
return live(list)
|
||||||
.filter((p) => (tab === 'open' ? !p.answeredAt : tab === 'answered' ? !!p.answeredAt : true))
|
.filter((p) => (tab === 'open' ? !p.answeredAt : tab === 'answered' ? !!p.answeredAt : true))
|
||||||
.filter((p) => !q || `${p.text} ${p.forWhom} ${p.answerNote} ${(p.verses ?? []).map((v) => v.label).join(' ')}`.toLowerCase().includes(q))
|
.filter((p) => !q || `${p.text} ${p.forWhom} ${p.answerNote} ${(p.verses ?? []).map((v) => v.label).join(' ')}`.toLowerCase().includes(q))
|
||||||
.sort((x, y) => (tab === 'answered' ? (y.answeredAt ?? 0) - (x.answeredAt ?? 0) : (y.updatedAt ?? 0) - (x.updatedAt ?? 0)));
|
.sort((x, y) => {
|
||||||
|
if (tab === 'answered') return (y.answeredAt ?? 0) - (x.answeredAt ?? 0);
|
||||||
|
const px = x.pinned && !x.answeredAt;
|
||||||
|
const py = y.pinned && !y.answeredAt;
|
||||||
|
if (px !== py) return px ? -1 : 1;
|
||||||
|
if (px) return (x.rank ?? 0) - (y.rank ?? 0);
|
||||||
|
return (y.updatedAt ?? 0) - (x.updatedAt ?? 0);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export const daysToAnswer = (p) => (p.answeredAt ? Math.max(0, Math.round((p.answeredAt - p.createdAt) / DAY_MS)) : null);
|
export const daysToAnswer = (p) => (p.answeredAt ? Math.max(0, Math.round((p.answeredAt - p.createdAt) / DAY_MS)) : null);
|
||||||
@@ -73,6 +105,9 @@ export function prayersToMarkdown(list, { date = new Date() } = {}) {
|
|||||||
for (const p of rows) {
|
for (const p of rows) {
|
||||||
out.push(`- ${p.forWhom ? `**${p.forWhom}:** ` : ''}${p.text.replace(/\s*\n\s*/g, ' ')}${p.verses?.length ? ` (${p.verses.map(labelFor).join('; ')})` : ''}`);
|
out.push(`- ${p.forWhom ? `**${p.forWhom}:** ` : ''}${p.text.replace(/\s*\n\s*/g, ' ')}${p.verses?.length ? ` (${p.verses.map(labelFor).join('; ')})` : ''}`);
|
||||||
out.push(` - Asked ${new Date(p.createdAt).toLocaleDateString()}${p.prayedOn?.length ? ` · prayed on ${p.prayedOn.length} day${p.prayedOn.length === 1 ? '' : 's'}` : ''}`);
|
out.push(` - Asked ${new Date(p.createdAt).toLocaleDateString()}${p.prayedOn?.length ? ` · prayed on ${p.prayedOn.length} day${p.prayedOn.length === 1 ? '' : 's'}` : ''}`);
|
||||||
|
if (p.pinned && !p.answeredAt) out.push(' - Pinned');
|
||||||
|
if (p.status === 'waiting') out.push(' - Waiting');
|
||||||
|
if (p.status === 'partial') out.push(` - Partly answered${p.answerNote ? `: ${p.answerNote.replace(/\s*\n\s*/g, ' ')}` : ''}`);
|
||||||
if (p.answeredAt) out.push(` - Answered ${new Date(p.answeredAt).toLocaleDateString()}${p.answerNote ? `: ${p.answerNote.replace(/\s*\n\s*/g, ' ')}` : ''}`);
|
if (p.answeredAt) out.push(` - Answered ${new Date(p.answeredAt).toLocaleDateString()}${p.answerNote ? `: ${p.answerNote.replace(/\s*\n\s*/g, ' ')}` : ''}`);
|
||||||
}
|
}
|
||||||
out.push('');
|
out.push('');
|
||||||
@@ -82,3 +117,90 @@ export function prayersToMarkdown(list, { date = new Date() } = {}) {
|
|||||||
if (!items.length) out.push('No prayers yet.', '');
|
if (!items.length) out.push('No prayers yet.', '');
|
||||||
return out.join('\n');
|
return out.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Import -------------------------------------------------------------------------------------------------
|
||||||
|
// Reads a file made by "Download" (markdown) or "Download JSON" (also a full backup, or a bare list of prayers).
|
||||||
|
// Returns { prayers, skipped } where `prayers` are new entries only: ones already in `existing` (same id, or the
|
||||||
|
// same words for the same person) are left out.
|
||||||
|
const key = (p) => `${String(p.forWhom ?? '').trim().toLowerCase()}|${String(p.text ?? '').trim().toLowerCase().replace(/\s+/g, ' ')}`;
|
||||||
|
const stamp = (v, fallback) => (Number.isFinite(v) && v > 0 ? v : fallback);
|
||||||
|
const dateMs = (str, fallback) => { const t = Date.parse(str); return Number.isFinite(t) ? t : fallback; };
|
||||||
|
|
||||||
|
function fromJson(raw, now) {
|
||||||
|
const rows = Array.isArray(raw) ? raw : Array.isArray(raw?.prayers) ? raw.prayers : null;
|
||||||
|
if (!rows) return null;
|
||||||
|
const out = [];
|
||||||
|
let skipped = 0;
|
||||||
|
for (const r of rows) {
|
||||||
|
if (!r || typeof r.text !== 'string' || !r.text.trim() || r.deleted) { skipped += 1; continue; }
|
||||||
|
const base = newPrayer({ text: r.text, forWhom: typeof r.forWhom === 'string' ? r.forWhom : '', verses: Array.isArray(r.verses) ? r.verses.filter((v) => v && typeof v.book === 'string' && Number.isInteger(v.chapter)) : [] }, now);
|
||||||
|
out.push({
|
||||||
|
...base,
|
||||||
|
id: typeof r.id === 'string' && /^[\w-]{1,64}$/.test(r.id) ? r.id : base.id,
|
||||||
|
prayedOn: (Array.isArray(r.prayedOn) ? r.prayedOn.filter((d) => /^\d{4}-\d{2}-\d{2}$/.test(d)) : []).sort().slice(-MAX_DAYS),
|
||||||
|
createdAt: stamp(r.createdAt, now), updatedAt: stamp(r.updatedAt, now),
|
||||||
|
answeredAt: stamp(r.answeredAt, null), answerNote: typeof r.answerNote === 'string' ? r.answerNote.trim().slice(0, 1000) : '',
|
||||||
|
status: STATUSES[r.status] ? r.status : '', pinned: r.pinned === true && !r.answeredAt, rank: Number.isFinite(r.rank) ? r.rank : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { prayers: out, skipped };
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromMarkdown(text, now) {
|
||||||
|
const out = [];
|
||||||
|
let section = '';
|
||||||
|
let cur = null;
|
||||||
|
for (const line of String(text).split(/\r?\n/)) {
|
||||||
|
const head = line.match(/^##\s+(.+?)\s*$/);
|
||||||
|
if (head) { section = head[1].toLowerCase(); cur = null; continue; }
|
||||||
|
const item = line.match(/^- (?:\*\*(.+?):\*\* )?(.+)$/);
|
||||||
|
if (item && section) {
|
||||||
|
let body = item[2].trim();
|
||||||
|
let verses = [];
|
||||||
|
const tail = body.match(/\s\(([^()]*)\)$/);
|
||||||
|
if (tail) {
|
||||||
|
const r = parseVerses(tail[1]);
|
||||||
|
if (r.verses.length && !r.unknown.length) { verses = r.verses; body = body.slice(0, tail.index).trim(); }
|
||||||
|
}
|
||||||
|
cur = { ...newPrayer({ text: body, forWhom: item[1] ?? '', verses }, now) };
|
||||||
|
if (section === 'answered') cur.answeredAt = now;
|
||||||
|
out.push(cur);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const sub = cur && line.match(/^\s+- (.+)$/);
|
||||||
|
if (!sub) continue;
|
||||||
|
const m = sub[1];
|
||||||
|
let x;
|
||||||
|
if ((x = m.match(/^Asked (.+?)(?: · prayed on \d+ days?)?$/))) { cur.createdAt = dateMs(x[1], now); cur.updatedAt = cur.createdAt; }
|
||||||
|
else if ((x = m.match(/^Answered ([^:]+?)(?:: (.*))?$/))) { cur.answeredAt = dateMs(x[1], now); cur.answerNote = (x[2] ?? '').slice(0, 1000); }
|
||||||
|
else if (m === 'Pinned') { cur.pinned = !cur.answeredAt; }
|
||||||
|
else if (m === 'Waiting') cur.status = 'waiting';
|
||||||
|
else if ((x = m.match(/^Partly answered(?:: (.*))?$/))) { cur.status = 'partial'; cur.answerNote = (x[1] ?? '').slice(0, 1000); }
|
||||||
|
}
|
||||||
|
// assign ranks to pinned ones in file order
|
||||||
|
let rank = 0;
|
||||||
|
for (const p of out) if (p.pinned) { rank += 1; p.rank = rank; }
|
||||||
|
return { prayers: out, skipped: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parsePrayerImport(fileText, existing = [], now = Date.now()) {
|
||||||
|
const trimmed = String(fileText ?? '').trim();
|
||||||
|
let parsed = null;
|
||||||
|
if (/^[[{]/.test(trimmed)) { try { parsed = fromJson(JSON.parse(trimmed), now); } catch { parsed = null; } }
|
||||||
|
if (!parsed && !/^[[{]/.test(trimmed)) parsed = fromMarkdown(trimmed, now);
|
||||||
|
if (!parsed) return { prayers: [], skipped: 0, error: 'That file isn’t a prayer journal.' };
|
||||||
|
const have = live(existing);
|
||||||
|
const ids = new Set((existing ?? []).map((p) => p.id));
|
||||||
|
const seen = new Set(have.map(key));
|
||||||
|
const prayers = [];
|
||||||
|
let skipped = parsed.skipped;
|
||||||
|
for (const p of parsed.prayers) {
|
||||||
|
if (ids.has(p.id) || seen.has(key(p))) { skipped += 1; continue; }
|
||||||
|
seen.add(key(p));
|
||||||
|
prayers.push(p);
|
||||||
|
}
|
||||||
|
if (!prayers.length && !skipped) return { prayers, skipped, error: 'No prayers found in that file.' };
|
||||||
|
return { prayers, skipped };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const prayersToJson = (list) => JSON.stringify({ kind: 'prayer-journal', version: 1, prayers: live(list) }, null, 2);
|
||||||
@@ -68,3 +68,52 @@ test('the Markdown separates what is being prayed for from what was answered', (
|
|||||||
expect(md).toContain(': Hired!');
|
expect(md).toContain(': Hired!');
|
||||||
expect(prayersToMarkdown([])).toContain('No prayers yet.');
|
expect(prayersToMarkdown([])).toContain('No prayers yet.');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
import { movePinned, parsePrayerImport, pinned, prayersToJson, setPinned, setStatus } from './prayers.js';
|
||||||
|
|
||||||
|
test('pinning orders prayers, moving swaps them, and answering unpins', () => {
|
||||||
|
let list = ['a', 'b', 'c'].map((t, i) => newPrayer({ text: t }, T0 + i));
|
||||||
|
const [a, b, c] = list.map((p) => p.id);
|
||||||
|
list = setPinned(list, b, true, T0 + 10);
|
||||||
|
list = setPinned(list, a, true, T0 + 11);
|
||||||
|
expect(pinned(list).map((p) => p.id)).toEqual([b, a]);
|
||||||
|
list = movePinned(list, a, -1, T0 + 12);
|
||||||
|
expect(pinned(list).map((p) => p.id)).toEqual([a, b]);
|
||||||
|
expect(movePinned(list, a, -1)).toBe(list); // already first
|
||||||
|
expect(filterPrayers(list, { tab: 'open' }).map((p) => p.id)[0]).toBe(a);
|
||||||
|
expect(filterPrayers(list, { tab: 'open' }).map((p) => p.id)).toEqual([a, b, c]);
|
||||||
|
list = list.map((p) => (p.id === a ? markAnswered(p, '', T0 + 20) : p));
|
||||||
|
expect(pinned(list).map((p) => p.id)).toEqual([b]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('waiting and partly answered stay open; partial keeps a note; answering clears them', () => {
|
||||||
|
let p = setStatus(newPrayer({ text: 'x' }, T0), 'partial', ' half way ', T0 + 1);
|
||||||
|
expect(p).toMatchObject({ status: 'partial', answerNote: 'half way', answeredAt: null });
|
||||||
|
p = setStatus(p, 'waiting', 'ignored', T0 + 2);
|
||||||
|
expect(p).toMatchObject({ status: 'waiting', answerNote: '' });
|
||||||
|
expect(setStatus(p, '', '', T0 + 3).status).toBe('');
|
||||||
|
expect(markAnswered(p, 'done', T0 + 4)).toMatchObject({ status: '', answeredAt: T0 + 4 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('import reads our markdown and JSON, and skips prayers already in the journal', () => {
|
||||||
|
const src = [
|
||||||
|
{ ...newPrayer({ text: 'Wisdom', forWhom: 'Sam', verses: [{ book: 'JAS', chapter: 1, verse: 5, label: 'James 1:5' }] }, T0), pinned: true, rank: 1, status: 'waiting' },
|
||||||
|
markAnswered(newPrayer({ text: 'A job' }, T0), 'Hired', T0 + DAY),
|
||||||
|
];
|
||||||
|
const md = prayersToMarkdown(src, { date: new Date(T0) });
|
||||||
|
const fromMd = parsePrayerImport(md, [], T0);
|
||||||
|
expect(fromMd.prayers).toHaveLength(2);
|
||||||
|
expect(fromMd.prayers[0]).toMatchObject({ text: 'Wisdom', forWhom: 'Sam', status: 'waiting', pinned: true, answeredAt: null });
|
||||||
|
expect(fromMd.prayers[0].verses[0]).toMatchObject({ book: 'JAS', chapter: 1, verse: 5 });
|
||||||
|
expect(fromMd.prayers[1]).toMatchObject({ text: 'A job', answerNote: 'Hired' });
|
||||||
|
expect(fromMd.prayers[1].answeredAt).toBeTruthy();
|
||||||
|
|
||||||
|
const fromJson = parsePrayerImport(prayersToJson(src), [], T0);
|
||||||
|
expect(fromJson.prayers.map((p) => p.id)).toEqual(src.map((p) => p.id));
|
||||||
|
expect(fromJson.prayers[0]).toMatchObject({ pinned: true, status: 'waiting' });
|
||||||
|
|
||||||
|
expect(parsePrayerImport(prayersToJson(src), [src[0]], T0)).toMatchObject({ skipped: 1, prayers: [expect.objectContaining({ text: 'A job' })] });
|
||||||
|
expect(parsePrayerImport(md, [src[0]], T0).prayers.map((p) => p.text)).toEqual(['A job']); // same words, same person
|
||||||
|
expect(parsePrayerImport('{"nope":1}', []).error).toBeTruthy();
|
||||||
|
expect(parsePrayerImport('', []).error).toBeTruthy();
|
||||||
|
});
|
||||||
@@ -94,6 +94,7 @@ export function saveProjectToStorage(project) {
|
|||||||
lastEdited: updated.lastEdited,
|
lastEdited: updated.lastEdited,
|
||||||
chapterSummary: buildChapterSummary(updated),
|
chapterSummary: buildChapterSummary(updated),
|
||||||
series: (updated.series ?? '').trim(),
|
series: (updated.series ?? '').trim(),
|
||||||
|
archived: !!updated.archived,
|
||||||
tags,
|
tags,
|
||||||
};
|
};
|
||||||
if (existing >= 0) {
|
if (existing >= 0) {
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// Finds Scripture references in your own writing ("see Rom 8:28-30", "Psalm 23", "1 Cor 13:4") so they can be
|
||||||
|
// linked to the verse. Only full book names and the usual short forms count, and the chapter must exist.
|
||||||
|
import { CHAPTER_COUNTS, bookOptions } from './bibleUtils.jsx';
|
||||||
|
import { exactBook } from './passageRef.js';
|
||||||
|
|
||||||
|
// Standard short forms that passageRef doesn't list because it matches the start of a name (strict matching can't).
|
||||||
|
// Left out on purpose: "Mar" (March), "Act" and "Number" (ordinary words).
|
||||||
|
const EXTRA = {
|
||||||
|
gen: 'GEN', exo: 'EXO', lev: 'LEV', num: 'NUM', deu: 'DEU', jos: 'JOS', jdg: 'JDG', rut: 'RUT', '1sa': '1SA', '2sa': '2SA', '1sam': '1SA', '2sam': '2SA',
|
||||||
|
'1ki': '1KI', '2ki': '2KI', '1kin': '1KI', '2kin': '2KI', '1ch': '1CH', '2ch': '2CH', ezr: 'EZR', neh: 'NEH', est: 'EST', pro: 'PRO', ecc: 'ECC',
|
||||||
|
isa: 'ISA', lam: 'LAM', eze: 'EZK', ezk: 'EZK', dan: 'DAN', hos: 'HOS', amo: 'AMO', oba: 'OBA', mic: 'MIC', nah: 'NAM', nam: 'NAM', hab: 'HAB',
|
||||||
|
zep: 'ZEP', hag: 'HAG', zec: 'ZEC', mal: 'MAL', luk: 'LUK', joh: 'JHN', '1co': '1CO', '2co': '2CO', gal: 'GAL', eph: 'EPH', col: 'COL',
|
||||||
|
'1th': '1TH', '2th': '2TH', '1ti': '1TI', '2ti': '2TI', tit: 'TIT', phm: 'PHM', heb: 'HEB', jas: 'JAS', '1pe': '1PE', '2pe': '2PE', '1jo': '1JN', '2jo': '2JN', '3jo': '3JN', rev: 'REV',
|
||||||
|
};
|
||||||
|
const squash = (s) => String(s).toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||||
|
const bookFor = (name) => exactBook(name) ?? (EXTRA[squash(name)] ? bookOptions.find((b) => b.abbrev === EXTRA[squash(name)]) ?? null : null);
|
||||||
|
|
||||||
|
// A capitalised name (up to three words, "Song of Solomon") then a chapter, then optionally :verse and -endVerse
|
||||||
|
const RE = /\b((?:[1-3]\s?)?[A-Z][A-Za-z]*\.?(?:\s(?:of\s)?[A-Z][a-z]*\.?){0,2})\s+(\d{1,3})(?::(\d{1,3})(?:\s?[-–—]\s?(\d{1,3}))?)?/g;
|
||||||
|
|
||||||
|
// -> [{ text, start, end, book, bookName, chapter, verse, endVerse }] in the order they appear (verse/endVerse may be null)
|
||||||
|
export function findReferences(text) {
|
||||||
|
const found = [];
|
||||||
|
const source = String(text ?? '');
|
||||||
|
for (const m of source.matchAll(RE)) {
|
||||||
|
const words = m[1].split(/\s+/);
|
||||||
|
// "See Romans 8" captures "See Romans": try the name without its first words until a book matches
|
||||||
|
for (let drop = 0; drop < words.length; drop += 1) {
|
||||||
|
const name = words.slice(drop).join(' ').replace(/\.$/, '');
|
||||||
|
const book = bookFor(name);
|
||||||
|
if (!book) continue;
|
||||||
|
const chapter = Number(m[2]);
|
||||||
|
if (chapter < 1 || chapter > (CHAPTER_COUNTS[book.abbrev] ?? 0)) break;
|
||||||
|
const lead = m[1].length - words.slice(drop).join(' ').length;
|
||||||
|
const start = m.index + lead;
|
||||||
|
const verse = m[3] ? Number(m[3]) : null;
|
||||||
|
const endVerse = m[4] && Number(m[4]) > verse ? Number(m[4]) : null;
|
||||||
|
found.push({ text: source.slice(start, m.index + m[0].length), start, end: m.index + m[0].length, book: book.abbrev, bookName: book.name, chapter, verse, endVerse });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The references with repeats collapsed, by what they point to
|
||||||
|
export function uniqueReferences(text) {
|
||||||
|
const seen = new Set();
|
||||||
|
return findReferences(text).filter((r) => { const k = `${r.book}.${r.chapter}.${r.verse}.${r.endVerse}`; if (seen.has(k)) return false; seen.add(k); return true; });
|
||||||
|
}
|
||||||
|
|
||||||
|
export const labelFor = (r) => `${r.bookName} ${r.chapter}${r.verse ? `:${r.verse}${r.endVerse ? `–${r.endVerse}` : ''}` : ''}`;
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { test, expect } from 'vitest';
|
||||||
|
import { findReferences, labelFor, uniqueReferences } from './scriptureRefs.js';
|
||||||
|
import { exactBook } from './passageRef.js';
|
||||||
|
|
||||||
|
const refs = (text) => findReferences(text).map((r) => labelFor(r));
|
||||||
|
|
||||||
|
test('references of every common shape are found', () => {
|
||||||
|
expect(refs('See Romans 8:28 and Psalm 23, then 1 Cor 13:4-7.')).toEqual(['Romans 8:28', 'Psalms 23', '1 Corinthians 13:4–7']);
|
||||||
|
expect(refs('Compare Jn 3:16; Gen. 1:1 and Song of Solomon 2:4.')).toEqual(['John 3:16', 'Genesis 1:1', 'Song 2:4']);
|
||||||
|
expect(refs('Phil 2:5-11 shows it, as does Heb 11')).toEqual(['Philippians 2:5–11', 'Hebrews 11']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('standard short forms work, and look-alike words do not', () => {
|
||||||
|
expect(refs('Gen 1:1; Exod 3; Deut 6:4; 1 Sam 17; 2 Kgs 5; Zech 9:9; Rev 21:4')).toEqual(['Genesis 1:1', 'Exodus 3', 'Deuteronomy 6:4', '1 Samuel 17', '2 Kings 5', 'Zechariah 9:9', 'Revelation 21:4']);
|
||||||
|
expect(refs('Mar 5 is the date')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the matched text and its position are exact, even when a word like "See" comes first', () => {
|
||||||
|
const text = 'Look: See Romans 8:28 here';
|
||||||
|
const [r] = findReferences(text);
|
||||||
|
expect(r.text).toBe('Romans 8:28');
|
||||||
|
expect(text.slice(r.start, r.end)).toBe('Romans 8:28');
|
||||||
|
expect(r).toMatchObject({ book: 'ROM', chapter: 8, verse: 28, endVerse: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ordinary words and impossible chapters are not references', () => {
|
||||||
|
expect(refs('Number 5 on the list, Act 3 of the play, Chapter 4, Session 2, Day 12')).toEqual([]);
|
||||||
|
expect(refs('Romans 99:1 and John 40')).toEqual([]);
|
||||||
|
expect(refs('in 1999 and 3 days, Thanks 5')).toEqual([]);
|
||||||
|
expect(refs('')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('repeats collapse and a reversed range is kept as a single verse', () => {
|
||||||
|
expect(uniqueReferences('Rom 8:28 ... Romans 8:28 ... Rom 8:29').map(labelFor)).toEqual(['Romans 8:28', 'Romans 8:29']);
|
||||||
|
expect(findReferences('Rom 8:30-28')[0]).toMatchObject({ verse: 30, endVerse: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('only a full book name or a known short form is a book', () => {
|
||||||
|
expect(exactBook('Romans')?.abbrev).toBe('ROM');
|
||||||
|
expect(exactBook('1 Cor')?.abbrev).toBe('1CO');
|
||||||
|
expect(exactBook('Jn')?.abbrev).toBe('JHN');
|
||||||
|
expect(exactBook('Number')).toBeNull();
|
||||||
|
expect(exactBook('Act')).toBeNull();
|
||||||
|
expect(exactBook('')).toBeNull();
|
||||||
|
});
|
||||||
@@ -30,11 +30,13 @@ export function groupBySeries(entries) {
|
|||||||
return groups.has('') ? [...named, { name: '', items: groups.get('') }] : named;
|
return groups.has('') ? [...named, { name: '', items: groups.get('') }] : named;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function filterStudies(index, { query = '', tags = [], series = '' } = {}) {
|
// archived: false (default) leaves out finished/archived studies, true keeps only them, null keeps all.
|
||||||
|
export function filterStudies(index, { query = '', tags = [], series = '', archived = false } = {}) {
|
||||||
const raw = String(query).trim().toLowerCase();
|
const raw = String(query).trim().toLowerCase();
|
||||||
const tagOnly = raw.startsWith('#');
|
const tagOnly = raw.startsWith('#');
|
||||||
const q = tagOnly ? raw.slice(1).trim() : raw;
|
const q = tagOnly ? raw.slice(1).trim() : raw;
|
||||||
return index.filter((entry) => {
|
return index.filter((entry) => {
|
||||||
|
if (archived !== null && !!entry.archived !== archived) return false;
|
||||||
if (series === '__none') { if ((entry.series ?? '').trim()) return false; } else if (series && (entry.series ?? '').trim() !== series) return false;
|
if (series === '__none') { if ((entry.series ?? '').trim()) return false; } else if (series && (entry.series ?? '').trim() !== series) return false;
|
||||||
const own = (entry.tags ?? []).map((t) => t.toLowerCase());
|
const own = (entry.tags ?? []).map((t) => t.toLowerCase());
|
||||||
if (tags.length && !tags.every((t) => own.includes(t.toLowerCase()))) return false;
|
if (tags.length && !tags.every((t) => own.includes(t.toLowerCase()))) return false;
|
||||||
|
|||||||
@@ -52,3 +52,11 @@ test('a series can be chosen as a filter, and the search finds studies by series
|
|||||||
expect(filterStudies(idx, { series: '__none' }).map((e) => e.id)).toEqual(['b']);
|
expect(filterStudies(idx, { series: '__none' }).map((e) => e.id)).toEqual(['b']);
|
||||||
expect(filterStudies(idx, { query: 'advent' }).map((e) => e.id)).toEqual(['c']);
|
expect(filterStudies(idx, { query: 'advent' }).map((e) => e.id)).toEqual(['c']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('archived studies are left out unless asked for', () => {
|
||||||
|
const idx = [{ id: 'a', title: 'Live' }, { id: 'b', title: 'Done', archived: true }, { id: 'c', title: 'Done too', archived: true, tags: ['hope'] }];
|
||||||
|
expect(filterStudies(idx).map((e) => e.id)).toEqual(['a']);
|
||||||
|
expect(filterStudies(idx, { archived: true }).map((e) => e.id)).toEqual(['b', 'c']);
|
||||||
|
expect(filterStudies(idx, { archived: true, tags: ['hope'] }).map((e) => e.id)).toEqual(['c']);
|
||||||
|
expect(filterStudies(idx, { archived: null }).map((e) => e.id)).toEqual(['a', 'b', 'c']);
|
||||||
|
});
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
// Fetches a chapter's raw text for building a study (the free Bible source). The Reader's "Save for
|
// Fetches a chapter's raw text for building a study (the free Bible source). The Reader's "Save for
|
||||||
// offline" downloads go through the same address, so a saved book builds a study with no connection.
|
// offline" downloads go through the same address, so a saved book builds a study with no connection.
|
||||||
// Failures say what's wrong and what to do, instead of a generic error.
|
// Failures say what's wrong and what to do, instead of a generic error.
|
||||||
|
import { helloaoCode } from './bibleText.js';
|
||||||
|
|
||||||
export async function fetchStudyChapter(translation, bookAbbrev, chapter, { signal } = {}) {
|
export async function fetchStudyChapter(translation, bookAbbrev, chapter, { signal } = {}) {
|
||||||
const code = translation === 'NET' ? 'eng_net' : (translation || 'BSB');
|
const code = helloaoCode(translation || 'BSB');
|
||||||
let res;
|
let res;
|
||||||
try {
|
try {
|
||||||
res = await fetch(`https://bible.helloao.org/api/${code}/${bookAbbrev}/${chapter}.json`, { signal });
|
res = await fetch(`https://bible.helloao.org/api/${code}/${bookAbbrev}/${chapter}.json`, { signal });
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
|
exclude: ['e2e/**', 'node_modules/**', 'dist/**'], // e2e/ holds the Playwright browser tests (npm run test:e2e)
|
||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
environmentOptions: {
|
environmentOptions: {
|
||||||
jsdom: { url: 'http://localhost:3000/' },
|
jsdom: { url: 'http://localhost:3000/' },
|
||||||
|
|||||||
Reference in new issue
Block a user