Compare commits
15
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' } },
|
||||||
|
],
|
||||||
|
});
|
||||||
+14
-12
@@ -140,7 +140,7 @@ export function initDb() {
|
|||||||
db.exec('ALTER TABLE projects ADD COLUMN outline_share_token TEXT');
|
db.exec('ALTER TABLE projects ADD COLUMN outline_share_token TEXT');
|
||||||
}
|
}
|
||||||
// Share links can expire and be protected by a passcode.
|
// Share links can expire and be protected by a passcode.
|
||||||
for (const col of ['share_expires INTEGER', 'share_pass TEXT', 'outline_share_expires INTEGER', 'outline_share_pass TEXT']) {
|
for (const col of ['share_expires INTEGER', 'share_pass TEXT', 'share_reveal INTEGER', 'outline_share_expires INTEGER', 'outline_share_pass TEXT']) {
|
||||||
if (!projectCols.some((c) => c.name === col.split(' ')[0])) db.exec(`ALTER TABLE projects ADD COLUMN ${col}`);
|
if (!projectCols.some((c) => c.name === col.split(' ')[0])) db.exec(`ALTER TABLE projects ADD COLUMN ${col}`);
|
||||||
}
|
}
|
||||||
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_outline_share_token ON projects(outline_share_token) WHERE outline_share_token IS NOT NULL');
|
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_outline_share_token ON projects(outline_share_token) WHERE outline_share_token IS NOT NULL');
|
||||||
@@ -780,7 +780,7 @@ export function setShareToken(id, userId, token) {
|
|||||||
|
|
||||||
/** Revokes a project's share link, scoped to userId. */
|
/** Revokes a project's share link, scoped to userId. */
|
||||||
export function clearShareToken(id, userId) {
|
export function clearShareToken(id, userId) {
|
||||||
db.prepare('UPDATE projects SET share_token = NULL, share_expires = NULL, share_pass = NULL WHERE id = ? AND user_id = ?').run(id, userId);
|
db.prepare('UPDATE projects SET share_token = NULL, share_expires = NULL, share_pass = NULL, share_reveal = NULL WHERE id = ? AND user_id = ?').run(id, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- read-only outline links: a separate token, so sharing an outline never shares the whole study ----
|
// ---- read-only outline links: a separate token, so sharing an outline never shares the whole study ----
|
||||||
@@ -800,14 +800,14 @@ export function clearOutlineShareToken(id, userId) {
|
|||||||
|
|
||||||
// ---- expiry and passcode for either kind of read-only link (outline = the outline-only link) ----
|
// ---- expiry and passcode for either kind of read-only link (outline = the outline-only link) ----
|
||||||
const shareCols = (outline) => (outline
|
const shareCols = (outline) => (outline
|
||||||
? { token: 'outline_share_token', expires: 'outline_share_expires', pass: 'outline_share_pass' }
|
? { token: 'outline_share_token', expires: 'outline_share_expires', pass: 'outline_share_pass', reveal: 'NULL' }
|
||||||
: { token: 'share_token', expires: 'share_expires', pass: 'share_pass' });
|
: { token: 'share_token', expires: 'share_expires', pass: 'share_pass', reveal: 'share_reveal' });
|
||||||
|
|
||||||
/** { token, expiresAt, hasPasscode } for the owner's link settings. */
|
/** { token, expiresAt, hasPasscode, revealAt } for the owner's link settings (revealAt: notes stay hidden until then). */
|
||||||
export function getShareSettings(id, userId, outline = false) {
|
export function getShareSettings(id, userId, outline = false) {
|
||||||
const c = shareCols(outline);
|
const c = shareCols(outline);
|
||||||
const row = db.prepare(`SELECT ${c.token} AS token, ${c.expires} AS expiresAt, ${c.pass} AS pass FROM projects WHERE id = ? AND user_id = ?`).get(id, userId);
|
const row = db.prepare(`SELECT ${c.token} AS token, ${c.expires} AS expiresAt, ${c.pass} AS pass, ${c.reveal} AS revealAt FROM projects WHERE id = ? AND user_id = ?`).get(id, userId);
|
||||||
return row ? { token: row.token ?? null, expiresAt: row.expiresAt ?? null, hasPasscode: !!row.pass } : null;
|
return row ? { token: row.token ?? null, expiresAt: row.expiresAt ?? null, hasPasscode: !!row.pass, revealAt: row.revealAt ?? null } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The stored passcode hash of a link (kept when only the expiry is changed). */
|
/** The stored passcode hash of a link (kept when only the expiry is changed). */
|
||||||
@@ -816,18 +816,20 @@ export function getSharePassHash(id, userId, outline = false) {
|
|||||||
return db.prepare(`SELECT ${c.pass} AS pass FROM projects WHERE id = ? AND user_id = ?`).get(id, userId)?.pass ?? null;
|
return db.prepare(`SELECT ${c.pass} AS pass FROM projects WHERE id = ? AND user_id = ?`).get(id, userId)?.pass ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sets expiry (ms timestamp or null) and passcode hash (or null) on an existing link. */
|
/** Sets expiry (ms timestamp or null), passcode hash (or null) and, for a study link, when its notes appear (or null). */
|
||||||
export function setShareSettings(id, userId, { expiresAt, passHash }, outline = false) {
|
export function setShareSettings(id, userId, { expiresAt, passHash, revealAt }, outline = false) {
|
||||||
const c = shareCols(outline);
|
const c = shareCols(outline);
|
||||||
return db.prepare(`UPDATE projects SET ${c.expires} = ?, ${c.pass} = ? WHERE id = ? AND user_id = ?`).run(expiresAt ?? null, passHash ?? null, id, userId).changes > 0;
|
const changed = db.prepare(`UPDATE projects SET ${c.expires} = ?, ${c.pass} = ? WHERE id = ? AND user_id = ?`).run(expiresAt ?? null, passHash ?? null, id, userId).changes > 0;
|
||||||
|
if (changed && !outline && revealAt !== undefined) db.prepare('UPDATE projects SET share_reveal = ? WHERE id = ? AND user_id = ?').run(revealAt ?? null, id, userId);
|
||||||
|
return changed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Public lookup for a link: the project plus the link's rules, or null for an unknown token. */
|
/** Public lookup for a link: the project plus the link's rules, or null for an unknown token. */
|
||||||
export function getSharedByToken(token, outline = false) {
|
export function getSharedByToken(token, outline = false) {
|
||||||
const c = shareCols(outline);
|
const c = shareCols(outline);
|
||||||
const row = db.prepare(`SELECT data, ${c.expires} AS expiresAt, ${c.pass} AS passHash FROM projects WHERE ${c.token} = ?`).get(token);
|
const row = db.prepare(`SELECT data, ${c.expires} AS expiresAt, ${c.pass} AS passHash, ${c.reveal} AS revealAt FROM projects WHERE ${c.token} = ?`).get(token);
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
try { return { project: JSON.parse(row.data), expiresAt: row.expiresAt ?? null, passHash: row.passHash ?? null }; } catch { return null; }
|
try { return { project: JSON.parse(row.data), expiresAt: row.expiresAt ?? null, passHash: row.passHash ?? null, revealAt: row.revealAt ?? null }; } catch { return null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Public lookup: the project data behind an outline link (callers must only ever send the outlines on). */
|
/** Public lookup: the project data behind an outline link (callers must only ever send the outlines on). */
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import { acceptEditInvite, clearOutlineShareToken, clearShareToken, countOpenEditInvites, createEditInvite, getSharePassHash, getShareSettings, getSharedByToken, isProjectOwner, listCollaborators, removeCollaborator, setOutlineShareToken, setShareSettings, setShareToken } from '../db.js';
|
import { acceptEditInvite, clearOutlineShareToken, clearShareToken, countOpenEditInvites, createEditInvite, getSharePassHash, getShareSettings, getSharedByToken, isProjectOwner, listCollaborators, removeCollaborator, setOutlineShareToken, setShareSettings, setShareToken } from '../db.js';
|
||||||
import { requireAuth } from '../auth.js';
|
import { requireAuth } from '../auth.js';
|
||||||
import { checkShareAccess, expiryFromDays, hashPasscode } from '../shareLinks.js';
|
import { checkShareAccess, expiryFromDays, hashPasscode, hideUntilReveal, revealFromInput } from '../shareLinks.js';
|
||||||
import { buildOutlinePayload } from '../outlineShare.js';
|
import { buildOutlinePayload } from '../outlineShare.js';
|
||||||
import { TOO_MANY, isLimited, recordFailure } from '../limiter.js';
|
import { TOO_MANY, isLimited, recordFailure } from '../limiter.js';
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ export function registerSharingRoutes(app) {
|
|||||||
try {
|
try {
|
||||||
const settings = getShareSettings(req.params.id, req.session.userId);
|
const settings = getShareSettings(req.params.id, req.session.userId);
|
||||||
if (!settings) return res.status(404).json({ error: 'Project not found.' });
|
if (!settings) return res.status(404).json({ error: 'Project not found.' });
|
||||||
res.json({ shareToken: settings.token, expiresAt: settings.expiresAt, hasPasscode: settings.hasPasscode });
|
res.json({ shareToken: settings.token, expiresAt: settings.expiresAt, hasPasscode: settings.hasPasscode, revealAt: settings.revealAt });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('GET /api/projects/:id/share error:', err);
|
console.error('GET /api/projects/:id/share error:', err);
|
||||||
res.status(500).json({ error: 'Failed to load share status.' });
|
res.status(500).json({ error: 'Failed to load share status.' });
|
||||||
@@ -37,8 +37,10 @@ export function registerSharingRoutes(app) {
|
|||||||
// a blank passcode field means "keep it"; null clears; a string sets
|
// a blank passcode field means "keep it"; null clears; a string sets
|
||||||
const current = getSharePassHash(req.params.id, userId, outline);
|
const current = getSharePassHash(req.params.id, userId, outline);
|
||||||
const passHash = 'passcode' in body ? (body.passcode === null ? null : await hashPasscode(body.passcode) ?? current) : current;
|
const passHash = 'passcode' in body ? (body.passcode === null ? null : await hashPasscode(body.passcode) ?? current) : current;
|
||||||
setShareSettings(req.params.id, userId, { expiresAt, passHash }, outline);
|
// only a study link can hide its notes until a time; a past or empty time turns hiding off
|
||||||
res.json({ shareToken: token, expiresAt, hasPasscode: !!passHash });
|
const revealAt = outline ? null : ('revealAt' in body ? revealFromInput(body.revealAt) : settings.revealAt);
|
||||||
|
setShareSettings(req.params.id, userId, { expiresAt, passHash, revealAt }, outline);
|
||||||
|
res.json({ shareToken: token, expiresAt, hasPasscode: !!passHash, revealAt });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`POST share (${outline ? 'outline' : 'study'}) error:`, err);
|
console.error(`POST share (${outline ? 'outline' : 'study'}) error:`, err);
|
||||||
res.status(500).json({ error: 'Failed to enable sharing.' });
|
res.status(500).json({ error: 'Failed to enable sharing.' });
|
||||||
@@ -123,14 +125,14 @@ export function registerSharingRoutes(app) {
|
|||||||
res.status(access.status).json({ error: access.error, needsPasscode: !!access.needsPasscode, expired: access.status === 410 });
|
res.status(access.status).json({ error: access.error, needsPasscode: !!access.needsPasscode, expired: access.status === 410 });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return found.project;
|
return found;
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/share-outline/:token — PUBLIC, no login required (a passcode may be needed)
|
// GET /api/share-outline/:token — PUBLIC, no login required (a passcode may be needed)
|
||||||
app.get('/api/share-outline/:token', async (req, res) => {
|
app.get('/api/share-outline/:token', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const project = await openSharedLink(req, res, true);
|
const found = await openSharedLink(req, res, true);
|
||||||
if (project) res.json(buildOutlinePayload(project));
|
if (found) res.json(buildOutlinePayload(found.project));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('GET /api/share-outline/:token error:', err);
|
console.error('GET /api/share-outline/:token error:', err);
|
||||||
res.status(500).json({ error: 'Failed to load the shared outline.' });
|
res.status(500).json({ error: 'Failed to load the shared outline.' });
|
||||||
@@ -140,8 +142,8 @@ export function registerSharingRoutes(app) {
|
|||||||
// GET /api/share/:token — PUBLIC, no login required: fetch a shared project read-only (a passcode may be needed)
|
// GET /api/share/:token — PUBLIC, no login required: fetch a shared project read-only (a passcode may be needed)
|
||||||
app.get('/api/share/:token', async (req, res) => {
|
app.get('/api/share/:token', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const project = await openSharedLink(req, res, false);
|
const found = await openSharedLink(req, res, false);
|
||||||
if (project) res.json(project);
|
if (found) res.json(hideUntilReveal(found.project, found.revealAt));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('GET /api/share/:token error:', err);
|
console.error('GET /api/share/:token error:', err);
|
||||||
res.status(500).json({ error: 'Failed to load shared project.' });
|
res.status(500).json({ error: 'Failed to load shared project.' });
|
||||||
|
|||||||
@@ -25,3 +25,27 @@ export async function checkShareAccess({ expiresAt, passHash, supplied }, now =
|
|||||||
if (!(await bcrypt.compare(given, passHash))) return { ok: false, status: 401, needsPasscode: true, error: 'That passcode is not right.' };
|
if (!(await bcrypt.compare(given, passHash))) return { ok: false, status: 401, needsPasscode: true, error: 'That passcode is not right.' };
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "Hide my notes until…": before the reveal time a shared study shows only the passages and the discussion questions
|
||||||
|
// (no notes, outline, script, words or group notes), and says when the rest appears. The strip is done here, on the
|
||||||
|
// server, so the hidden text never reaches the browser. After the time (or with none set) the study is sent whole.
|
||||||
|
const OPEN_CHUNK_FIELDS = ['id', 'startVerse', 'endVerse', 'spilloverEndVerse', 'episodeNumber', 'episodeTitle', 'discussionQuestions'];
|
||||||
|
|
||||||
|
export function hideUntilReveal(project, revealAt, now = Date.now()) {
|
||||||
|
if (!revealAt || revealAt <= now || !project || typeof project !== 'object') return project;
|
||||||
|
return {
|
||||||
|
id: project.id, title: project.title, translation: project.translation, series: project.series, outline: [],
|
||||||
|
hiddenUntil: revealAt,
|
||||||
|
chapters: (Array.isArray(project.chapters) ? project.chapters : []).map((ch) => ({
|
||||||
|
book: ch.book, bookAbbrev: ch.bookAbbrev, chapter: ch.chapter, verses: ch.verses,
|
||||||
|
chunks: (Array.isArray(ch.chunks) ? ch.chunks : []).map((chunk) => Object.fromEntries(OPEN_CHUNK_FIELDS.filter((f) => f in chunk).map((f) => [f, chunk[f]]))),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// A reveal time from the browser -> ms in the future (up to a year), or null for "no hiding"
|
||||||
|
export function revealFromInput(value, now = Date.now()) {
|
||||||
|
const n = Number(value);
|
||||||
|
if (!value || !Number.isFinite(n) || n <= now) return null;
|
||||||
|
return Math.min(n, now + 365 * 86_400_000);
|
||||||
|
}
|
||||||
@@ -31,3 +31,36 @@ test('an expired link is reported as expired even before the passcode is checked
|
|||||||
const passHash = await hashPasscode('x');
|
const passHash = await hashPasscode('x');
|
||||||
expect(await checkShareAccess({ passHash, expiresAt: NOW - 5, supplied: 'x' }, NOW)).toMatchObject({ status: 410 });
|
expect(await checkShareAccess({ passHash, expiresAt: NOW - 5, supplied: 'x' }, NOW)).toMatchObject({ status: 410 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
import { hideUntilReveal, revealFromInput } from './shareLinks.js';
|
||||||
|
|
||||||
|
const study = () => ({
|
||||||
|
id: 's1', title: 'John', translation: 'BSB', outline: [{ id: 'n', text: 'Point' }],
|
||||||
|
chapters: [{ book: 'John', bookAbbrev: 'JHN', chapter: '3', verses: [{ number: 16, text: 'For God so loved.' }],
|
||||||
|
chunks: [{ id: 'c', startVerse: 16, endVerse: 18, discussionQuestions: 'Why?', observation: 'secret', interpretation: 'secret', application: 'secret', finalScript: 'secret', generalNotes: 'secret', greekWords: [{ id: 'w' }], teachBack: 'secret', recalls: [{ text: 'secret' }] }] }],
|
||||||
|
});
|
||||||
|
|
||||||
|
test('before the reveal time a shared study keeps the passages and questions and drops every note', () => {
|
||||||
|
const now = 1_000;
|
||||||
|
const out = hideUntilReveal(study(), now + 5_000, now);
|
||||||
|
expect(out.hiddenUntil).toBe(now + 5_000);
|
||||||
|
expect(out.outline).toEqual([]);
|
||||||
|
expect(out.chapters[0].verses).toHaveLength(1);
|
||||||
|
expect(out.chapters[0].chunks[0]).toEqual({ id: 'c', startVerse: 16, endVerse: 18, discussionQuestions: 'Why?' });
|
||||||
|
expect(JSON.stringify(out)).not.toContain('secret');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('after the reveal time, or with none set, the whole study is sent', () => {
|
||||||
|
const full = study();
|
||||||
|
expect(hideUntilReveal(full, 500, 1_000)).toBe(full);
|
||||||
|
expect(hideUntilReveal(full, null, 1_000)).toBe(full);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a reveal time must be in the future, and no more than a year away', () => {
|
||||||
|
const now = 1_000_000;
|
||||||
|
expect(revealFromInput(null, now)).toBeNull();
|
||||||
|
expect(revealFromInput(now - 1, now)).toBeNull();
|
||||||
|
expect(revealFromInput('nope', now)).toBeNull();
|
||||||
|
expect(revealFromInput(now + 60_000, now)).toBe(now + 60_000);
|
||||||
|
expect(revealFromInput(now + 10 * 365 * 86_400_000, now)).toBe(now + 365 * 86_400_000);
|
||||||
|
});
|
||||||
+18
-24
@@ -11,6 +11,8 @@ 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 { useProjectLibrary } from './hooks/useProjectLibrary.js';
|
import { useProjectLibrary } from './hooks/useProjectLibrary.js';
|
||||||
import { useHistoryNav } from './hooks/useHistoryNav.js';
|
import { useHistoryNav } from './hooks/useHistoryNav.js';
|
||||||
import { usePrayers } from './hooks/usePrayers.js';
|
import { usePrayers } from './hooks/usePrayers.js';
|
||||||
@@ -37,6 +39,8 @@ const ImportPage = lazy(() => import('./pages/ImportPage.jsx'));
|
|||||||
const ReaderPage = lazy(() => import('./pages/ReaderPage.jsx'));
|
const ReaderPage = lazy(() => import('./pages/ReaderPage.jsx'));
|
||||||
const SettingsPage = lazy(() => import('./pages/SettingsPage.jsx'));
|
const SettingsPage = lazy(() => import('./pages/SettingsPage.jsx'));
|
||||||
const SetupPage = lazy(() => import('./pages/SetupPage.jsx'));
|
const SetupPage = lazy(() => import('./pages/SetupPage.jsx'));
|
||||||
|
const AudienceView = lazy(() => import('./components/AudienceView.jsx'));
|
||||||
|
const InsightsPage = lazy(() => import('./pages/InsightsPage.jsx'));
|
||||||
const StudyPage = lazy(() => import('./pages/StudyPage.jsx'));
|
const StudyPage = lazy(() => import('./pages/StudyPage.jsx'));
|
||||||
const OutlinePage = lazy(() => import('./pages/OutlinePage.jsx'));
|
const OutlinePage = lazy(() => import('./pages/OutlinePage.jsx'));
|
||||||
const ScriptPage = lazy(() => import('./pages/ScriptPage.jsx'));
|
const ScriptPage = lazy(() => import('./pages/ScriptPage.jsx'));
|
||||||
@@ -166,8 +170,8 @@ const AppInner = () => {
|
|||||||
// The browser tab and history show where you are ("Romans 8 · Bible Study", "Luke — Outline · Bible Study")
|
// The browser tab and history show where you are ("Romans 8 · Bible Study", "Luke — Outline · Bible Study")
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const names = {
|
const names = {
|
||||||
home: 'My Studies', setup: 'Chunks', study: 'Study', outline: 'Outline', script: 'Script', export: 'Export',
|
home: 'My Studies', setup: 'Chunks', study: 'Study', outline: 'Outline', script: 'Script', export: 'Finish',
|
||||||
import: 'Import', pronunciation: 'Pronunciation Guide', settings: 'Settings', admin: 'Admin', 'reading-plans': 'Reading Plans', prayer: 'Prayer journal',
|
import: 'Import', pronunciation: 'Pronunciation Guide', settings: 'Settings', admin: 'Admin', 'reading-plans': 'Reading Plans', prayer: 'Prayer journal', insights: 'Insights',
|
||||||
};
|
};
|
||||||
let page = names[currentPage] ?? '';
|
let page = names[currentPage] ?? '';
|
||||||
if (currentPage === 'reader') {
|
if (currentPage === 'reader') {
|
||||||
@@ -355,12 +359,13 @@ const AppInner = () => {
|
|||||||
setProject((current) => (current ? updater(current) : current));
|
setProject((current) => (current ? updater(current) : current));
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateChunk = (chunkId, patch) => {
|
// `touch: false` saves a change without counting it as an edit (so it doesn't reset "last edited" for recalls and summaries).
|
||||||
|
const updateChunk = (chunkId, patch, { touch = true } = {}) => {
|
||||||
updateProject((current) => ({
|
updateProject((current) => ({
|
||||||
...current,
|
...current,
|
||||||
chapters: current.chapters.map((ch) => ({
|
chapters: current.chapters.map((ch) => ({
|
||||||
...ch,
|
...ch,
|
||||||
chunks: ch.chunks.map((c) => (c.id === chunkId ? { ...c, ...patch, editedAt: Date.now() } : c)),
|
chunks: ch.chunks.map((c) => (c.id === chunkId ? { ...c, ...patch, ...(touch ? { editedAt: Date.now() } : {}) } : c)),
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
@@ -421,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) {
|
||||||
@@ -584,6 +573,9 @@ function AppRouter() {
|
|||||||
// A read-only outline link: no sign-in, nothing editable
|
// A read-only outline link: no sign-in, nothing editable
|
||||||
if (sharedOutlineToken) return <SharedOutlineView token={sharedOutlineToken} />;
|
if (sharedOutlineToken) return <SharedOutlineView token={sharedOutlineToken} />;
|
||||||
|
|
||||||
|
// The second window for presenting on a projector: no sign-in, it only shows what the presenter window sends.
|
||||||
|
if (isAudienceWindow()) return <Suspense fallback={null}><AudienceView /></Suspense>;
|
||||||
|
|
||||||
if (sharedViewToken) {
|
if (sharedViewToken) {
|
||||||
if (needsPasscode) return <SharedPasscodeForm dark error={passcodeError} busy={checking} onSubmit={submitPasscode} />;
|
if (needsPasscode) return <SharedPasscodeForm dark error={passcodeError} busy={checking} onSubmit={submitPasscode} />;
|
||||||
if (sharedError) {
|
if (sharedError) {
|
||||||
@@ -606,6 +598,7 @@ function AppRouter() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-dvh bg-slate-100">
|
<div className="min-h-dvh bg-slate-100">
|
||||||
<div className="border-b border-slate-200 bg-slate-900 px-4 py-3 text-center text-xs text-slate-300">
|
<div className="border-b border-slate-200 bg-slate-900 px-4 py-3 text-center text-xs text-slate-300">
|
||||||
|
{sharedProject.hiddenUntil ? `Notes appear ${new Date(sharedProject.hiddenUntil).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' })}. For now: the passages and questions. ` : ''}
|
||||||
🔗 Read-only shared view —{' '}
|
🔗 Read-only shared view —{' '}
|
||||||
<a href="/" className="underline hover:text-white">go to Bible Study Project</a>
|
<a href="/" className="underline hover:text-white">go to Bible Study Project</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -629,6 +622,7 @@ function AppRouter() {
|
|||||||
if (currentPage === 'prayer') return <PrayerPage />;
|
if (currentPage === 'prayer') return <PrayerPage />;
|
||||||
if (currentPage === 'reader') return <ReaderPage />;
|
if (currentPage === 'reader') return <ReaderPage />;
|
||||||
if (currentPage === 'import') return <ImportPage />;
|
if (currentPage === 'import') return <ImportPage />;
|
||||||
|
if (currentPage === 'insights') return <InsightsPage />;
|
||||||
if (currentPage === 'study') return <StudyPage />;
|
if (currentPage === 'study') return <StudyPage />;
|
||||||
if (currentPage === 'outline') return <OutlinePage />;
|
if (currentPage === 'outline') return <OutlinePage />;
|
||||||
if (currentPage === 'script') return <ScriptPage />;
|
if (currentPage === 'script') return <ScriptPage />;
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// The audience window for two-screen presenting: drag it to the projector or second display and press F (or click)
|
||||||
|
// for full screen. It only draws what the presenter window sends, which never includes private notes.
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import StepFace from './StepFace.jsx';
|
||||||
|
import { openChannel } from '../utils/presentSync.js';
|
||||||
|
|
||||||
|
export default function AudienceView() {
|
||||||
|
const [state, setState] = useState(null);
|
||||||
|
const [ended, setEnded] = useState(false);
|
||||||
|
const [hint, setHint] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = 'Audience display';
|
||||||
|
const channel = openChannel();
|
||||||
|
if (!channel) return undefined;
|
||||||
|
channel.onmessage = (e) => {
|
||||||
|
if (e.data?.type === 'state') { setState(e.data); setEnded(false); }
|
||||||
|
else if (e.data?.type === 'end') setEnded(true);
|
||||||
|
};
|
||||||
|
channel.postMessage({ type: 'hello' }); // ask a presenter already running to send what it is showing
|
||||||
|
return () => channel.close();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fullscreen = () => { if (document.fullscreenElement) document.exitFullscreen?.(); else document.documentElement.requestFullscreen?.().catch(() => {}); };
|
||||||
|
const onKey = (e) => { if (e.key === 'f' || e.key === 'F') fullscreen(); };
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
const timer = window.setTimeout(() => setHint(false), 8000);
|
||||||
|
return () => { window.removeEventListener('keydown', onKey); window.clearTimeout(timer); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const dark = state?.dark ?? true;
|
||||||
|
const colors = dark ? 'bg-[#111] text-[#f2efe8]' : 'bg-[#faf7f0] text-[#2b2620]';
|
||||||
|
const fontSize = `${state?.fontPx ?? 40}px`;
|
||||||
|
let body;
|
||||||
|
if (!state || ended) {
|
||||||
|
body = <p className="text-center text-lg opacity-60">{ended ? 'The presentation has ended.' : 'Waiting for the presenter…'}</p>;
|
||||||
|
} else if (state.blank) {
|
||||||
|
body = null;
|
||||||
|
} else if (state.mode === 'outline' && state.step) {
|
||||||
|
body = <StepFace step={state.step} index={state.index} total={state.total} fontSize={fontSize} />;
|
||||||
|
} else {
|
||||||
|
body = (
|
||||||
|
<div className="text-center font-serif" style={{ fontSize }}>
|
||||||
|
<h1 className="font-semibold">{state.title}</h1>
|
||||||
|
{state.subtitle && <p className="mt-3 text-[0.55em] opacity-70">{state.subtitle}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className={`fixed inset-0 flex cursor-none items-center justify-center overflow-hidden px-[6vw] py-[6dvh] ${state?.blank ? 'bg-black' : colors}`} onClick={() => document.documentElement.requestFullscreen?.().catch(() => {})}>
|
||||||
|
{body}
|
||||||
|
{hint && !state?.blank && <p className="fixed bottom-3 left-0 right-0 text-center font-sans text-xs opacity-40">Press F or click for full screen</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// Export → Co-teachers: let other accounts edit this study with you. You make a single-use invite link;
|
// Finish → Co-teachers: let other accounts edit this study with you. You make a single-use invite link;
|
||||||
// whoever opens it (signed in) can edit the study, and you can remove them any time. Edits from both of you
|
// whoever opens it (signed in) can edit the study, and you can remove them any time. Edits from both of you
|
||||||
// sync through the account; if you both change the same study, "Combine both" keeps everything.
|
// sync through the account; if you both change the same study, "Combine both" keeps everything.
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
// Export → Lesson handout: a printable page for each session (or one session), for a group to follow along.
|
// Finish → Lesson handout: a printable page for each session (or one session), for a group to follow along.
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { printHandout, sessionsFor } from '../utils/handout.js';
|
import { HANDOUT_LAYOUTS, printHandout, sessionsFor } from '../utils/handout.js';
|
||||||
|
|
||||||
export default function HandoutExport({ project }) {
|
export default function HandoutExport({ project }) {
|
||||||
const sessions = sessionsFor(project);
|
const sessions = sessionsFor(project);
|
||||||
const [which, setWhich] = useState('all');
|
const [which, setWhich] = useState('all');
|
||||||
const [lines, setLines] = useState(true);
|
const [lines, setLines] = useState(true);
|
||||||
|
const [layout, setLayout] = useState('standard');
|
||||||
const [blocked, setBlocked] = useState(false);
|
const [blocked, setBlocked] = useState(false);
|
||||||
if (sessions.length === 0) return null;
|
if (sessions.length === 0) return null;
|
||||||
const go = () => setBlocked(!printHandout(project, which === 'all' ? null : [which], { answerLines: lines }));
|
const go = () => setBlocked(!printHandout(project, which === 'all' ? null : [which], { answerLines: lines, layout }));
|
||||||
return (
|
return (
|
||||||
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5" aria-label="Lesson handout">
|
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5" aria-label="Lesson handout">
|
||||||
<h2 className="text-sm font-medium">Lesson handout</h2>
|
<h2 className="text-sm font-medium">Lesson handout</h2>
|
||||||
@@ -20,6 +21,11 @@ export default function HandoutExport({ project }) {
|
|||||||
{sessions.map((s) => <option key={s.chunk.id} value={s.chunk.id}>{s.number}. {s.reference}</option>)}
|
{sessions.map((s) => <option key={s.chunk.id} value={s.chunk.id}>{s.number}. {s.reference}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="text-xs text-[var(--r-sub)]">Layout
|
||||||
|
<select value={layout} onChange={(e) => setLayout(e.target.value)} className="mt-1 block rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm text-[var(--r-text)]">
|
||||||
|
{HANDOUT_LAYOUTS.map((l) => <option key={l.id} value={l.id}>{l.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<label className="flex items-center gap-2 pb-2 text-xs text-[var(--r-sub)]"><input type="checkbox" checked={lines} onChange={(e) => setLines(e.target.checked)} className="accent-[var(--r-accent)]" /> Lines to write on</label>
|
<label className="flex items-center gap-2 pb-2 text-xs text-[var(--r-sub)]"><input type="checkbox" checked={lines} onChange={(e) => setLines(e.target.checked)} className="accent-[var(--r-accent)]" /> Lines to write on</label>
|
||||||
<button type="button" onClick={go} className="rounded-xl border border-[var(--r-line)] px-4 py-2 text-sm hover:bg-black/5">Print / Save PDF</button>
|
<button type="button" onClick={go} className="rounded-xl border border-[var(--r-line)] px-4 py-2 text-sm hover:bg-black/5">Print / Save PDF</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
// Finish → Import notes: bring notes from a Word or Markdown file into this study, matched to its chunks by passage.
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useApp } from '../context/AppContext.js';
|
||||||
|
import { useToast } from './Toasts.jsx';
|
||||||
|
import { applyImportedNotes, parseNotesText } from '../utils/importNotes.js';
|
||||||
|
|
||||||
|
export default function ImportNotes() {
|
||||||
|
const { project, updateProject } = useApp();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [file, setFile] = useState('');
|
||||||
|
const [sections, setSections] = useState(null); // null until a file is read
|
||||||
|
const [own, setOwn] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const choose = async (e) => {
|
||||||
|
const f = e.target.files?.[0];
|
||||||
|
e.target.value = '';
|
||||||
|
setError(''); setSections(null);
|
||||||
|
if (!f) return;
|
||||||
|
try {
|
||||||
|
let text;
|
||||||
|
if (/\.docx$/i.test(f.name)) {
|
||||||
|
const { default: mammoth } = await import('mammoth');
|
||||||
|
text = (await mammoth.extractRawText({ arrayBuffer: await f.arrayBuffer() })).value;
|
||||||
|
} else text = await f.text();
|
||||||
|
const found = parseNotesText(text);
|
||||||
|
setFile(f.name);
|
||||||
|
setSections(found);
|
||||||
|
if (found.length === 0) setError('No passage headings with Observe / Interpret / Apply notes were found in that file. Start each section with a heading like “John 3:16–18”.');
|
||||||
|
} catch { setError('That file couldn’t be read.'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const preview = sections ? applyImportedNotes(project, sections, { own }) : null;
|
||||||
|
const add = () => {
|
||||||
|
updateProject((current) => applyImportedNotes(current, sections, { own }).project);
|
||||||
|
toast(`Added notes to ${preview.matched.length} chunk${preview.matched.length === 1 ? '' : 's'}`);
|
||||||
|
setSections(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5" aria-label="Import notes">
|
||||||
|
<h2 className="text-sm font-medium">Import notes</h2>
|
||||||
|
<p className="mb-3 mt-1 text-xs text-[var(--r-sub)]">Bring in notes from a Word (.docx), Markdown or text file. Start each section with a passage heading (“John 3:16–18”), then label the notes “Observe:”, “Interpret:”, “Apply:”, “Background:” or “Questions:”. Notes you’ve already written are kept; imported text is added below them.</p>
|
||||||
|
<label className="inline-block cursor-pointer rounded-xl border border-[var(--r-line)] px-4 py-2 text-sm hover:bg-black/5">Choose a file
|
||||||
|
<input type="file" accept=".docx,.md,.markdown,.txt,text/markdown,text/plain" className="hidden" onChange={choose} />
|
||||||
|
</label>
|
||||||
|
{error && <p role="alert" className="mt-2 text-xs text-[var(--r-danger,#b42318)]">{error}</p>}
|
||||||
|
{preview && sections.length > 0 && (
|
||||||
|
<div className="mt-3 space-y-2 text-sm">
|
||||||
|
<p className="text-xs text-[var(--r-sub)]">{file}: {sections.length} section{sections.length === 1 ? '' : 's'} found.</p>
|
||||||
|
<ul className="space-y-1 text-xs">
|
||||||
|
{preview.matched.map((m) => <li key={m.heading}>✓ {m.heading}: {m.fields.join(', ')}</li>)}
|
||||||
|
{preview.unmatched.map((h) => <li key={h} className="text-[var(--r-sub)]">No chunk for {h}, skipped</li>)}
|
||||||
|
</ul>
|
||||||
|
<label className="flex items-center gap-2 text-xs text-[var(--r-sub)]"><input type="checkbox" checked={own} onChange={(e) => setOwn(e.target.checked)} className="accent-[var(--r-accent)]" /> These notes are my own writing (untick if they came from AI or another source)</label>
|
||||||
|
<button type="button" onClick={add} disabled={preview.matched.length === 0} className="rounded-xl bg-[var(--r-text)] px-4 py-2 text-sm font-medium text-[var(--r-bg)] disabled:opacity-40">Add to this study</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,21 +11,27 @@ export const expiryText = (expiresAt, now = Date.now()) => {
|
|||||||
return `Expires ${new Date(expiresAt).toLocaleDateString()} (${days} day${days === 1 ? '' : 's'} left)`;
|
return `Expires ${new Date(expiresAt).toLocaleDateString()} (${days} day${days === 1 ? '' : 's'} left)`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function LinkRules({ expiresAt, hasPasscode, busy, onSave }) {
|
// "2026-10-18T09:00" in the person's own time zone <-> ms
|
||||||
|
const toLocalInput = (ms) => { if (!ms) return ''; const d = new Date(ms); const pad = (n) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; };
|
||||||
|
|
||||||
|
// `showReveal` adds "Hide my notes until…" (study links only; an outline link never shows notes).
|
||||||
|
export default function LinkRules({ expiresAt, hasPasscode, busy, onSave, showReveal = false, revealAt = null }) {
|
||||||
const [days, setDays] = useState('keep'); // 'keep' | '' | '1' | '7' | '30'
|
const [days, setDays] = useState('keep'); // 'keep' | '' | '1' | '7' | '30'
|
||||||
const [passcode, setPasscode] = useState('');
|
const [passcode, setPasscode] = useState('');
|
||||||
const dirty = days !== 'keep' || passcode.trim() !== '';
|
const [reveal, setReveal] = useState(null); // null = leave as is; '' = show everything now; else a local date-time
|
||||||
|
const dirty = days !== 'keep' || passcode.trim() !== '' || reveal !== null;
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
const options = {};
|
const options = {};
|
||||||
if (days !== 'keep') options.expiresInDays = days === '' ? null : Number(days);
|
if (days !== 'keep') options.expiresInDays = days === '' ? null : Number(days);
|
||||||
if (passcode.trim()) options.passcode = passcode.trim();
|
if (passcode.trim()) options.passcode = passcode.trim();
|
||||||
if (await onSave(options)) { setDays('keep'); setPasscode(''); }
|
if (reveal !== null) options.revealAt = reveal === '' ? null : new Date(reveal).getTime();
|
||||||
|
if (await onSave(options)) { setDays('keep'); setPasscode(''); setReveal(null); }
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-3 rounded-xl border border-[var(--r-line)] p-3" role="group" aria-label="Link settings">
|
<div className="mt-3 rounded-xl border border-[var(--r-line)] p-3" role="group" aria-label="Link settings">
|
||||||
<p className="text-xs text-[var(--r-sub)]">{expiryText(expiresAt)} · {hasPasscode ? 'Needs a passcode' : 'No passcode'}</p>
|
<p className="text-xs text-[var(--r-sub)]">{expiryText(expiresAt)} · {hasPasscode ? 'Needs a passcode' : 'No passcode'}{showReveal && revealAt && revealAt > Date.now() ? ` · Your notes appear ${new Date(revealAt).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' })}` : ''}</p>
|
||||||
<div className="mt-2 flex flex-wrap items-end gap-3">
|
<div className="mt-2 flex flex-wrap items-end gap-3">
|
||||||
<label className="text-xs text-[var(--r-sub)]">Stop working
|
<label className="text-xs text-[var(--r-sub)]">Stop working
|
||||||
<select value={days} onChange={(e) => setDays(e.target.value)} className={`${field} mt-1 block`}>
|
<select value={days} onChange={(e) => setDays(e.target.value)} className={`${field} mt-1 block`}>
|
||||||
@@ -36,10 +42,18 @@ export default function LinkRules({ expiresAt, hasPasscode, busy, onSave }) {
|
|||||||
<label className="text-xs text-[var(--r-sub)]">{hasPasscode ? 'New passcode' : 'Passcode (optional)'}
|
<label className="text-xs text-[var(--r-sub)]">{hasPasscode ? 'New passcode' : 'Passcode (optional)'}
|
||||||
<input type="text" value={passcode} onChange={(e) => setPasscode(e.target.value)} maxLength={100} autoComplete="off" placeholder={hasPasscode ? 'Type to change' : 'None'} className={`${field} mt-1 block w-44`} />
|
<input type="text" value={passcode} onChange={(e) => setPasscode(e.target.value)} maxLength={100} autoComplete="off" placeholder={hasPasscode ? 'Type to change' : 'None'} className={`${field} mt-1 block w-44`} />
|
||||||
</label>
|
</label>
|
||||||
|
{showReveal && (
|
||||||
|
<label className="text-xs text-[var(--r-sub)]">Hide my notes until
|
||||||
|
<input type="datetime-local" value={reveal ?? toLocalInput(revealAt > Date.now() ? revealAt : null)} onChange={(e) => setReveal(e.target.value)} className={`${field} mt-1 block`} />
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{showReveal && (reveal ?? toLocalInput(revealAt > Date.now() ? revealAt : null)) && (
|
||||||
|
<button type="button" onClick={() => setReveal('')} className="text-xs text-[var(--r-sub)] hover:underline">Show everything now</button>
|
||||||
|
)}
|
||||||
<button type="button" onClick={save} disabled={!dirty || busy} className="rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-sm hover:bg-black/5 disabled:opacity-50">Save settings</button>
|
<button type="button" onClick={save} disabled={!dirty || busy} className="rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-sm hover:bg-black/5 disabled:opacity-50">Save settings</button>
|
||||||
{hasPasscode && <button type="button" onClick={() => onSave({ passcode: null })} disabled={busy} className="text-xs text-[var(--r-danger,#b42318)] hover:underline">Remove passcode</button>}
|
{hasPasscode && <button type="button" onClick={() => onSave({ passcode: null })} disabled={busy} className="text-xs text-[var(--r-danger,#b42318)] hover:underline">Remove passcode</button>}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-[11px] text-[var(--r-sub)]">Send the passcode separately from the link. Anyone with both can read it until it expires or you turn it off.</p>
|
<p className="mt-2 text-[11px] text-[var(--r-sub)]">Send the passcode separately from the link. Anyone with both can read it until it expires or you turn it off.{showReveal ? ' Until the time you set, the group sees only the passages and discussion questions; your notes, outline and scripts stay on the server.' : ''}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// On a study card: whether its Bible text works without a connection, and a button to fix that.
|
||||||
|
import { DownloadProgress } from './OfflineSave.jsx';
|
||||||
|
import { offlineStatus } from '../utils/offline.js';
|
||||||
|
|
||||||
|
export default function OfflineBadge({ project, offline }) {
|
||||||
|
if (!project) return null;
|
||||||
|
const status = offlineStatus(project, offline.saved);
|
||||||
|
const label = 'mt-1 text-xs';
|
||||||
|
if (offline.job) return <div className="mt-2"><DownloadProgress job={offline.job} onCancel={offline.cancel} /></div>;
|
||||||
|
if (status.state === 'ready') return <p className={`${label} text-[var(--r-accent)]`} title="Your notes and the Bible text for this study open without a connection">✓ Works offline</p>;
|
||||||
|
if (status.state === 'licensed') {
|
||||||
|
return <p className={`${label} text-[var(--r-sub)]`} title={`The ${status.translation} licence doesn’t allow saving its text for offline use`}>Notes work offline; {status.translation} text needs a connection</p>;
|
||||||
|
}
|
||||||
|
const next = status.missing[0];
|
||||||
|
return (
|
||||||
|
<p className={`${label} flex flex-wrap items-center gap-x-2 text-[var(--r-sub)]`}>
|
||||||
|
<span>Needs a connection for {status.missing.map((b) => b.name).join(', ')}</span>
|
||||||
|
<button type="button" onClick={() => offline.saveBook(status.translation, next.abbrev, next.name)} className="text-[var(--r-accent)] hover:underline">Save {next.name} for offline</button>
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
+103
-12
@@ -1,8 +1,15 @@
|
|||||||
// Present mode: the script or the outline full-screen in large type, with a timer. The script scrolls by
|
// Present mode: the script or the outline full-screen in large type, with a timer. The script scrolls by
|
||||||
// itself at a speed you set (a teleprompter), and the outline moves one point at a time with the minutes you
|
// itself at a speed you set (a teleprompter), and the outline moves one point at a time with the minutes you
|
||||||
// planned beside the clock. The screen is kept awake while it is open. Esc, or Close, leaves.
|
// planned beside the clock. The screen is kept awake while it is open. Esc, or Close, leaves.
|
||||||
|
//
|
||||||
|
// With a projector or second screen: "Audience window" opens a second window to drag over. It shows only the point,
|
||||||
|
// its passage and quoted verse; your speaker notes, the script, the timer and the plan stay on this screen. This
|
||||||
|
// screen then becomes a presenter view (what the audience sees, your notes, the next point). On one screen, notes are
|
||||||
|
// hidden unless you turn them on, so a mirrored display never shows them by accident. B blacks out the audience screen.
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { formatClock, outlineSteps, paceText, pacing } from '../utils/present.js';
|
import { formatClock, outlineSteps, paceText, pacing } from '../utils/present.js';
|
||||||
|
import { audienceState, audienceUrl, openChannel } from '../utils/presentSync.js';
|
||||||
|
import StepFace from './StepFace.jsx';
|
||||||
|
|
||||||
const SIZES = [20, 24, 28, 34, 40, 48, 60, 72];
|
const SIZES = [20, 24, 28, 34, 40, 48, 60, 72];
|
||||||
const btn = 'rounded-lg border border-current/30 px-3 py-1.5 text-sm hover:bg-white/10 disabled:opacity-40';
|
const btn = 'rounded-lg border border-current/30 px-3 py-1.5 text-sm hover:bg-white/10 disabled:opacity-40';
|
||||||
@@ -16,10 +23,43 @@ export default function PresentMode({ title, subtitle = '', mode, script = '', t
|
|||||||
const [speed, setSpeed] = useState(30); // pixels per second
|
const [speed, setSpeed] = useState(30); // pixels per second
|
||||||
const [index, setIndex] = useState(0);
|
const [index, setIndex] = useState(0);
|
||||||
const scroller = useRef(null);
|
const scroller = useRef(null);
|
||||||
|
const [showNotes, setShowNotes] = useState(() => { try { return localStorage.getItem('present-notes-here') === 'on'; } catch { return false; } });
|
||||||
|
const [audienceOpen, setAudienceOpen] = useState(false); // an audience window is connected: this screen is the presenter's
|
||||||
|
const [blank, setBlank] = useState(false); // the audience screen is blacked out
|
||||||
|
const [audSize, setAudSize] = useState(() => { try { return Math.max(0, SIZES.indexOf(Number(localStorage.getItem('present-aud-size')) || 48)); } catch { return 5; } });
|
||||||
|
const [popupBlocked, setPopupBlocked] = useState(false);
|
||||||
|
const audienceWin = useRef(null);
|
||||||
|
const channel = useRef(null);
|
||||||
|
const latest = useRef(null);
|
||||||
const steps = mode === 'outline' ? outlineSteps(tree) : [];
|
const steps = mode === 'outline' ? outlineSteps(tree) : [];
|
||||||
const step = steps[index];
|
const step = steps[index];
|
||||||
const pace = mode === 'outline' ? pacing(steps, index, elapsed) : null;
|
const pace = mode === 'outline' ? pacing(steps, index, elapsed) : null;
|
||||||
|
|
||||||
|
// Two screens: keep the audience window in step with this one, answering a window that has just opened.
|
||||||
|
const audience = audienceState({ title, subtitle, mode, step, index, total: steps.length, dark, fontPx: SIZES[audSize], blank });
|
||||||
|
latest.current = audience;
|
||||||
|
useEffect(() => {
|
||||||
|
const ch = openChannel();
|
||||||
|
channel.current = ch;
|
||||||
|
if (!ch) return undefined;
|
||||||
|
ch.onmessage = (e) => { if (e.data?.type === 'hello') { setAudienceOpen(true); ch.postMessage(latest.current); } };
|
||||||
|
return () => { ch.postMessage({ type: 'end' }); ch.close(); channel.current = null; };
|
||||||
|
}, []);
|
||||||
|
useEffect(() => { channel.current?.postMessage(audience); }); // every change: the point, the colours, blank
|
||||||
|
useEffect(() => {
|
||||||
|
if (!audienceOpen) return undefined;
|
||||||
|
const t = window.setInterval(() => { if (audienceWin.current?.closed) { audienceWin.current = null; setAudienceOpen(false); setBlank(false); } }, 1000);
|
||||||
|
return () => window.clearInterval(t);
|
||||||
|
}, [audienceOpen]);
|
||||||
|
const openAudience = () => {
|
||||||
|
if (!channel.current) return;
|
||||||
|
const win = window.open(audienceUrl(), 'bible-study-audience', 'popup,width=1280,height=720');
|
||||||
|
setPopupBlocked(!win);
|
||||||
|
if (win) { audienceWin.current = win; setAudienceOpen(true); }
|
||||||
|
};
|
||||||
|
const toggleNotes = () => setShowNotes((on) => { const next = !on; try { localStorage.setItem('present-notes-here', next ? 'on' : 'off'); } catch { /* storage unavailable */ } return next; });
|
||||||
|
const audBigger = (d) => setAudSize((i) => { const next = Math.min(SIZES.length - 1, Math.max(0, i + d)); try { localStorage.setItem('present-aud-size', String(SIZES[next])); } catch { /* storage unavailable */ } return next; });
|
||||||
|
|
||||||
// the clock
|
// the clock
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!running) return undefined;
|
if (!running) return undefined;
|
||||||
@@ -68,13 +108,20 @@ export default function PresentMode({ title, subtitle = '', mode, script = '', t
|
|||||||
if (e.key === 'ArrowRight' || e.key === 'ArrowDown' || e.key === ' ' || e.key === 'PageDown') { e.preventDefault(); go(1); }
|
if (e.key === 'ArrowRight' || e.key === 'ArrowDown' || e.key === ' ' || e.key === 'PageDown') { e.preventDefault(); go(1); }
|
||||||
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp' || e.key === 'PageUp') { e.preventDefault(); go(-1); }
|
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp' || e.key === 'PageUp') { e.preventDefault(); go(-1); }
|
||||||
} else if (e.key === ' ') { e.preventDefault(); setScrolling((s) => !s); }
|
} else if (e.key === ' ') { e.preventDefault(); setScrolling((s) => !s); }
|
||||||
|
if ((e.key === 'b' || e.key === 'B') && audienceOpen) setBlank((b) => !b);
|
||||||
|
if ((e.key === 'n' || e.key === 'N') && !audienceOpen && mode === 'outline') toggleNotes();
|
||||||
if (e.key === '+' || e.key === '=') bigger(1);
|
if (e.key === '+' || e.key === '=') bigger(1);
|
||||||
if (e.key === '-') bigger(-1);
|
if (e.key === '-') bigger(-1);
|
||||||
};
|
};
|
||||||
window.addEventListener('keydown', onKey);
|
window.addEventListener('keydown', onKey);
|
||||||
return () => window.removeEventListener('keydown', onKey);
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
}, [mode, go, onClose]);
|
}, [mode, go, onClose, audienceOpen]);
|
||||||
|
|
||||||
|
const banner = audienceOpen && (
|
||||||
|
<p role="status" className={`shrink-0 px-4 py-1.5 text-center text-xs ${blank ? 'bg-red-500/25' : 'bg-emerald-500/15'}`}>
|
||||||
|
{blank ? 'The audience screen is blank. Press B to bring it back.' : mode === 'script' ? 'The audience window is connected. It shows only the title; your script stays on this screen.' : 'The audience window is connected. It shows the points and verses, never your notes.'}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
const colors = dark ? 'bg-[#111] text-[#f2efe8]' : 'bg-[#faf7f0] text-[#2b2620]';
|
const colors = dark ? 'bg-[#111] text-[#f2efe8]' : 'bg-[#faf7f0] text-[#2b2620]';
|
||||||
const fontSize = `${SIZES[size]}px`;
|
const fontSize = `${SIZES[size]}px`;
|
||||||
const total = steps.length ? steps[steps.length - 1].plannedEnd : 0;
|
const total = steps.length ? steps[steps.length - 1].plannedEnd : 0;
|
||||||
@@ -102,29 +149,73 @@ export default function PresentMode({ title, subtitle = '', mode, script = '', t
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{typeof BroadcastChannel !== 'undefined' ? (
|
||||||
|
<div className="flex items-center gap-2" role="group" aria-label="Second screen">
|
||||||
|
{audienceOpen ? (
|
||||||
|
<>
|
||||||
|
<button type="button" onClick={() => setBlank((b) => !b)} aria-pressed={blank} className={btn} title="Black out the audience screen (B)">{blank ? 'Show audience' : 'Blank audience'}</button>
|
||||||
|
{mode === 'outline' && (
|
||||||
|
<span className="flex items-center gap-1" role="group" aria-label="Audience text size">
|
||||||
|
<button type="button" onClick={() => audBigger(-1)} disabled={audSize === 0} aria-label="Smaller audience text" className={btn}>Screen A−</button>
|
||||||
|
<button type="button" onClick={() => audBigger(1)} disabled={audSize === SIZES.length - 1} aria-label="Larger audience text" className={btn}>A+</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button type="button" onClick={openAudience} className={btn} title="Opens a second window for a projector or other screen. It shows the points, never your notes.">Audience window</button>
|
||||||
|
{mode === 'outline' && <button type="button" onClick={toggleNotes} aria-pressed={showNotes} className={btn} title="Speaker notes stay hidden on this screen unless you turn them on (N)">{showNotes ? 'Hide notes' : 'Show notes'}</button>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<button type="button" onClick={onClose} className={`${btn} font-medium`}>Close</button>
|
<button type="button" onClick={onClose} className={`${btn} font-medium`}>Close</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{mode === 'script' ? (
|
{mode === 'script' ? (
|
||||||
|
<>
|
||||||
|
{banner}
|
||||||
<div ref={scroller} className="min-h-0 flex-1 overflow-y-auto px-[6vw] py-[8dvh]" style={{ scrollBehavior: 'auto' }}>
|
<div ref={scroller} className="min-h-0 flex-1 overflow-y-auto px-[6vw] py-[8dvh]" style={{ scrollBehavior: 'auto' }}>
|
||||||
{script.trim()
|
{script.trim()
|
||||||
? <div className="mx-auto max-w-[46em] whitespace-pre-wrap font-serif" style={{ fontSize, lineHeight: 1.5 }}>{script}<div style={{ height: '50dvh' }} aria-hidden="true" /></div>
|
? <div className="mx-auto max-w-[46em] whitespace-pre-wrap font-serif" style={{ fontSize, lineHeight: 1.5 }}>{script}<div style={{ height: '50dvh' }} aria-hidden="true" /></div>
|
||||||
: <p className="mt-20 text-center text-lg opacity-70">There’s no script to present yet. Write one on the Script page.</p>}
|
: <p className="mt-20 text-center text-lg opacity-70">There’s no script to present yet. Write one on the Script page.</p>}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex min-h-0 flex-1 flex-col">
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto px-[6vw] py-[6dvh]">
|
{popupBlocked && <p role="alert" className="shrink-0 bg-amber-500/20 px-4 py-1.5 text-center text-xs">Your browser blocked the audience window. Allow pop-ups for this site, then try again.</p>}
|
||||||
{step ? (
|
{banner}
|
||||||
<div className="mx-auto max-w-[46em] font-serif" style={{ fontSize, lineHeight: 1.4 }}>
|
{audienceOpen && step ? (
|
||||||
<p className="font-sans text-[0.4em] uppercase tracking-[0.16em] opacity-60">{step.kind ? `${step.kind} · ` : ''}Point {index + 1} of {steps.length}{step.minutes ? ` · ${step.minutes} min` : ''}</p>
|
// Presenter view: what the audience sees, your private notes, and what comes next
|
||||||
<h1 className="mt-2 font-semibold"><span className="mr-3 opacity-60">{step.label}</span>{step.text || '(untitled)'}</h1>
|
<div className="grid min-h-0 flex-1 gap-0 md:grid-cols-[3fr_2fr]">
|
||||||
{step.ref && <p className="mt-2 text-[0.6em] opacity-70">{step.ref}</p>}
|
<div className="min-h-0 overflow-y-auto border-current/15 px-[3vw] py-[4dvh] md:border-r">
|
||||||
{step.scripture?.text && <blockquote className="mt-5 border-l-4 border-current/30 pl-5 text-[0.8em] italic opacity-90">“{step.scripture.text}” <span className="not-italic opacity-60">— {step.scripture.ref}</span></blockquote>}
|
<p className="mb-3 font-sans text-[11px] uppercase tracking-[0.16em] opacity-50">The audience sees</p>
|
||||||
{step.note && <p className="mt-5 whitespace-pre-wrap text-[0.7em] opacity-85">{step.note}</p>}
|
<StepFace step={step} index={index} total={steps.length} fontSize={`${SIZES[Math.max(0, size - 3)]}px`} minutes={step.minutes} />
|
||||||
{step.attachments.length > 0 && <p className="mt-5 text-[0.55em] opacity-60">{step.attachments.map((a) => `${a.type === 'word' ? 'Word' : 'See'}: ${a.label}`).join(' · ')}</p>}
|
|
||||||
</div>
|
</div>
|
||||||
) : <p className="mt-20 text-center text-lg opacity-70">There’s no outline to present yet. Build one on the Outline page.</p>}
|
<div className="min-h-0 overflow-y-auto px-[3vw] py-[4dvh]">
|
||||||
</div>
|
<p className="mb-3 font-sans text-[11px] uppercase tracking-[0.16em] text-amber-500">Only you see this</p>
|
||||||
|
{step.note.trim()
|
||||||
|
? <p className="whitespace-pre-wrap font-serif" style={{ fontSize, lineHeight: 1.45 }}>{step.note}</p>
|
||||||
|
: <p className="font-sans text-sm opacity-50">No speaker notes for this point. Add them under the point’s “⋯” details on the Outline page.</p>}
|
||||||
|
{steps[index + 1] && (
|
||||||
|
<div className="mt-8 border-t border-current/15 pt-4">
|
||||||
|
<p className="font-sans text-[11px] uppercase tracking-[0.16em] opacity-50">Next</p>
|
||||||
|
<p className="mt-1 font-serif text-xl"><span className="mr-2 opacity-60">{steps[index + 1].label}</span>{steps[index + 1].text || '(untitled)'}</p>
|
||||||
|
{steps[index + 1].minutes > 0 && <p className="font-sans text-xs opacity-60">{steps[index + 1].minutes} min</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto px-[6vw] py-[6dvh]">
|
||||||
|
{step ? (
|
||||||
|
<>
|
||||||
|
<StepFace step={step} index={index} total={steps.length} fontSize={fontSize} minutes={step.minutes} note={showNotes ? step.note : ''} />
|
||||||
|
{showNotes && step.note && <p className="mx-auto mt-2 max-w-[46em] font-sans text-[11px] uppercase tracking-[0.16em] text-amber-500">Speaker notes are showing on this screen (N to hide)</p>}
|
||||||
|
</>
|
||||||
|
) : <p className="mt-20 text-center text-lg opacity-70">There’s no outline to present yet. Build one on the Outline page.</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex shrink-0 items-center justify-between gap-3 border-t border-current/15 px-4 py-3">
|
<div className="flex shrink-0 items-center justify-between gap-3 border-t border-current/15 px-4 py-3">
|
||||||
<button type="button" onClick={() => go(-1)} disabled={index === 0} className={btn}>← Back</button>
|
<button type="button" onClick={() => go(-1)} disabled={index === 0} className={btn}>← Back</button>
|
||||||
<span className="text-xs opacity-60">{total ? `${total} min planned` : 'Set minutes on points to see your pace'} · arrows or space to move</span>
|
<span className="text-xs opacity-60">{total ? `${total} min planned` : 'Set minutes on points to see your pace'} · arrows or space to move</span>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { render, screen, act } from '@testing-library/react';
|
import { render, screen, act } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { vi, test, expect, afterEach } from 'vitest';
|
import { vi, test, expect, afterEach, beforeEach } from 'vitest';
|
||||||
import PresentMode from './PresentMode.jsx';
|
import PresentMode from './PresentMode.jsx';
|
||||||
|
|
||||||
afterEach(() => vi.useRealTimers());
|
beforeEach(() => localStorage.clear());
|
||||||
|
afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); });
|
||||||
const n = (id, text, minutes, extra = {}) => ({ id, text, ref: '', chunkId: null, minutes, note: '', kind: '', scripture: null, attachments: [], children: [], ...extra });
|
const n = (id, text, minutes, extra = {}) => ({ id, text, ref: '', chunkId: null, minutes, note: '', kind: '', scripture: null, attachments: [], children: [], ...extra });
|
||||||
const tree = [n('a', 'Welcome', 2, { note: 'Say hello' }), n('b', 'The gospel', 10, { ref: 'Rom 1:16' })];
|
const tree = [n('a', 'Welcome', 2, { note: 'Say hello' }), n('b', 'The gospel', 10, { ref: 'Rom 1:16' })];
|
||||||
|
|
||||||
@@ -11,7 +12,7 @@ test('the outline is stepped through a point at a time, with the keyboard or the
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<PresentMode mode="outline" title="Romans" tree={tree} onClose={() => {}} />);
|
render(<PresentMode mode="outline" title="Romans" tree={tree} onClose={() => {}} />);
|
||||||
expect(screen.getByText('Welcome')).toBeInTheDocument();
|
expect(screen.getByText('Welcome')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Say hello')).toBeInTheDocument();
|
expect(screen.queryByText('Say hello')).not.toBeInTheDocument(); // speaker notes are private by default
|
||||||
expect(screen.getByText(/Point 1 of 2/)).toBeInTheDocument();
|
expect(screen.getByText(/Point 1 of 2/)).toBeInTheDocument();
|
||||||
expect(screen.getByRole('button', { name: /back/i })).toBeDisabled();
|
expect(screen.getByRole('button', { name: /back/i })).toBeDisabled();
|
||||||
await user.keyboard('{ArrowRight}');
|
await user.keyboard('{ArrowRight}');
|
||||||
@@ -58,3 +59,77 @@ test('Escape and Close both leave, and an empty script says what to do', async (
|
|||||||
await user.click(screen.getByRole('button', { name: 'Close' }));
|
await user.click(screen.getByRole('button', { name: 'Close' }));
|
||||||
expect(onClose).toHaveBeenCalledTimes(2);
|
expect(onClose).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A stand-in for the browser's BroadcastChannel that records what the presenter window sends
|
||||||
|
class FakeChannel {
|
||||||
|
static instances = [];
|
||||||
|
constructor() { this.sent = []; FakeChannel.instances.push(this); }
|
||||||
|
postMessage(m) { this.sent.push(m); }
|
||||||
|
close() {}
|
||||||
|
}
|
||||||
|
const withChannel = () => { FakeChannel.instances = []; vi.stubGlobal('BroadcastChannel', FakeChannel); };
|
||||||
|
const last = () => FakeChannel.instances[0].sent.filter((m) => m.type === 'state').at(-1);
|
||||||
|
|
||||||
|
test('on one screen the speaker notes stay hidden until you turn them on', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<PresentMode mode="outline" title="Romans" tree={tree} onClose={() => {}} />);
|
||||||
|
expect(screen.queryByText('Say hello')).not.toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Show notes' }));
|
||||||
|
expect(screen.getByText('Say hello')).toBeInTheDocument();
|
||||||
|
expect(localStorage.getItem('present-notes-here')).toBe('on');
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Hide notes' }));
|
||||||
|
expect(screen.queryByText('Say hello')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('with an audience window the notes appear only on the presenter screen, never in what is sent', async () => {
|
||||||
|
withChannel();
|
||||||
|
vi.stubGlobal('open', vi.fn(() => ({ closed: false })));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<PresentMode mode="outline" title="Romans" tree={tree} onClose={() => {}} />);
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Audience window' }));
|
||||||
|
expect(window.open).toHaveBeenCalledWith(expect.stringContaining('?audience=1'), 'bible-study-audience', expect.any(String));
|
||||||
|
expect(screen.getByText('Only you see this')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Say hello')).toBeInTheDocument(); // the presenter's own screen
|
||||||
|
expect(screen.getByText(/never your notes/i)).toBeInTheDocument();
|
||||||
|
expect(last()).toMatchObject({ mode: 'outline', index: 0, total: 2, blank: false });
|
||||||
|
expect(last().step.text).toBe('Welcome');
|
||||||
|
const everything = JSON.stringify(FakeChannel.instances[0].sent);
|
||||||
|
expect(everything).not.toContain('Say hello');
|
||||||
|
expect(screen.getByText('Next')).toBeInTheDocument(); // the preview of the next point
|
||||||
|
await user.keyboard('{ArrowRight}');
|
||||||
|
expect(last().step.text).toBe('The gospel');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a window that opens later is brought up to date, and B blacks the audience screen out', async () => {
|
||||||
|
withChannel();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<PresentMode mode="outline" title="Romans" tree={tree} onClose={() => {}} />);
|
||||||
|
const channel = FakeChannel.instances[0];
|
||||||
|
act(() => channel.onmessage({ data: { type: 'hello' } })); // someone opened the audience address by hand
|
||||||
|
expect(await screen.findByText('Only you see this')).toBeInTheDocument();
|
||||||
|
expect(channel.sent.at(-1).step.text).toBe('Welcome');
|
||||||
|
await user.keyboard('b');
|
||||||
|
expect(last().blank).toBe(true);
|
||||||
|
expect(screen.getByText(/audience screen is blank/i)).toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Show audience' }));
|
||||||
|
expect(last().blank).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('in script mode the audience is only ever sent the title', async () => {
|
||||||
|
withChannel();
|
||||||
|
vi.stubGlobal('open', vi.fn(() => ({ closed: false })));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<PresentMode mode="script" title="Romans 8" script="My private script text" onClose={() => {}} />);
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Audience window' }));
|
||||||
|
expect(last()).toMatchObject({ mode: 'script', title: 'Romans 8', step: null });
|
||||||
|
expect(JSON.stringify(FakeChannel.instances[0].sent)).not.toContain('private script');
|
||||||
|
expect(screen.getByText(/only the title/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the presentation ending tells the audience window', () => {
|
||||||
|
withChannel();
|
||||||
|
const { unmount } = render(<PresentMode mode="outline" title="Romans" tree={tree} onClose={() => {}} />);
|
||||||
|
const channel = FakeChannel.instances[0];
|
||||||
|
unmount();
|
||||||
|
expect(channel.sent.at(-1)).toEqual({ type: 'end' });
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// Export → Schedule: the study's sessions (one per chunk) as a calendar file, starting on a date you pick.
|
// Finish → Schedule: the study's sessions (one per chunk) as a calendar file, starting on a date you pick.
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { formatChunkReference } from '../utils/bibleUtils.jsx';
|
import { formatChunkReference } from '../utils/bibleUtils.jsx';
|
||||||
import { buildIcs, downloadIcs, sessionDates } from '../utils/icsFile.js';
|
import { buildIcs, downloadIcs, sessionDates } from '../utils/icsFile.js';
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ const BY_PAGE = {
|
|||||||
],
|
],
|
||||||
study: [
|
study: [
|
||||||
['← →', 'Previous / next chunk'],
|
['← →', 'Previous / next chunk'],
|
||||||
|
['1 2 3', 'Write in Observe / Interpret / Apply'],
|
||||||
|
['Esc', 'Leave a note box (then keys work again)'],
|
||||||
|
['C W X P R', 'Commentary / Word study / Cross-refs / People & places / Reading list'],
|
||||||
|
['V', 'Compare passages or translations'],
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// Finish → Slides: a printable slide deck for a session or the whole study (Save as PDF in the print dialog).
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { sessionsFor } from '../utils/handout.js';
|
||||||
|
import { printSlides } from '../utils/slides.js';
|
||||||
|
|
||||||
|
export default function SlidesExport({ project }) {
|
||||||
|
const sessions = sessionsFor(project);
|
||||||
|
const [which, setWhich] = useState('all');
|
||||||
|
const [dark, setDark] = useState(false);
|
||||||
|
const [blocked, setBlocked] = useState(false);
|
||||||
|
if (sessions.length === 0) return null;
|
||||||
|
const go = () => setBlocked(!printSlides(project, which === 'all' ? null : [which], { dark }));
|
||||||
|
return (
|
||||||
|
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5" aria-label="Slides">
|
||||||
|
<h2 className="text-sm font-medium">Slides</h2>
|
||||||
|
<p className="mb-3 mt-1 text-xs text-[var(--r-sub)]">A 16:9 slide for each session’s passage, each outline point (with the verse it quotes) and the discussion questions. Choose “Save as PDF” in the print dialog, then open it in any slide viewer or project it from the PDF. It isn’t a PowerPoint file.</p>
|
||||||
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
|
<label className="text-xs text-[var(--r-sub)]">Sessions
|
||||||
|
<select value={which} onChange={(e) => setWhich(e.target.value)} className="mt-1 block rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm text-[var(--r-text)]">
|
||||||
|
<option value="all">All {sessions.length}</option>
|
||||||
|
{sessions.map((s) => <option key={s.chunk.id} value={s.chunk.id}>{s.number}. {s.reference}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 pb-2 text-xs text-[var(--r-sub)]"><input type="checkbox" checked={dark} onChange={(e) => setDark(e.target.checked)} className="accent-[var(--r-accent)]" /> Dark slides</label>
|
||||||
|
<button type="button" onClick={go} className="rounded-xl border border-[var(--r-line)] px-4 py-2 text-sm hover:bg-black/5">Print / Save PDF</button>
|
||||||
|
</div>
|
||||||
|
{blocked && <p role="alert" className="mt-2 text-xs text-[var(--r-danger,#b42318)]">Allow pop-ups for this site to print the slides.</p>}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// One outline point as it appears on screen: the point, its passage and quoted verse, and what it carries.
|
||||||
|
// Used for the whole screen in present mode, for the presenter's preview, and on the audience window. `note` and
|
||||||
|
// `minutes` are only passed where the speaker is the one looking.
|
||||||
|
export default function StepFace({ step, index, total, fontSize, minutes = null, note = '' }) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-[46em] font-serif" style={{ fontSize, lineHeight: 1.4 }}>
|
||||||
|
<p className="font-sans text-[0.4em] uppercase tracking-[0.16em] opacity-60">{step.kind ? `${step.kind} · ` : ''}Point {index + 1} of {total}{minutes ? ` · ${minutes} min` : ''}</p>
|
||||||
|
<h1 className="mt-2 font-semibold"><span className="mr-3 opacity-60">{step.label}</span>{step.text || '(untitled)'}</h1>
|
||||||
|
{step.ref && <p className="mt-2 text-[0.6em] opacity-70">{step.ref}</p>}
|
||||||
|
{step.scripture?.text && <blockquote className="mt-5 border-l-4 border-current/30 pl-5 text-[0.8em] italic opacity-90">“{step.scripture.text}” <span className="not-italic opacity-60">— {step.scripture.ref}</span></blockquote>}
|
||||||
|
{note && <p className="mt-5 whitespace-pre-wrap text-[0.7em] opacity-85">{note}</p>}
|
||||||
|
{step.attachments?.length > 0 && <p className="mt-5 text-[0.55em] opacity-60">{step.attachments.map((a) => `${a.type === 'word' ? 'Word' : 'See'}: ${a.label}`).join(' · ')}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// Export → Earlier versions: the copies the server kept of this study as you worked. Look at what is different
|
// Finish → Earlier versions: the copies the server kept of this study as you worked. Look at what is different
|
||||||
// from now, and put one back. The version you replace is kept, so putting one back can be undone too.
|
// from now, and put one back. The version you replace is kept, so putting one back can be undone too.
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { getStudyVersion, listStudyVersions } from '../syncService.js';
|
import { getStudyVersion, listStudyVersions } from '../syncService.js';
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export default function WeeklyRecall() {
|
|||||||
const { projectIndex, saveChunkRecall, openStudyChunk } = useApp();
|
const { projectIndex, saveChunkRecall, openStudyChunk } = useApp();
|
||||||
const [step, setStep] = useState('card'); // card -> write -> compare -> done
|
const [step, setStep] = useState('card'); // card -> write -> compare -> done
|
||||||
const [text, setText] = useState('');
|
const [text, setText] = useState('');
|
||||||
|
const [gaps, setGaps] = useState('');
|
||||||
const pick = useMemo(
|
const pick = useMemo(
|
||||||
() => pickRecall(projectIndex.map((e) => loadProjectById(e.id)).filter(Boolean)),
|
() => pickRecall(projectIndex.map((e) => loadProjectById(e.id)).filter(Boolean)),
|
||||||
[projectIndex],
|
[projectIndex],
|
||||||
@@ -24,7 +25,7 @@ export default function WeeklyRecall() {
|
|||||||
if (!pick) return null;
|
if (!pick) return null;
|
||||||
|
|
||||||
const finish = (rating) => {
|
const finish = (rating) => {
|
||||||
saveChunkRecall(pick.projectId, pick.chunk.id, { at: Date.now(), text: text.trim(), rating });
|
saveChunkRecall(pick.projectId, pick.chunk.id, { at: Date.now(), text: text.trim(), rating, gaps: gaps.trim() });
|
||||||
setStep('done');
|
setStep('done');
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -63,6 +64,10 @@ export default function WeeklyRecall() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<label className="mt-3 block text-xs text-[var(--r-sub)]">What did you miss or get wrong? <span className="opacity-70">(one per line; collected in Insights)</span>
|
||||||
|
<textarea aria-label="What slipped" value={gaps} onChange={(e) => setGaps(e.target.value)} rows={2}
|
||||||
|
className="mt-1 w-full resize-y 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" />
|
||||||
|
</label>
|
||||||
<p className="mt-3 text-xs text-[var(--r-sub)]">How did you do?</p>
|
<p className="mt-3 text-xs text-[var(--r-sub)]">How did you do?</p>
|
||||||
<div className="mt-1 flex flex-wrap gap-2">
|
<div className="mt-1 flex flex-wrap gap-2">
|
||||||
{RATE.map((r) => (
|
{RATE.map((r) => (
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useApp } from '../../context/AppContext.js';
|
|||||||
import { useToast } from '../Toasts.jsx';
|
import { useToast } from '../Toasts.jsx';
|
||||||
import { DownloadProgress } from '../OfflineSave.jsx';
|
import { DownloadProgress } from '../OfflineSave.jsx';
|
||||||
import { useOfflineSave } from '../../hooks/useOfflineSave.js';
|
import { useOfflineSave } from '../../hooks/useOfflineSave.js';
|
||||||
import { backupFileName, buildBackup, restoreProjects, validateBackup } from '../../utils/backup.js';
|
import { backupFileName, buildBackup, markBackedUp, restoreProjects, validateBackup } from '../../utils/backup.js';
|
||||||
import { saveRemoteProject } from '../../syncService.js';
|
import { saveRemoteProject } from '../../syncService.js';
|
||||||
|
|
||||||
const btn = 'rounded-xl border border-[var(--r-line)] px-4 py-2 text-sm hover:bg-black/5 disabled:opacity-50';
|
const btn = 'rounded-xl border border-[var(--r-line)] px-4 py-2 text-sm hover:bg-black/5 disabled:opacity-50';
|
||||||
@@ -38,6 +38,7 @@ export default function DataSection() {
|
|||||||
link.click();
|
link.click();
|
||||||
link.remove();
|
link.remove();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
|
markBackedUp();
|
||||||
toast(`Backup downloaded: ${plural(backup.projects.length, 'study', 'studies')} and your reading marks`);
|
toast(`Backup downloaded: ${plural(backup.projects.length, 'study', 'studies')} and your reading marks`);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -80,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" />
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
// Compare two passages side by side — your notes and the verses — for parallels (the Gospels), repeated themes,
|
||||||
|
// or the same chapter studied twice. Pick any chunk from any of your studies to set beside the one you're on.
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useApp } from '../../context/AppContext.js';
|
||||||
|
import { loadProjectById } from '../../utils/projectStorage.js';
|
||||||
|
import { formatChunkReference, getChunkVerseEntries } from '../../utils/bibleUtils.jsx';
|
||||||
|
import { READER_TRANSLATIONS, fetchChapter, translationName } from '../../utils/bibleText.js';
|
||||||
|
import { alignVerses, differencesFor, markRanges, translationSpans } from '../../utils/compareTranslations.js';
|
||||||
|
import { ownShare, pastedWords } from '../../utils/ownWork.js';
|
||||||
|
import { usePasteCheck } from './PastePrompt.jsx';
|
||||||
|
|
||||||
|
const FIELDS = [['observation', 'Observe'], ['interpretation', 'Interpret'], ['application', 'Apply']];
|
||||||
|
|
||||||
|
// Every chunk in your studies, as a pick-list entry that remembers where it sits.
|
||||||
|
function choices(projectIndex, currentProject) {
|
||||||
|
const projects = projectIndex.map((e) => (e.id === currentProject?.id ? currentProject : loadProjectById(e.id))).filter(Boolean);
|
||||||
|
return projects.flatMap((project) => project.chapters.flatMap((chapter, chapterIndex) => chapter.chunks.map((chunk) => ({
|
||||||
|
key: `${project.id}:${chunk.id}`, project, chapterIndex, chunk,
|
||||||
|
label: `${formatChunkReference(project, chapterIndex, chunk, '–')} · ${project.title}`,
|
||||||
|
}))));
|
||||||
|
}
|
||||||
|
|
||||||
|
function Column({ entry }) {
|
||||||
|
if (!entry) return <p className="p-4 text-sm text-[var(--r-sub)]">Pick a passage to compare.</p>;
|
||||||
|
const verses = getChunkVerseEntries(entry.project, entry.chapterIndex, entry.chunk);
|
||||||
|
return (
|
||||||
|
<div className="min-w-0 space-y-3 p-4">
|
||||||
|
<h3 className="font-medium">{entry.label}</h3>
|
||||||
|
<div className="max-h-48 space-y-1 overflow-y-auto rounded-xl bg-[var(--r-bar)] p-3 font-serif text-[13px] leading-relaxed">
|
||||||
|
{verses.map((v) => <p key={v.number}><sup className="mr-1 text-[var(--r-accent)]">{v.number}</sup>{v.text}</p>)}
|
||||||
|
</div>
|
||||||
|
{FIELDS.map(([f, label]) => (
|
||||||
|
<div key={f}>
|
||||||
|
<p className="text-[11px] uppercase tracking-wide text-[var(--r-sub)]">{label}</p>
|
||||||
|
<p className="whitespace-pre-line font-serif text-[14px] leading-relaxed">{entry.chunk[f]?.trim() || <span className="text-[var(--r-sub)]">Nothing written.</span>}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const OIA = [
|
||||||
|
['observation', 'Observe', 'What does the text say?'],
|
||||||
|
['interpretation', 'Interpret', 'What does it mean?'],
|
||||||
|
['application', 'Apply', 'What do I do with it?'],
|
||||||
|
];
|
||||||
|
|
||||||
|
// The chunk's own Observe / Interpret / Apply notes, editable right here while the translations are side by side.
|
||||||
|
// They are the same notes as on the Study page, and long pastes are tagged the same way.
|
||||||
|
function OiaBoxes({ chunk, updateChunk }) {
|
||||||
|
const share = ownShare(chunk);
|
||||||
|
const pasteCheck = usePasteCheck(chunk, updateChunk);
|
||||||
|
return (
|
||||||
|
<section aria-label="Your notes" className="border-t border-[var(--r-line)] px-4 pb-6 pt-4">
|
||||||
|
<p className="mb-3 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Your notes{share !== null ? ` · ${share}% your own words` : ''}</p>
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
{OIA.map(([field, label, hint]) => {
|
||||||
|
const pasted = pastedWords(chunk, field);
|
||||||
|
return (
|
||||||
|
<label key={field} className="flex flex-col text-xs text-[var(--r-sub)]">
|
||||||
|
<span className="mb-1 flex items-baseline justify-between"><span><span className="text-sm font-medium text-[var(--r-text)]">{label}</span> {hint}</span>{pasted > 0 && <span className="rounded-full bg-[var(--r-hl)] px-2 py-0.5 text-[var(--r-text)]">{pasted} pasted</span>}</span>
|
||||||
|
<textarea aria-label={label} value={chunk[field] ?? ''} rows={9}
|
||||||
|
onPaste={pasteCheck.onPaste(field)}
|
||||||
|
onChange={(e) => updateChunk(chunk.id, { [field]: e.target.value })}
|
||||||
|
className="min-h-[10rem] w-full resize-y rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2.5 font-serif text-[15px] leading-7 text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]" />
|
||||||
|
{pasteCheck.prompt(field)}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// One chunk's verses in several translations, a row per verse. Free translations load straight away; licensed ones
|
||||||
|
// come through the server and may be unavailable (no key set), which shows as a note instead of text.
|
||||||
|
function TranslationsView({ project, chunkEntry, updateChunk }) {
|
||||||
|
const [highlight, setHighlight] = useState(() => { try { return localStorage.getItem('compare-highlight') !== 'off'; } catch { return true; } });
|
||||||
|
const setHl = (on) => { setHighlight(on); try { localStorage.setItem('compare-highlight', on ? 'on' : 'off'); } catch { /* storage unavailable */ } };
|
||||||
|
const spans = useMemo(() => (chunkEntry ? translationSpans(project, chunkEntry.chapterIndex, chunkEntry.chunk) : []), [project, chunkEntry]);
|
||||||
|
const spanKey = spans.map((s) => `${s.book}${s.chapter}:${s.from}-${s.to}`).join(',');
|
||||||
|
const [picked, setPicked] = useState(() => {
|
||||||
|
try { const saved = JSON.parse(localStorage.getItem('compare-translations')); if (Array.isArray(saved) && saved.length) return saved; } catch { /* storage unavailable */ }
|
||||||
|
return [project?.translation ?? 'BSB', 'NET'];
|
||||||
|
});
|
||||||
|
const [loaded, setLoaded] = useState({}); // id -> { status: 'loading'|'ok'|'error', chapters, copyright, error }
|
||||||
|
const toggle = (id) => setPicked((cur) => {
|
||||||
|
const next = cur.includes(id) ? cur.filter((x) => x !== id) : [...cur, id];
|
||||||
|
try { localStorage.setItem('compare-translations', JSON.stringify(next)); } catch { /* storage unavailable */ }
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
// The sheet is modal, so the passage can't change while it's open; results are only dropped after it closes.
|
||||||
|
const alive = useRef(true);
|
||||||
|
useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
|
||||||
|
useEffect(() => {
|
||||||
|
picked.filter((id) => !loaded[id]).forEach((id) => {
|
||||||
|
setLoaded((cur) => ({ ...cur, [id]: { status: 'loading' } }));
|
||||||
|
Promise.all(spans.map((span) => fetchChapter(id, span.bookAbbrev, Number(span.chapter))
|
||||||
|
.then((res) => [`${span.book}|${span.chapter}`, res])))
|
||||||
|
.then((results) => alive.current && setLoaded((cur) => ({
|
||||||
|
...cur,
|
||||||
|
[id]: { status: 'ok', chapters: Object.fromEntries(results.map(([k, r]) => [k, r.verses])), copyright: results.map(([, r]) => r.copyright).find(Boolean) ?? '' },
|
||||||
|
})))
|
||||||
|
.catch((err) => { if (alive.current) setLoaded((cur) => ({ ...cur, [id]: { status: 'error', error: err.message || 'Unable to load.' } })); });
|
||||||
|
});
|
||||||
|
}, [picked, spanKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const shown = picked.filter((id) => loaded[id]?.status === 'ok');
|
||||||
|
const rows = alignVerses(spans, Object.fromEntries(shown.map((id) => [id, loaded[id].chapters])));
|
||||||
|
const cols = { gridTemplateColumns: `repeat(${Math.max(1, shown.length)}, minmax(0, 1fr))` };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 p-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-2" role="group" aria-label="Translations to compare">
|
||||||
|
{READER_TRANSLATIONS.map((t) => {
|
||||||
|
const on = picked.includes(t.id);
|
||||||
|
return (
|
||||||
|
<button key={t.id} type="button" aria-pressed={on} onClick={() => toggle(t.id)} title={t.name}
|
||||||
|
className={`rounded-full border px-3 py-1 text-xs ${on ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>{t.id}</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{shown.length > 1 && (
|
||||||
|
<label className="flex items-center gap-2 text-xs text-[var(--r-sub)]"><input type="checkbox" checked={highlight} onChange={(e) => setHl(e.target.checked)} className="accent-[var(--r-accent)]" /> Highlight the words that differ from {shown[0]}</label>
|
||||||
|
)}
|
||||||
|
{picked.map((id) => loaded[id]?.status === 'error' && (
|
||||||
|
<p key={id} role="status" className="text-xs text-[var(--r-sub)]">{id} isn’t available right now: {loaded[id].error}</p>
|
||||||
|
))}
|
||||||
|
{picked.some((id) => loaded[id]?.status === 'loading') && <p className="text-xs text-[var(--r-sub)]">Loading…</p>}
|
||||||
|
{shown.length === 0 && !picked.some((id) => !loaded[id] || loaded[id].status === 'loading') && <p className="text-sm text-[var(--r-sub)]">Pick two or more translations above.</p>}
|
||||||
|
{shown.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="hidden gap-4 text-[11px] uppercase tracking-wide text-[var(--r-sub)] md:grid" style={cols}>
|
||||||
|
{shown.map((id) => <span key={id}>{translationName(id)}</span>)}
|
||||||
|
</div>
|
||||||
|
{rows.map((row) => {
|
||||||
|
const diffs = highlight && shown.length > 1 ? differencesFor(row.texts, shown) : {};
|
||||||
|
const note = chunkEntry.chunk.verseNotes?.[row.noteKey] ?? '';
|
||||||
|
return (
|
||||||
|
<div key={row.key} className="border-t border-[var(--r-line)] pt-3">
|
||||||
|
<div className="grid gap-x-4 gap-y-1 md:[grid-template-columns:var(--cols)]" style={{ '--cols': cols.gridTemplateColumns }}>
|
||||||
|
{shown.map((id) => (
|
||||||
|
<p key={id} className="font-serif text-[14px] leading-relaxed">
|
||||||
|
<sup className="mr-1 font-sans text-[var(--r-accent)]">{row.label}</sup>
|
||||||
|
<span className="mr-1 font-sans text-[10px] uppercase tracking-wide text-[var(--r-sub)] md:hidden">{id}</span>
|
||||||
|
{row.texts[id] == null ? <span className="text-[var(--r-sub)]">—</span>
|
||||||
|
: markRanges(row.texts[id], diffs[id]).map((part, i) => (part.mark ? <mark key={i} className="rounded bg-[var(--r-hl)] px-0.5 text-inherit">{part.text}</mark> : <span key={i}>{part.text}</span>))}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<input aria-label={`Note on verse ${row.label}`} value={note} placeholder={`Note on verse ${row.label}`}
|
||||||
|
onChange={(e) => updateChunk(chunkEntry.chunk.id, { verseNotes: { ...(chunkEntry.chunk.verseNotes ?? {}), [row.noteKey]: e.target.value } })}
|
||||||
|
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" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div className="space-y-1 border-t border-[var(--r-line)] pt-3 text-[11px] leading-snug text-[var(--r-sub)]">
|
||||||
|
{shown.map((id) => {
|
||||||
|
const t = READER_TRANSLATIONS.find((x) => x.id === id);
|
||||||
|
return t?.notice || loaded[id].copyright ? <p key={id}>{loaded[id].copyright || t.notice}</p> : null;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CompareSheet({ currentChunkId, onClose }) {
|
||||||
|
const { projectIndex, project, updateChunk } = useApp();
|
||||||
|
// Other studies are read from storage once per list change; the open study comes straight from state so typing stays quick.
|
||||||
|
const others = useMemo(() => choices(projectIndex.filter((e) => e.id !== project?.id), null), [projectIndex, project?.id]);
|
||||||
|
const here = useMemo(() => (project?.chapters ?? []).flatMap((chapter, chapterIndex) => chapter.chunks.map((chunk) => ({
|
||||||
|
key: `${project.id}:${chunk.id}`, project, chapterIndex, chunk, label: `${formatChunkReference(project, chapterIndex, chunk, '–')} · ${project.title}`,
|
||||||
|
}))), [project]);
|
||||||
|
const mine = here.find((c) => c.chunk.id === currentChunkId);
|
||||||
|
const all = useMemo(() => [...here, ...others], [here, others]);
|
||||||
|
const [mode, setMode] = useState('passage'); // 'passage' | 'translations'
|
||||||
|
const [otherKey, setOtherKey] = useState('');
|
||||||
|
const other = all.find((c) => c.key === otherKey);
|
||||||
|
const tab = (id) => `rounded-full border px-3 py-1 text-xs ${mode === id ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`;
|
||||||
|
return (
|
||||||
|
<div role="dialog" aria-modal="true" aria-label="Compare passages" className="fixed inset-0 z-[70] flex flex-col bg-[var(--r-bg)] text-[var(--r-text)]">
|
||||||
|
<div className="flex shrink-0 flex-wrap items-center gap-x-3 gap-y-2 border-b border-[var(--r-line)] bg-[var(--r-bar)] px-4 py-2.5">
|
||||||
|
<h2 className="mr-auto text-sm font-medium sm:mr-0">Compare</h2>
|
||||||
|
<div role="tablist" aria-label="What to compare" className="flex gap-1.5">
|
||||||
|
<button type="button" role="tab" aria-selected={mode === 'passage'} onClick={() => setMode('passage')} className={tab('passage')}>Another passage</button>
|
||||||
|
<button type="button" role="tab" aria-selected={mode === 'translations'} onClick={() => setMode('translations')} className={tab('translations')}>Translations</button>
|
||||||
|
</div>
|
||||||
|
{mode === 'passage' && (
|
||||||
|
<select aria-label="Compare with" value={otherKey} onChange={(e) => setOtherKey(e.target.value)}
|
||||||
|
className="order-last min-w-0 basis-full rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm sm:order-none sm:basis-0 sm:flex-1">
|
||||||
|
<option value="">Choose a passage to set beside this one…</option>
|
||||||
|
{all.filter((c) => c.key !== mine?.key).map((c) => <option key={c.key} value={c.key}>{c.label}</option>)}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
<button type="button" onClick={onClose} className="ml-auto rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-sm hover:bg-black/5">Close</button>
|
||||||
|
</div>
|
||||||
|
{mode === 'passage' ? (
|
||||||
|
<div className="grid min-h-0 flex-1 overflow-y-auto md:grid-cols-2 md:divide-x md:divide-[var(--r-line)]">
|
||||||
|
<Column entry={mine} />
|
||||||
|
<Column entry={other} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||||
|
<h3 className="px-4 pt-4 font-medium">{mine?.label}</h3>
|
||||||
|
<TranslationsView project={project} chunkEntry={mine} updateChunk={updateChunk} />
|
||||||
|
{mine && <OiaBoxes chunk={mine.chunk} updateChunk={updateChunk} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// "What changed since my first draft": your Observe / Interpret / Apply notes now, with the words added or
|
||||||
|
// reworded since the copy kept when you first opened commentary. It shows whether commentary changed your thinking.
|
||||||
|
import { diffVerses } from '../../utils/textDiff.js';
|
||||||
|
import { markRanges } from '../../utils/compareTranslations.js';
|
||||||
|
|
||||||
|
const FIELDS = [['observation', 'Observe'], ['interpretation', 'Interpret'], ['application', 'Apply']];
|
||||||
|
const count = (s) => (s ?? '').trim().split(/\s+/).filter(Boolean).length;
|
||||||
|
|
||||||
|
export default function FirstDraft({ chunk }) {
|
||||||
|
const draft = chunk.firstDraft;
|
||||||
|
if (!draft) return null;
|
||||||
|
const rows = FIELDS.map(([f, label]) => {
|
||||||
|
const then = draft[f] ?? '';
|
||||||
|
const now = chunk[f] ?? '';
|
||||||
|
return { f, label, then, now, same: then.trim() === now.trim(), parts: markRanges(now, diffVerses(then, now).b) };
|
||||||
|
}).filter((r) => r.then.trim() || r.now.trim());
|
||||||
|
const changed = rows.filter((r) => !r.same).length;
|
||||||
|
return (
|
||||||
|
<details className="rounded-xl border border-[var(--r-line)] p-3 text-xs text-[var(--r-sub)]">
|
||||||
|
<summary className="cursor-pointer">First draft, before commentary <span className="opacity-70">· {new Date(draft.at).toLocaleDateString()} · {changed === 0 ? 'nothing changed since' : `${changed} of ${rows.length} notes changed since`}</span></summary>
|
||||||
|
<div className="mt-3 space-y-3">
|
||||||
|
{rows.length === 0 && <p>You opened commentary before writing anything.</p>}
|
||||||
|
{rows.map((r) => (
|
||||||
|
<div key={r.f}>
|
||||||
|
<p className="font-medium text-[var(--r-text)]">{r.label} <span className="font-normal text-[var(--r-sub)]">· {count(r.then)} words then, {count(r.now)} now</span></p>
|
||||||
|
{r.same
|
||||||
|
? <p className="mt-0.5">Unchanged.</p>
|
||||||
|
: <p className="mt-0.5 whitespace-pre-line font-serif text-[13px] leading-relaxed text-[var(--r-text)]">{r.parts.map((p, i) => (p.mark ? <mark key={i} className="rounded bg-[var(--r-hl)] px-0.5 text-inherit">{p.text}</mark> : <span key={i}>{p.text}</span>))}</p>}
|
||||||
|
{!r.same && r.then.trim() && <details className="mt-1"><summary className="cursor-pointer">Show the first draft</summary><p className="mt-1 whitespace-pre-line font-serif text-[13px] leading-relaxed">{r.then}</p></details>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<p className="opacity-70">Highlighted words are new or reworded since the draft.</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
// Observe / Interpret / Apply as an accordion (one open at a time), plus a "More"
|
// Observe / Interpret / Apply as an accordion (one open at a time), plus a "More"
|
||||||
// area for background notes and tags so the page stays quiet.
|
// area for background notes and tags so the page stays quiet.
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import SessionNotes from './SessionNotes.jsx';
|
||||||
|
import FirstDraft from './FirstDraft.jsx';
|
||||||
|
import { usePasteCheck } from './PastePrompt.jsx';
|
||||||
|
import { listNoteVersions, recordNoteVersion } from '../../utils/noteHistory.js';
|
||||||
import DictateButton, { appendSpoken } from '../DictateButton.jsx';
|
import DictateButton, { appendSpoken } from '../DictateButton.jsx';
|
||||||
import { ownShare, ownWords, pastedWords, withPaste, clearPastes } from '../../utils/ownWork.js';
|
import { ownShare, ownWords, pastedWords, clearPastes } from '../../utils/ownWork.js';
|
||||||
import { templateFromChunk, templatePrompt } from '../../utils/studyTemplates.js';
|
import { templateFromChunk, templatePrompt } from '../../utils/studyTemplates.js';
|
||||||
|
|
||||||
const SECTIONS = [
|
const SECTIONS = [
|
||||||
@@ -17,11 +21,25 @@ const SECTIONS = [
|
|||||||
const areaCls = 'w-full resize-y rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3.5 py-3 font-serif text-[15px] leading-7 text-[var(--r-text)] placeholder:font-sans placeholder:text-[13px] placeholder:leading-5 placeholder:text-[var(--r-sub)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]';
|
const areaCls = 'w-full resize-y rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3.5 py-3 font-serif text-[15px] leading-7 text-[var(--r-text)] placeholder:font-sans placeholder:text-[13px] placeholder:leading-5 placeholder:text-[var(--r-sub)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]';
|
||||||
const words = (s) => (s ?? '').trim().split(/\s+/).filter(Boolean).length;
|
const words = (s) => (s ?? '').trim().split(/\s+/).filter(Boolean).length;
|
||||||
|
|
||||||
export default function NotesPane({ chunk, updateChunk, allTags, templateId, saveTemplate, notify }) {
|
export default function NotesPane({ chunk, updateChunk, allTags, templateId, saveTemplate, notify, onPray, focusRequest }) {
|
||||||
const [open, setOpen] = useState(() => {
|
const [open, setOpen] = useState(() => {
|
||||||
try { return localStorage.getItem('studyNotesOpen') || 'observation'; } catch { return 'observation'; }
|
try { return localStorage.getItem('studyNotesOpen') || 'observation'; } catch { return 'observation'; }
|
||||||
});
|
});
|
||||||
const [more, setMore] = useState(false);
|
const [more, setMore] = useState(false);
|
||||||
|
// A keyboard jump (1 / 2 / 3 on the Study page): open that note and put the cursor in it.
|
||||||
|
const areas = useRef({});
|
||||||
|
const wanted = useRef(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!focusRequest) return;
|
||||||
|
wanted.current = focusRequest.field;
|
||||||
|
setOpen(focusRequest.field);
|
||||||
|
}, [focusRequest]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (wanted.current && wanted.current === open) {
|
||||||
|
const el = areas.current[open];
|
||||||
|
if (el) { el.focus(); const end = el.value.length; el.setSelectionRange(end, end); wanted.current = null; }
|
||||||
|
}
|
||||||
|
});
|
||||||
const [tplName, setTplName] = useState('');
|
const [tplName, setTplName] = useState('');
|
||||||
const [tplOpen, setTplOpen] = useState(false);
|
const [tplOpen, setTplOpen] = useState(false);
|
||||||
const [tagInput, setTagInput] = useState('');
|
const [tagInput, setTagInput] = useState('');
|
||||||
@@ -38,9 +56,25 @@ export default function NotesPane({ chunk, updateChunk, allTags, templateId, sav
|
|||||||
const suggestions = allTags.filter((t) => !tags.some((x) => x.toLowerCase() === t.toLowerCase()));
|
const suggestions = allTags.filter((t) => !tags.some((x) => x.toLowerCase() === t.toLowerCase()));
|
||||||
|
|
||||||
const share = ownShare(chunk);
|
const share = ownShare(chunk);
|
||||||
const keepPaste = (field) => (e) => {
|
const pasteCheck = usePasteCheck(chunk, updateChunk);
|
||||||
const next = withPaste(chunk, field, e.clipboardData?.getData('text'));
|
// Earlier versions of one note: kept when you start and stop editing it; "History" lists them with Restore.
|
||||||
if (next) updateChunk(chunk.id, { aiPastes: next });
|
const [historyField, setHistoryField] = useState(null);
|
||||||
|
const [versions, setVersions] = useState([]);
|
||||||
|
useEffect(() => { setHistoryField(null); }, [chunk.id]);
|
||||||
|
const keepVersion = (field) => {
|
||||||
|
const list = recordNoteVersion(chunk.id, field, chunk[field]);
|
||||||
|
if (historyField === field) setVersions(list);
|
||||||
|
};
|
||||||
|
const openHistory = (field) => {
|
||||||
|
if (historyField === field) { setHistoryField(null); return; }
|
||||||
|
recordNoteVersion(chunk.id, field, chunk[field]);
|
||||||
|
setVersions(listNoteVersions(chunk.id, field));
|
||||||
|
setHistoryField(field);
|
||||||
|
};
|
||||||
|
const restore = (field, text) => {
|
||||||
|
recordNoteVersion(chunk.id, field, chunk[field]); // so restoring can itself be undone
|
||||||
|
updateChunk(chunk.id, { [field]: text });
|
||||||
|
setVersions(listNoteVersions(chunk.id, field));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -72,15 +106,31 @@ export default function NotesPane({ chunk, updateChunk, allTags, templateId, sav
|
|||||||
</button>
|
</button>
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div className="px-4 pb-4">
|
<div className="px-4 pb-4">
|
||||||
<textarea 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}
|
||||||
onPaste={keepPaste(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} />
|
||||||
|
{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>
|
||||||
|
{historyField === field && (
|
||||||
|
<ul aria-label={`${label} history`} className="mt-1.5 space-y-1.5 rounded-xl border border-[var(--r-line)] p-2 text-xs">
|
||||||
|
{versions.length === 0 && <li className="text-[var(--r-sub)]">No earlier versions yet. They are saved as you edit.</li>}
|
||||||
|
{versions.map((v) => (
|
||||||
|
<li key={v.at} className="flex items-baseline justify-between gap-3">
|
||||||
|
<span className="min-w-0"><span className="text-[var(--r-sub)]">{new Date(v.at).toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })} · {words(v.text)} words · </span><span className="font-serif">{v.text.length > 70 ? `${v.text.slice(0, 70)}…` : v.text}</span></span>
|
||||||
|
<button type="button" onClick={() => restore(field, v.text)} disabled={v.text === chunk[field]} className="shrink-0 text-[var(--r-accent)] hover:underline disabled:opacity-40 disabled:no-underline">Restore</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
{pasted > 0 && (
|
{pasted > 0 && (
|
||||||
<p className="mt-1.5 text-xs text-[var(--r-sub)]">
|
<p className="mt-1.5 text-xs text-[var(--r-sub)]">
|
||||||
{pasted} word{pasted === 1 ? '' : 's'} here were pasted, so they don't count as your own work.{' '}
|
{pasted} word{pasted === 1 ? '' : 's'} here were pasted, so they don't count as your own work.{' '}
|
||||||
<button type="button" onClick={() => updateChunk(chunk.id, { aiPastes: clearPastes(chunk, field) })} className="text-[var(--r-accent)] hover:underline">That’s mine</button>
|
<button type="button" onClick={() => updateChunk(chunk.id, { aiPastes: clearPastes(chunk, field) })} className="text-[var(--r-accent)] hover:underline">That’s mine</button>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="mt-1.5"><DictateButton label={`Dictate ${label.toLowerCase()}`} onText={(said) => updateChunk(chunk.id, { [field]: appendSpoken(chunk[field], said) })} /></div>
|
{field === 'application' && onPray && (
|
||||||
|
<button type="button" onClick={onPray} className="mt-1.5 mr-3 text-xs text-[var(--r-accent)] hover:underline">Pray about this →</button>
|
||||||
|
)}
|
||||||
|
<div className="mt-1.5 inline-block"><DictateButton label={`Dictate ${label.toLowerCase()}`} onText={(said) => updateChunk(chunk.id, { [field]: appendSpoken(chunk[field], said) })} /></div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -106,6 +156,8 @@ export default function NotesPane({ chunk, updateChunk, allTags, templateId, sav
|
|||||||
<textarea value={chunk.discussionQuestions ?? ''} rows={4} onChange={(e) => updateChunk(chunk.id, { discussionQuestions: e.target.value })}
|
<textarea value={chunk.discussionQuestions ?? ''} rows={4} onChange={(e) => updateChunk(chunk.id, { discussionQuestions: e.target.value })}
|
||||||
placeholder={'What does this passage say about God?\nWhere do you see this in your own life?'} className={`mt-1 ${areaCls}`} />
|
placeholder={'What does this passage say about God?\nWhere do you see this in your own life?'} className={`mt-1 ${areaCls}`} />
|
||||||
</label>
|
</label>
|
||||||
|
<FirstDraft chunk={chunk} />
|
||||||
|
<SessionNotes chunk={chunk} updateChunk={updateChunk} />
|
||||||
{saveTemplate && (
|
{saveTemplate && (
|
||||||
<div className="rounded-xl border border-dashed border-[var(--r-line)] p-3">
|
<div className="rounded-xl border border-dashed border-[var(--r-line)] p-3">
|
||||||
{!tplOpen ? (
|
{!tplOpen ? (
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// When a long block of text is pasted into a note, ask where it came from. Pastes are remembered as "not typed by
|
||||||
|
// me" straight away (so forgetting to answer never counts as your own work); saying you wrote it takes that back.
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { withPaste, withoutPaste, PASTE_MIN_WORDS } from '../../utils/ownWork.js';
|
||||||
|
|
||||||
|
const LABELS = { observation: 'Observe', interpretation: 'Interpret', application: 'Apply' };
|
||||||
|
const wordsIn = (s) => s.trim().split(/\s+/).filter(Boolean).length;
|
||||||
|
|
||||||
|
export function usePasteCheck(chunk, updateChunk) {
|
||||||
|
const [pending, setPending] = useState(null); // { field, text }
|
||||||
|
useEffect(() => { setPending(null); }, [chunk.id]);
|
||||||
|
|
||||||
|
const onPaste = (field) => (e) => {
|
||||||
|
const text = e.clipboardData?.getData('text') ?? '';
|
||||||
|
const next = withPaste(chunk, field, text);
|
||||||
|
if (!next) return;
|
||||||
|
updateChunk(chunk.id, { aiPastes: next });
|
||||||
|
setPending({ field, text: text.trim() });
|
||||||
|
};
|
||||||
|
const wroteIt = () => {
|
||||||
|
if (pending) updateChunk(chunk.id, { aiPastes: withoutPaste(chunk, pending.field, pending.text) });
|
||||||
|
setPending(null);
|
||||||
|
};
|
||||||
|
const prompt = (field) => (pending && pending.field === field ? <PastePrompt field={field} words={wordsIn(pending.text)} onKeep={() => setPending(null)} onWroteIt={wroteIt} /> : null);
|
||||||
|
return { onPaste, prompt };
|
||||||
|
}
|
||||||
|
|
||||||
|
function PastePrompt({ field, words, onKeep, onWroteIt }) {
|
||||||
|
const btn = 'rounded-full border border-[var(--r-text)] px-3 py-1 text-xs hover:bg-black/5';
|
||||||
|
return (
|
||||||
|
<div role="group" aria-label="Where did this paste come from?" className="mt-1.5 rounded-xl border border-[var(--r-accent)] bg-[var(--r-hl)] p-3 text-xs">
|
||||||
|
<p className="font-medium text-[var(--r-text)]">You pasted {words} words into {LABELS[field] ?? 'this note'}. Is this from AI?</p>
|
||||||
|
<p className="mt-0.5 text-[var(--r-sub)]">Pasted text isn’t counted as your own work. Choose “I wrote it” if it came from you.</p>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
<button type="button" onClick={onKeep} className={`${btn} bg-[var(--r-text)] text-[var(--r-bg)]`}>Yes, from AI</button>
|
||||||
|
<button type="button" onClick={onKeep} className={btn}>No, a book or website</button>
|
||||||
|
<button type="button" onClick={onWroteIt} className={btn}>No, I wrote it</button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1.5 text-[11px] text-[var(--r-sub)]">Pastes under {PASTE_MIN_WORDS} words aren’t asked about.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,26 +1,40 @@
|
|||||||
// Right-hand pane of the Study stage: commentary, word study, cross-references and
|
// Right-hand pane of the Study stage: commentary, word study, cross-references and
|
||||||
// people & places, all driven by the selected chunk.
|
// people & places, all driven by the selected chunk.
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import CommentaryChunkTab from './CommentaryChunkTab.jsx';
|
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';
|
||||||
|
|
||||||
export const RESOURCE_TABS = [
|
export const RESOURCE_TABS = [
|
||||||
{ id: 'commentary', label: 'Commentary' },
|
{ id: 'commentary', label: 'Commentary' },
|
||||||
{ 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';
|
||||||
const counts = { words: chunk.greekWords.length, refs: (chunk.crossReferences ?? []).length };
|
// Commentary-type tabs wait for your own Observe notes (per chunk, until you choose to look anyway).
|
||||||
|
const [unlocked, setUnlocked] = useState(() => new Set());
|
||||||
|
useEffect(() => { setUnlocked(new Set()); }, [chunk.id]);
|
||||||
|
const held = (tab === 'commentary' || tab === 'aids') && !observeFirstDone(chunk) && !unlocked.has(chunk.id);
|
||||||
|
// The first time commentary or study aids are on screen for a chunk, keep a copy of your notes as they were,
|
||||||
|
// so you can later see what changed after reading others (shown under "More" in the notes).
|
||||||
|
const seeing = (tab === 'commentary' || tab === 'aids') && !held;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!seeing || chunk.firstDraft) return;
|
||||||
|
updateChunk(chunk.id, { firstDraft: { at: Date.now(), observation: chunk.observation ?? '', interpretation: chunk.interpretation ?? '', application: chunk.application ?? '' } }, { touch: false });
|
||||||
|
}, [seeing, chunk.id, chunk.firstDraft]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
const counts = { words: chunk.greekWords.length, refs: (chunk.crossReferences ?? []).length, 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">
|
||||||
@@ -32,11 +46,19 @@ export default function ResourcePane({ tab, setTab, chunk, chapter, chapterIndex
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div role="tabpanel" className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
|
<div role="tabpanel" className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
|
||||||
{tab === 'commentary' && <CommentaryChunkTab resources={resources} chunk={chunk} project={project} chapterIndex={chapterIndex} updateChunk={updateChunk} notify={notify} />}
|
{held && (
|
||||||
|
<div role="note" className="rounded-xl border border-[var(--r-line)] bg-[var(--r-bar)] p-4 text-sm">
|
||||||
|
<p className="font-medium">Observe first</p>
|
||||||
|
<p className="mt-1 text-xs text-[var(--r-sub)]">Write what you notice in the text before reading what others say. Commentary opens after {OBSERVE_FIRST_MIN} words of your own Observe notes ({ownWords(chunk, 'observation')} so far).</p>
|
||||||
|
<button type="button" onClick={() => setUnlocked((u) => new Set(u).add(chunk.id))} className="mt-2 text-xs text-[var(--r-accent)] hover:underline">Show it anyway</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!held && tab === 'commentary' && <CommentaryChunkTab resources={resources} chunk={chunk} project={project} chapterIndex={chapterIndex} updateChunk={updateChunk} notify={notify} />}
|
||||||
{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 === 'aids' && <IqChunkAids chunk={chunk} chapter={chapter} updateChunk={updateChunk} notify={notify} />}
|
{tab === 'readings' && updateProject && <ReadingListTab project={project} updateProject={updateProject} />}
|
||||||
|
{!held && tab === 'aids' && <IqChunkAids chunk={chunk} chapter={chapter} updateChunk={updateChunk} notify={notify} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,8 +3,9 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { NT_BOOK_NUMBER } from '../../utils/bibleUtils.jsx';
|
import { NT_BOOK_NUMBER } from '../../utils/bibleUtils.jsx';
|
||||||
|
|
||||||
export default function ScripturePane({ verses, chapter, interlinear, interlinearLoading, addedStrongs, onPickWord }) {
|
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 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,6 +21,17 @@ export default function ScripturePane({ verses, chapter, interlinear, interlinea
|
|||||||
{v.chapter !== chapter?.chapter ? `${v.chapter}:` : ''}{v.number}
|
{v.chapter !== chapter?.chapter ? `${v.chapter}:` : ''}{v.number}
|
||||||
</button>
|
</button>
|
||||||
<span>{v.text}</span>
|
<span>{v.text}</span>
|
||||||
|
{onVerseNote && (
|
||||||
|
<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"
|
||||||
|
className={`ml-1.5 align-baseline font-sans text-[0.7em] ${verseNotes[`${v.chapter}:${v.number}`] ? 'text-[var(--r-accent)]' : 'text-[var(--r-sub)] opacity-40 hover:opacity-100'}`}>✎</button>
|
||||||
|
)}
|
||||||
|
{(verseNotes[`${v.chapter}:${v.number}`] || editingNote === `${v.chapter}:${v.number}`) && (
|
||||||
|
<textarea aria-label={`Verse ${v.number} note`} value={verseNotes[`${v.chapter}:${v.number}`] ?? ''} rows={2} autoFocus={editingNote === `${v.chapter}:${v.number}`}
|
||||||
|
onChange={(e) => onVerseNote?.(`${v.chapter}:${v.number}`, e.target.value)}
|
||||||
|
placeholder="Your note on this verse"
|
||||||
|
className="mt-1 block w-full resize-y rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 font-sans text-[13px] leading-snug text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]" />
|
||||||
|
)}
|
||||||
{open && words && (
|
{open && words && (
|
||||||
<div dir={isHebrew ? 'rtl' : 'ltr'} className="mt-2 flex flex-wrap gap-2 rounded-xl bg-[var(--r-bg)] p-2.5 font-sans">
|
<div dir={isHebrew ? 'rtl' : 'ltr'} className="mt-2 flex flex-wrap gap-2 rounded-xl bg-[var(--r-bg)] p-2.5 font-sans">
|
||||||
{words.map((w, i) => {
|
{words.map((w, i) => {
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// The "More" area's session-facing notes: a teach-it-back summary, who came, what the group said, and follow-ups.
|
||||||
|
import { makeId } from '../../utils/bibleUtils.jsx';
|
||||||
|
|
||||||
|
const areaCls = 'mt-1 w-full resize-y 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)]';
|
||||||
|
|
||||||
|
export default function SessionNotes({ chunk, updateChunk }) {
|
||||||
|
const followUps = chunk.followUps ?? [];
|
||||||
|
const setFollowUps = (next) => updateChunk(chunk.id, { followUps: next });
|
||||||
|
const add = (text) => { const t = text.trim(); if (t) setFollowUps([...followUps, { id: makeId(), text: t, done: false }]); };
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<label className="block text-xs text-[var(--r-sub)]">
|
||||||
|
Teach it back <span className="opacity-70">(explain the passage in three sentences, as you would to someone else)</span>
|
||||||
|
<textarea value={chunk.teachBack ?? ''} rows={3} onChange={(e) => updateChunk(chunk.id, { teachBack: e.target.value })} className={areaCls} />
|
||||||
|
</label>
|
||||||
|
<label className="block text-xs text-[var(--r-sub)]">
|
||||||
|
Who came
|
||||||
|
<input value={chunk.attendees ?? ''} onChange={(e) => updateChunk(chunk.id, { attendees: e.target.value })} placeholder="Names, separated by commas"
|
||||||
|
className="mt-1 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" />
|
||||||
|
</label>
|
||||||
|
<label className="block text-xs text-[var(--r-sub)]">
|
||||||
|
What the group said
|
||||||
|
<textarea value={chunk.groupNotes ?? ''} rows={3} onChange={(e) => updateChunk(chunk.id, { groupNotes: e.target.value })} className={areaCls} />
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-[var(--r-sub)]">Follow-ups</p>
|
||||||
|
<ul className="mt-1 space-y-1">
|
||||||
|
{followUps.map((f) => (
|
||||||
|
<li key={f.id} className="flex items-center gap-2 text-sm">
|
||||||
|
<input type="checkbox" aria-label={`Done: ${f.text}`} checked={!!f.done} onChange={() => setFollowUps(followUps.map((x) => (x.id === f.id ? { ...x, done: !x.done } : x)))} className="accent-[var(--r-accent)]" />
|
||||||
|
<span className={f.done ? 'text-[var(--r-sub)] line-through' : ''}>{f.text}</span>
|
||||||
|
<button type="button" aria-label={`Remove follow-up: ${f.text}`} onClick={() => setFollowUps(followUps.filter((x) => x.id !== f.id))} className="ml-auto text-xs text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]">×</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<input aria-label="Add a follow-up" placeholder="Something to follow up on, then Enter"
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); add(e.target.value); e.target.value = ''; } }}
|
||||||
|
className="mt-1.5 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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ export const STAGES = [
|
|||||||
{ id: 'study', label: 'Study' },
|
{ id: 'study', label: 'Study' },
|
||||||
{ id: 'outline', label: 'Outline' },
|
{ id: 'outline', label: 'Outline' },
|
||||||
{ id: 'script', label: 'Script' },
|
{ id: 'script', label: 'Script' },
|
||||||
{ id: 'export', label: 'Export' },
|
{ id: 'export', label: 'Finish' },
|
||||||
];
|
];
|
||||||
const THEME_ORDER = ['paper', 'sepia', 'dark'];
|
const THEME_ORDER = ['paper', 'sepia', 'dark'];
|
||||||
|
|
||||||
@@ -28,21 +28,21 @@ export default function StudioShell({ stage, loading = false, fixedHeight = fals
|
|||||||
<div className="safe-top z-30 shrink-0 border-b border-[var(--r-line)] bg-[var(--r-bar)]">
|
<div className="safe-top z-30 shrink-0 border-b border-[var(--r-line)] bg-[var(--r-bar)]">
|
||||||
<div className="mx-auto flex h-12 max-w-[1600px] items-center gap-3 px-3">
|
<div className="mx-auto flex h-12 max-w-[1600px] items-center gap-3 px-3">
|
||||||
<HomeButton onClick={goHome} />
|
<HomeButton onClick={goHome} />
|
||||||
<div className="min-w-0 max-w-[16rem] truncate text-sm font-medium">{project?.title ?? ''}</div>
|
<div className="hidden min-w-0 max-w-[16rem] truncate text-sm font-medium sm:block">{project?.title ?? ''}</div>
|
||||||
<nav aria-label="Study stages" className="mx-auto flex gap-0.5">
|
<nav aria-label="Study stages" className="flex min-w-0 flex-1 gap-0.5 overflow-x-auto sm:justify-center [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||||
{STAGES.map((s) => {
|
{STAGES.map((s) => {
|
||||||
const disabled = s.id !== 'setup' && (!project || noChunks);
|
const disabled = s.id !== 'setup' && (!project || noChunks);
|
||||||
return (
|
return (
|
||||||
<button key={s.id} type="button" disabled={disabled} aria-current={stage === s.id ? 'step' : undefined}
|
<button key={s.id} type="button" disabled={disabled} aria-current={stage === s.id ? 'step' : undefined}
|
||||||
title={disabled ? 'Add a chunk in Chunks first' : undefined}
|
title={disabled ? 'Add a chunk in Chunks first' : undefined}
|
||||||
onClick={() => setCurrentPage(s.id)}
|
onClick={() => setCurrentPage(s.id)}
|
||||||
className={`rounded-full px-3.5 py-1.5 text-xs ${stage === s.id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'} disabled:opacity-40 disabled:hover:bg-transparent`}>
|
className={`shrink-0 rounded-full px-3.5 py-1.5 text-xs ${stage === s.id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'} disabled:opacity-40 disabled:hover:bg-transparent`}>
|
||||||
{s.label}
|
{s.label}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="flex min-w-[7rem] items-center justify-end gap-2 text-xs text-[var(--r-sub)]" aria-live="polite">
|
<div className="flex shrink-0 items-center justify-end sm:min-w-[7rem] gap-2 text-xs text-[var(--r-sub)]" aria-live="polite">
|
||||||
{loading ? <span>Loading…</span>
|
{loading ? <span>Loading…</span>
|
||||||
: saveStatus ? <span className="text-[var(--r-accent)]">{saveStatus}</span>
|
: saveStatus ? <span className="text-[var(--r-accent)]">{saveStatus}</span>
|
||||||
: syncStatus === 'syncing' ? <span>Syncing…</span>
|
: syncStatus === 'syncing' ? <span>Syncing…</span>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -221,8 +221,8 @@ export function useProjectLibrary({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Saves a weekly recall (what you remembered, and how it went) on one chunk of a stored study.
|
// Changes one chunk of a stored study (from Home, without opening it) and syncs it. `change` takes the chunk, returns the new one.
|
||||||
const saveChunkRecall = (id, chunkId, recall) => {
|
const updateStoredChunk = (id, chunkId, change) => {
|
||||||
const loaded = loadProjectById(id);
|
const loaded = loadProjectById(id);
|
||||||
if (!loaded) return;
|
if (!loaded) return;
|
||||||
const updated = {
|
const updated = {
|
||||||
@@ -230,7 +230,7 @@ export function useProjectLibrary({
|
|||||||
lastEdited: Date.now(),
|
lastEdited: Date.now(),
|
||||||
chapters: loaded.chapters.map((ch) => ({
|
chapters: loaded.chapters.map((ch) => ({
|
||||||
...ch,
|
...ch,
|
||||||
chunks: ch.chunks.map((c) => (c.id === chunkId ? { ...c, recalls: [...(c.recalls ?? []), recall] } : c)),
|
chunks: ch.chunks.map((c) => (c.id === chunkId ? change(c) : c)),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
saveProjectToStorage(updated);
|
saveProjectToStorage(updated);
|
||||||
@@ -247,6 +247,11 @@ export function useProjectLibrary({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// A weekly recall (what you remembered, and how it went) on one chunk.
|
||||||
|
const saveChunkRecall = (id, chunkId, recall) => updateStoredChunk(id, chunkId, (c) => ({ ...c, recalls: [...(c.recalls ?? []), recall] }));
|
||||||
|
// Notes written from Home (the daily five minutes): counts as editing the chunk.
|
||||||
|
const saveChunkPatch = (id, chunkId, patch) => updateStoredChunk(id, chunkId, (c) => ({ ...c, ...patch, editedAt: Date.now() }));
|
||||||
|
|
||||||
// Deleting is immediate here, with an Undo for a few seconds; only after that is the server copy
|
// Deleting is immediate here, with an Undo for a few seconds; only after that is the server copy
|
||||||
// removed (and straight away if the tab is closed first, so a deleted project never comes back).
|
// removed (and straight away if the tab is closed first, so a deleted project never comes back).
|
||||||
const finishDelete = (id) => {
|
const finishDelete = (id) => {
|
||||||
@@ -314,6 +319,7 @@ export function useProjectLibrary({
|
|||||||
renameProjectInStorage,
|
renameProjectInStorage,
|
||||||
setProjectSeries,
|
setProjectSeries,
|
||||||
saveChunkRecall,
|
saveChunkRecall,
|
||||||
|
saveChunkPatch,
|
||||||
deleteProject,
|
deleteProject,
|
||||||
pullLatestFromServer,
|
pullLatestFromServer,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,14 +6,15 @@ export function useShareLink(projectId) {
|
|||||||
const [shareToken, setShareToken] = useState(null);
|
const [shareToken, setShareToken] = useState(null);
|
||||||
const [expiresAt, setExpiresAt] = useState(null);
|
const [expiresAt, setExpiresAt] = useState(null);
|
||||||
const [hasPasscode, setHasPasscode] = useState(false);
|
const [hasPasscode, setHasPasscode] = useState(false);
|
||||||
|
const [revealAt, setRevealAt] = useState(null);
|
||||||
const [shareBusy, setShareBusy] = useState(false);
|
const [shareBusy, setShareBusy] = useState(false);
|
||||||
const [shareCopied, setShareCopied] = useState(false);
|
const [shareCopied, setShareCopied] = useState(false);
|
||||||
const [shareError, setShareError] = useState('');
|
const [shareError, setShareError] = useState('');
|
||||||
|
|
||||||
const apply = (data) => { setShareToken(data.shareToken ?? null); setExpiresAt(data.expiresAt ?? null); setHasPasscode(!!data.hasPasscode); };
|
const apply = (data) => { setShareToken(data.shareToken ?? null); setExpiresAt(data.expiresAt ?? null); setHasPasscode(!!data.hasPasscode); setRevealAt(data.revealAt ?? null); };
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setShareToken(null); setExpiresAt(null); setHasPasscode(false); setShareError('');
|
setShareToken(null); setExpiresAt(null); setHasPasscode(false); setRevealAt(null); setShareError('');
|
||||||
if (!projectId) return undefined;
|
if (!projectId) return undefined;
|
||||||
let active = true;
|
let active = true;
|
||||||
getShareStatus(projectId).then((result) => {
|
getShareStatus(projectId).then((result) => {
|
||||||
@@ -26,7 +27,7 @@ export function useShareLink(projectId) {
|
|||||||
? `${window.location.origin}${window.location.pathname}?share=${shareToken}`
|
? `${window.location.origin}${window.location.pathname}?share=${shareToken}`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
// Turns the link on, or changes its rules: { expiresInDays?: number | null, passcode?: string | null }
|
// Turns the link on, or changes its rules: { expiresInDays?: number | null, passcode?: string | null, revealAt?: number | null }
|
||||||
const enable = async (options = {}) => {
|
const enable = async (options = {}) => {
|
||||||
setShareBusy(true);
|
setShareBusy(true);
|
||||||
setShareError('');
|
setShareError('');
|
||||||
@@ -52,5 +53,5 @@ export function useShareLink(projectId) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return { shareToken, shareUrl, expiresAt, hasPasscode, shareBusy, shareCopied, shareError, enable, revoke, copy };
|
return { shareToken, shareUrl, expiresAt, hasPasscode, revealAt, shareBusy, shareCopied, shareError, enable, revoke, copy };
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
// The Export stage: take the finished study out as a file, or share it read-only.
|
// The Finish stage: everything for when a study is ready to use: files, sharing, handouts, slides, importing notes, schedule, co-teachers and earlier versions.
|
||||||
import { useApp } from '../context/AppContext.js';
|
import { useApp } from '../context/AppContext.js';
|
||||||
import { useShareLink } from '../hooks/useShareLink.js';
|
import { useShareLink } from '../hooks/useShareLink.js';
|
||||||
|
import SlidesExport from '../components/SlidesExport.jsx';
|
||||||
|
import ImportNotes from '../components/ImportNotes.jsx';
|
||||||
import HandoutExport from '../components/HandoutExport.jsx';
|
import HandoutExport from '../components/HandoutExport.jsx';
|
||||||
import SessionSchedule from '../components/SessionSchedule.jsx';
|
import SessionSchedule from '../components/SessionSchedule.jsx';
|
||||||
import VersionHistory from '../components/VersionHistory.jsx';
|
import VersionHistory from '../components/VersionHistory.jsx';
|
||||||
@@ -30,7 +32,7 @@ export default function ExportPage() {
|
|||||||
<StudioShell stage="export">
|
<StudioShell stage="export">
|
||||||
<main className="mx-auto max-w-2xl space-y-5 px-4 py-8">
|
<main className="mx-auto max-w-2xl space-y-5 px-4 py-8">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-medium">Export</h1>
|
<h1 className="text-xl font-medium">Finish</h1>
|
||||||
<p className="mt-1 text-sm text-[var(--r-sub)]">{allChunks.length} chunk{allChunks.length === 1 ? '' : 's'} · {filled} script{filled === 1 ? '' : 's'} written</p>
|
<p className="mt-1 text-sm text-[var(--r-sub)]">{allChunks.length} chunk{allChunks.length === 1 ? '' : 's'} · {filled} script{filled === 1 ? '' : 's'} written</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -52,7 +54,7 @@ export default function ExportPage() {
|
|||||||
className="min-w-0 flex-1 truncate rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-xs text-[var(--r-sub)]" />
|
className="min-w-0 flex-1 truncate rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-xs text-[var(--r-sub)]" />
|
||||||
<button type="button" onClick={share.copy} className="shrink-0 rounded-lg bg-[var(--r-accent)] px-4 py-2 text-xs font-medium text-[var(--r-on)]">{share.shareCopied ? 'Copied!' : 'Copy'}</button>
|
<button type="button" onClick={share.copy} className="shrink-0 rounded-lg bg-[var(--r-accent)] px-4 py-2 text-xs font-medium text-[var(--r-on)]">{share.shareCopied ? 'Copied!' : 'Copy'}</button>
|
||||||
</div>
|
</div>
|
||||||
<LinkRules expiresAt={share.expiresAt} hasPasscode={share.hasPasscode} busy={share.shareBusy} onSave={share.enable} />
|
<LinkRules expiresAt={share.expiresAt} hasPasscode={share.hasPasscode} busy={share.shareBusy} onSave={share.enable} showReveal revealAt={share.revealAt} />
|
||||||
{share.shareError && <p role="alert" className="text-xs text-[var(--r-danger,#b42318)]">{share.shareError}</p>}
|
{share.shareError && <p role="alert" className="text-xs text-[var(--r-danger,#b42318)]">{share.shareError}</p>}
|
||||||
<button type="button" onClick={share.revoke} disabled={share.shareBusy} className="text-xs text-[var(--r-danger,#b42318)] hover:underline">{share.shareBusy ? 'Revoking…' : 'Revoke link'}</button>
|
<button type="button" onClick={share.revoke} disabled={share.shareBusy} className="text-xs text-[var(--r-danger,#b42318)] hover:underline">{share.shareBusy ? 'Revoking…' : 'Revoke link'}</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -63,6 +65,10 @@ export default function ExportPage() {
|
|||||||
|
|
||||||
<HandoutExport project={project} />
|
<HandoutExport project={project} />
|
||||||
|
|
||||||
|
<SlidesExport project={project} />
|
||||||
|
|
||||||
|
<ImportNotes />
|
||||||
|
|
||||||
<SessionSchedule project={project} />
|
<SessionSchedule project={project} />
|
||||||
|
|
||||||
<CoTeachers projectId={project?.id} sharedBy={project && sharedStudies?.[project.id]} onLeave={() => { deleteProject(project.id); setCurrentPage('home'); }} />
|
<CoTeachers projectId={project?.id} sharedBy={project && sharedStudies?.[project.id]} onLeave={() => { deleteProject(project.id); setCurrentPage('home'); }} />
|
||||||
|
|||||||
+22
-1
@@ -1,8 +1,11 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useApp } from '../context/AppContext.js';
|
import { useApp } from '../context/AppContext.js';
|
||||||
import { filterStudies, groupBySeries, isTagQuery, seriesList, tagCounts } from '../utils/studyFilter.js';
|
import { filterStudies, groupBySeries, isTagQuery, seriesList, tagCounts } from '../utils/studyFilter.js';
|
||||||
import BookCover from '../components/BookCover.jsx';
|
import BookCover from '../components/BookCover.jsx';
|
||||||
|
import DailyFive from '../components/DailyFive.jsx';
|
||||||
import WeeklyRecall from '../components/WeeklyRecall.jsx';
|
import WeeklyRecall from '../components/WeeklyRecall.jsx';
|
||||||
|
import OfflineBadge from '../components/OfflineBadge.jsx';
|
||||||
|
import { useOfflineSave } from '../hooks/useOfflineSave.js';
|
||||||
import RecallHistory from '../components/RecallHistory.jsx';
|
import RecallHistory from '../components/RecallHistory.jsx';
|
||||||
import IqDailyCard from '../components/iq/IqDailyCard.jsx';
|
import IqDailyCard from '../components/iq/IqDailyCard.jsx';
|
||||||
import IqNarratorOptions, { useLazyNarrators } from '../components/iq/IqNarratorOptions.jsx';
|
import IqNarratorOptions, { useLazyNarrators } from '../components/iq/IqNarratorOptions.jsx';
|
||||||
@@ -14,6 +17,7 @@ import {
|
|||||||
formatRelativeDate,
|
formatRelativeDate,
|
||||||
loadProjectIndex,
|
loadProjectIndex,
|
||||||
} from '../utils/bibleUtils.jsx';
|
} from '../utils/bibleUtils.jsx';
|
||||||
|
import { backupAge, lastBackupAt } from '../utils/backup.js';
|
||||||
import { loadProjectById } from '../utils/projectStorage.js';
|
import { loadProjectById } from '../utils/projectStorage.js';
|
||||||
import { compactPassages } from '../utils/passageSummary.js';
|
import { compactPassages } from '../utils/passageSummary.js';
|
||||||
import { getPlan, getPlanSchedule, formatDayReadings, nextUnreadDayIndex, computeStreak } from '../utils/readingPlans.js';
|
import { getPlan, getPlanSchedule, formatDayReadings, nextUnreadDayIndex, computeStreak } from '../utils/readingPlans.js';
|
||||||
@@ -38,6 +42,8 @@ export default function HomePage() {
|
|||||||
audioState,
|
audioState,
|
||||||
audioProgress,
|
audioProgress,
|
||||||
openImportProject,
|
openImportProject,
|
||||||
|
setCurrentPage,
|
||||||
|
openBackup,
|
||||||
openNewProject,
|
openNewProject,
|
||||||
pullLatestFromServer,
|
pullLatestFromServer,
|
||||||
resumeProject,
|
resumeProject,
|
||||||
@@ -52,6 +58,9 @@ export default function HomePage() {
|
|||||||
handleSeekAudioTo,
|
handleSeekAudioTo,
|
||||||
} = useApp();
|
} = useApp();
|
||||||
const narrators = useLazyNarrators(audioNarrator);
|
const narrators = useLazyNarrators(audioNarrator);
|
||||||
|
const offline = useOfflineSave();
|
||||||
|
// Each study's books and translation, read once per list change (the badge itself follows what's saved).
|
||||||
|
const studyBooks = useMemo(() => Object.fromEntries(projectIndex.map((e) => [e.id, loadProjectById(e.id)])), [projectIndex]);
|
||||||
const [homeSearch, setHomeSearch] = useState('');
|
const [homeSearch, setHomeSearch] = useState('');
|
||||||
const [homeSort, setHomeSort] = useState(() => {
|
const [homeSort, setHomeSort] = useState(() => {
|
||||||
try { return localStorage.getItem('homeSort') || 'recent'; } catch { return 'recent'; }
|
try { return localStorage.getItem('homeSort') || 'recent'; } catch { return 'recent'; }
|
||||||
@@ -130,6 +139,7 @@ export default function HomePage() {
|
|||||||
active="studies"
|
active="studies"
|
||||||
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={openImportProject} className={linkBtn} title="Set up a whole series from a session list (.docx)">Import Session List</button>
|
<button type="button" onClick={openImportProject} className={linkBtn} title="Set up a whole series from a session list (.docx)">Import Session List</button>
|
||||||
<button type="button" onClick={openNewProject} className="rounded-full bg-[var(--r-accent)] px-4 py-1.5 text-sm font-medium text-[var(--r-on)] hover:opacity-90">+ New Project</button>
|
<button type="button" onClick={openNewProject} className="rounded-full bg-[var(--r-accent)] px-4 py-1.5 text-sm font-medium text-[var(--r-on)] hover:opacity-90">+ New Project</button>
|
||||||
</>
|
</>
|
||||||
@@ -137,6 +147,16 @@ 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-6 px-4 py-6 lg:grid-cols-[minmax(0,1fr)_21rem]">
|
||||||
<section className="min-w-0">
|
<section className="min-w-0">
|
||||||
|
{projectIndex.length > 0 && (() => {
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
<DailyFive />
|
||||||
<WeeklyRecall />
|
<WeeklyRecall />
|
||||||
<RecallHistory />
|
<RecallHistory />
|
||||||
<IqDailyCard />
|
<IqDailyCard />
|
||||||
@@ -307,6 +327,7 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<p className="mt-1 text-xs text-[var(--r-sub)]">{formatRelativeDate(entry.lastEdited)}</p>
|
<p className="mt-1 text-xs text-[var(--r-sub)]">{formatRelativeDate(entry.lastEdited)}</p>
|
||||||
|
<OfflineBadge project={studyBooks[entry.id]} offline={offline} />
|
||||||
</div>
|
</div>
|
||||||
<div className={`flex items-center gap-2 ${homeView === 'list' ? 'sm:w-56 sm:shrink-0' : ''}`}>
|
<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={() => 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>
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
// Insights: your studies seen together — this week's own work, themes (tags), words you keep studying,
|
||||||
|
// what slipped in recalls, and follow-ups still open.
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useApp } from '../context/AppContext.js';
|
||||||
|
import PageShell from '../components/study/PageShell.jsx';
|
||||||
|
import { loadProjectById } from '../utils/projectStorage.js';
|
||||||
|
import { followUpList, slippedList, themeIndex, weeklySummary, wordIndex } from '../utils/insights.js';
|
||||||
|
|
||||||
|
const card = 'rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5';
|
||||||
|
const open = 'text-xs text-[var(--r-accent)] hover:underline';
|
||||||
|
|
||||||
|
function Stat({ label, value }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-4 py-3">
|
||||||
|
<p className="text-2xl font-medium tabular-nums">{value}</p>
|
||||||
|
<p className="text-xs text-[var(--r-sub)]">{label}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InsightsPage() {
|
||||||
|
const { projectIndex, openStudyChunk, goHome } = useApp();
|
||||||
|
const data = useMemo(() => {
|
||||||
|
const projects = projectIndex.map((e) => loadProjectById(e.id)).filter(Boolean);
|
||||||
|
return { week: weeklySummary(projects), themes: themeIndex(projects), words: wordIndex(projects), slipped: slippedList(projects), followUps: followUpList(projects) };
|
||||||
|
}, [projectIndex]);
|
||||||
|
const { week, themes, words, slipped, followUps } = data;
|
||||||
|
const link = (item) => <button type="button" onClick={() => openStudyChunk(item.projectId, item.chunkId)} className={open}>{item.reference} · {item.projectTitle}</button>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell title="Insights" active="studies" onBack={goHome} maxWidth="max-w-4xl">
|
||||||
|
<main className="mx-auto max-w-4xl space-y-5 px-4 pb-10">
|
||||||
|
<section aria-label="This week" className={card}>
|
||||||
|
<h2 className="text-sm font-medium">This week</h2>
|
||||||
|
<p className="mb-3 mt-0.5 text-xs text-[var(--r-sub)]">Counted over the chunks you edited in the last 7 days. Pasted text isn’t counted as yours.</p>
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
|
<Stat label="Chunks worked on" value={week.chunksTouched} />
|
||||||
|
<Stat label="Words of your own" value={week.ownWords} />
|
||||||
|
<Stat label="Your own words" value={week.ownShare === null ? '–' : `${week.ownShare}%`} />
|
||||||
|
<Stat label="Recalls done" value={week.recalls} />
|
||||||
|
</div>
|
||||||
|
{(week.teachBacks > 0 || week.fiveMinutes > 0) && <p className="mt-2 text-xs text-[var(--r-sub)]">{[week.fiveMinutes > 0 && `${week.fiveMinutes} five-minute session${week.fiveMinutes === 1 ? '' : 's'}`, week.teachBacks > 0 && `${week.teachBacks} teach-it-back summar${week.teachBacks === 1 ? 'y' : 'ies'}`].filter(Boolean).join(' · ')} this week.</p>}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section aria-label="Open follow-ups" className={card}>
|
||||||
|
<h2 className="text-sm font-medium">Follow-ups</h2>
|
||||||
|
{followUps.length === 0 ? <p className="mt-1 text-xs text-[var(--r-sub)]">None yet. Add them under “More” in the Study stage.</p> : (
|
||||||
|
<ul className="mt-2 space-y-1.5 text-sm">
|
||||||
|
{followUps.map((f) => (
|
||||||
|
<li key={f.id} className="flex flex-wrap items-baseline gap-x-3">
|
||||||
|
<span className={f.done ? 'text-[var(--r-sub)] line-through' : ''}>{f.text}</span>
|
||||||
|
{link(f)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section aria-label="What slipped" className={card}>
|
||||||
|
<h2 className="text-sm font-medium">What slipped in recalls</h2>
|
||||||
|
{slipped.length === 0 ? <p className="mt-1 text-xs text-[var(--r-sub)]">Nothing yet. After a weekly recall, note what you missed.</p> : (
|
||||||
|
<ul className="mt-2 space-y-3 text-sm">
|
||||||
|
{slipped.map((r) => (
|
||||||
|
<li key={`${r.chunkId}-${r.at}`}>
|
||||||
|
<p className="text-xs text-[var(--r-sub)]">{new Date(r.at).toLocaleDateString()} · {link(r)}</p>
|
||||||
|
<ul className="ml-4 list-disc">{r.items.map((i) => <li key={i}>{i}</li>)}</ul>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section aria-label="Themes" className={card}>
|
||||||
|
<h2 className="text-sm font-medium">Themes</h2>
|
||||||
|
{themes.length === 0 ? <p className="mt-1 text-xs text-[var(--r-sub)]">Tag a chunk (under “More”) and it appears here with every other chunk on the same theme.</p> : (
|
||||||
|
<div className="mt-2 space-y-3">
|
||||||
|
{themes.map((t) => (
|
||||||
|
<details key={t.tag} className="rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2">
|
||||||
|
<summary className="cursor-pointer text-sm"><span className="font-medium">{t.tag}</span> <span className="text-xs text-[var(--r-sub)]">· {t.count} chunk{t.count === 1 ? '' : 's'}</span></summary>
|
||||||
|
<ul className="mt-2 space-y-2">
|
||||||
|
{t.items.map((i) => (
|
||||||
|
<li key={`${i.chunkId}`} className="text-sm">{link(i)}{i.snippet && <p className="font-serif text-[13px] text-[var(--r-sub)]">{i.snippet}</p>}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section aria-label="Words you keep studying" className={card}>
|
||||||
|
<h2 className="text-sm font-medium">Words you keep studying</h2>
|
||||||
|
{words.length === 0 ? <p className="mt-1 text-xs text-[var(--r-sub)]">Words you study in two or more places show up here.</p> : (
|
||||||
|
<ul className="mt-2 space-y-2 text-sm">
|
||||||
|
{words.map((w) => (
|
||||||
|
<li key={w.key}>
|
||||||
|
<span className="font-serif text-base">{w.lexeme}</span> <span className="text-xs text-[var(--r-sub)]">{[w.transliteration, w.strong, w.gloss].filter(Boolean).join(' · ')} · {w.count} places</span>
|
||||||
|
<span className="mt-0.5 flex flex-wrap gap-x-3">{w.places.map((p) => <span key={p.chunkId}>{link(p)}</span>)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</PageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,12 +11,13 @@ import { buildChapterSummary } from '../utils/projectStorage.js';
|
|||||||
import { compactPassages } from '../utils/passageSummary.js';
|
import { compactPassages } from '../utils/passageSummary.js';
|
||||||
import { attachableFromChunk, fetchPassageText } from '../utils/passageText.js';
|
import { attachableFromChunk, fetchPassageText } from '../utils/passageText.js';
|
||||||
import PresentMode from '../components/PresentMode.jsx';
|
import PresentMode from '../components/PresentMode.jsx';
|
||||||
|
import { firstOverPoint, outlineSteps, planStatus } from '../utils/present.js';
|
||||||
import IqOutlineAttach from '../components/iq/IqOutlineAttach.jsx';
|
import IqOutlineAttach from '../components/iq/IqOutlineAttach.jsx';
|
||||||
import ShareOutlineSheet from '../components/ShareOutlineSheet.jsx';
|
import ShareOutlineSheet from '../components/ShareOutlineSheet.jsx';
|
||||||
import { downloadOutlineDocx, downloadOutlineMarkdown, printOutline } from '../utils/outlineExport.js';
|
import { downloadOutlineDocx, downloadOutlineMarkdown, printOutline } from '../utils/outlineExport.js';
|
||||||
import {
|
import {
|
||||||
KINDS, STUDY, buildChunkFromNotes, buildFromNotes, notesBuildCount, addAfter, addChild, addRoot, chunkOf, countNodes, flatten, getNode, indent, insertAt, kindLabel, locate,
|
KINDS, STUDY, buildChunkFromNotes, buildFromNotes, notesBuildCount, addAfter, addChild, addRoot, chunkOf, countNodes, flatten, getNode, indent, insertAt, kindLabel, locate,
|
||||||
moveNode, newNode, outdent, outlineFor, outlineToText, removeNode, seedFromChunks, seedFromVerses, timeSummary, updateNode, withOutline,
|
moveNode, newNode, outdent, outlineFor, outlineToText, removeNode, seedFromChunks, seedFromVerses, timeSummary, totalMinutes, updateNode, withOutline,
|
||||||
} from '../utils/outline.js';
|
} from '../utils/outline.js';
|
||||||
|
|
||||||
const iconBtn = 'flex h-7 w-7 items-center justify-center rounded-md text-sm text-[var(--r-sub)] hover:bg-black/5 hover:text-[var(--r-text)] disabled:opacity-30 disabled:hover:bg-transparent';
|
const iconBtn = 'flex h-7 w-7 items-center justify-center rounded-md text-sm text-[var(--r-sub)] hover:bg-black/5 hover:text-[var(--r-text)] disabled:opacity-30 disabled:hover:bg-transparent';
|
||||||
@@ -45,6 +46,11 @@ export default function OutlinePage() {
|
|||||||
const scopeChunk = scope === STUDY ? null : chunkOf(project, scope);
|
const scopeChunk = scope === STUDY ? null : chunkOf(project, scope);
|
||||||
const tree = outlineFor(project, scope);
|
const tree = outlineFor(project, scope);
|
||||||
const rows = flatten(tree);
|
const rows = flatten(tree);
|
||||||
|
// The time you have for a session, and how the planned minutes measure up to it
|
||||||
|
const sessionMinutes = Number(project?.sessionMinutes) || 0;
|
||||||
|
const plan = planStatus(totalMinutes(tree), sessionMinutes);
|
||||||
|
const overPoint = firstOverPoint(outlineSteps(tree), sessionMinutes);
|
||||||
|
const setSessionMinutes = (value) => updateProject((current) => ({ ...current, sessionMinutes: value === '' ? null : Math.max(0, Math.min(600, Math.round(Number(value)))) }));
|
||||||
const [pendingFocus, setPendingFocus] = useState(null);
|
const [pendingFocus, setPendingFocus] = useState(null);
|
||||||
const [metaOpen, setMetaOpen] = useState(() => new Set()); // points whose detail fields are showing
|
const [metaOpen, setMetaOpen] = useState(() => new Set()); // points whose detail fields are showing
|
||||||
const [shareOpen, setShareOpen] = useState(false);
|
const [shareOpen, setShareOpen] = useState(false);
|
||||||
@@ -160,6 +166,11 @@ export default function OutlinePage() {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
{timeSummary(tree) && <span className="text-sm text-[var(--r-sub)]">{timeSummary(tree)}</span>}
|
{timeSummary(tree) && <span className="text-sm text-[var(--r-sub)]">{timeSummary(tree)}</span>}
|
||||||
|
<label className="flex items-center gap-1.5 text-sm text-[var(--r-sub)]">Session length
|
||||||
|
<input type="number" min="0" max="600" inputMode="numeric" aria-label="Session length in minutes" value={project?.sessionMinutes ?? ''} onChange={(e) => setSessionMinutes(e.target.value)}
|
||||||
|
className="w-16 rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2 py-1 text-sm text-[var(--r-text)] focus:outline-none" /> min
|
||||||
|
</label>
|
||||||
|
{plan.state !== 'unset' && <span role="status" className={`text-sm ${plan.state === 'over' ? 'font-medium text-[var(--r-danger,#b42318)]' : 'text-[var(--r-sub)]'}`}>{plan.text}</span>}
|
||||||
{scopeChunk && <button type="button" onClick={() => openChunk(scope)} className="text-sm text-[var(--r-accent)] hover:underline">Open this chunk in Study</button>}
|
{scopeChunk && <button type="button" onClick={() => openChunk(scope)} className="text-sm text-[var(--r-accent)] hover:underline">Open this chunk in Study</button>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -229,10 +240,11 @@ export default function OutlinePage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* What the point carries, at a glance */}
|
{/* What the point carries, at a glance */}
|
||||||
{(node.kind || mins || node.ref || linked) && (
|
{(node.kind || mins || node.ref || linked || overPoint?.id === node.id) && (
|
||||||
<p className="flex flex-wrap items-center gap-x-2 gap-y-1 px-2 text-xs text-[var(--r-sub)]">
|
<p className="flex flex-wrap items-center gap-x-2 gap-y-1 px-2 text-xs text-[var(--r-sub)]">
|
||||||
{node.kind && <span className={`${chip} border-[var(--r-accent)] text-[var(--r-accent)]`}>{kindLabel(node.kind)}</span>}
|
{node.kind && <span className={`${chip} border-[var(--r-accent)] text-[var(--r-accent)]`}>{kindLabel(node.kind)}</span>}
|
||||||
{mins && <span>{node.minutes} min</span>}
|
{mins && <span>{node.minutes} min</span>}
|
||||||
|
{overPoint?.id === node.id && <span className="font-medium text-[var(--r-danger,#b42318)]">Runs past {sessionMinutes} min here</span>}
|
||||||
{node.ref && <span>{node.ref}</span>}
|
{node.ref && <span>{node.ref}</span>}
|
||||||
{linked && node.chunkId !== scope && <button type="button" onClick={() => openChunk(node.chunkId)} className="text-[var(--r-accent)] hover:underline">Open chunk in Study</button>}
|
{linked && node.chunkId !== scope && <button type="button" onClick={() => openChunk(node.chunkId)} className="text-[var(--r-accent)] hover:underline">Open chunk in Study</button>}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Every Greek and Hebrew word in the study with its transliteration, so a
|
// Every Greek and Hebrew word in the study with its transliteration, so a
|
||||||
// speaker can rehearse them. Reached from the Script and Export stages.
|
// speaker can rehearse them. Reached from the Script and Finish stages.
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useApp } from '../context/AppContext.js';
|
import { useApp } from '../context/AppContext.js';
|
||||||
import StudioShell from '../components/study/StudioShell.jsx';
|
import StudioShell from '../components/study/StudioShell.jsx';
|
||||||
|
|||||||
+60
-13
@@ -13,10 +13,15 @@ import OutlineRail from '../components/study/OutlineRail.jsx';
|
|||||||
import ScripturePane from '../components/study/ScripturePane.jsx';
|
import ScripturePane from '../components/study/ScripturePane.jsx';
|
||||||
import NotesPane from '../components/study/NotesPane.jsx';
|
import NotesPane from '../components/study/NotesPane.jsx';
|
||||||
import ResourcePane, { useResourceTab } from '../components/study/ResourcePane.jsx';
|
import ResourcePane, { useResourceTab } from '../components/study/ResourcePane.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, r open the resource tabs; v compares.
|
||||||
|
const NOTE_KEYS = { 1: 'observation', 2: 'interpretation', 3: 'application' };
|
||||||
|
const TAB_KEYS = { c: 'commentary', w: 'words', x: 'refs', p: 'people', r: 'readings' };
|
||||||
|
|
||||||
export default function StudyPage() {
|
export default function StudyPage() {
|
||||||
const { project, allChunks, projectIndex, updateProject, updateChunk, setCurrentPage, readerCommentary, setReaderCommentary, saveUserTemplate } = useApp();
|
const { project, allChunks, projectIndex, updateProject, updateChunk, setCurrentPage, readerCommentary, setReaderCommentary, saveUserTemplate, startPrayerFor } = useApp();
|
||||||
|
|
||||||
// Short-lived status line (word suggestions, lookups, …)
|
// Short-lived status line (word suggestions, lookups, …)
|
||||||
const [statusMessage, setStatusMessage] = useState('');
|
const [statusMessage, setStatusMessage] = useState('');
|
||||||
@@ -41,6 +46,26 @@ export default function StudyPage() {
|
|||||||
|
|
||||||
const [tab, setTab] = useResourceTab();
|
const [tab, setTab] = useResourceTab();
|
||||||
const [openWordId, setOpenWordId] = useState(null);
|
const [openWordId, setOpenWordId] = useState(null);
|
||||||
|
const [comparing, setComparing] = useState(false);
|
||||||
|
// Either half of the right column can be folded away so the other fills it (remembered on this device).
|
||||||
|
const [folded, setFolded] = useState(() => {
|
||||||
|
try { const f = JSON.parse(localStorage.getItem('study-folded')); return { notes: !!f?.notes, resources: !!f?.resources && !f?.notes }; } catch { return { notes: false, resources: false }; }
|
||||||
|
});
|
||||||
|
const [focusRequest, setFocusRequest] = useState(null);
|
||||||
|
const showHalf = (which) => setFolded((cur) => {
|
||||||
|
if (!cur[which]) return cur;
|
||||||
|
const next = { ...cur, [which]: false };
|
||||||
|
try { localStorage.setItem('study-folded', JSON.stringify(next)); } catch { /* storage unavailable */ }
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
const toggleFold = (which) => setFolded((cur) => {
|
||||||
|
const other = which === 'notes' ? 'resources' : 'notes';
|
||||||
|
// folding the last open half opens the other one instead, so the column is never empty
|
||||||
|
const next = { ...cur, [which]: !cur[which] };
|
||||||
|
if (next[which] && next[other]) next[other] = false;
|
||||||
|
try { localStorage.setItem('study-folded', JSON.stringify(next)); } catch { /* storage unavailable */ }
|
||||||
|
return next;
|
||||||
|
});
|
||||||
useEffect(() => { setOpenWordId(null); }, [selectedChunk?.id]);
|
useEffect(() => { setOpenWordId(null); }, [selectedChunk?.id]);
|
||||||
|
|
||||||
const resources = useStudyResources({
|
const resources = useStudyResources({
|
||||||
@@ -62,11 +87,15 @@ export default function StudyPage() {
|
|||||||
const editable = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || event.target?.isContentEditable;
|
const editable = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || event.target?.isContentEditable;
|
||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape') {
|
||||||
if (wordStudy.suggestModal) { wordStudy.setSuggestModal(null); wordStudy.setSuggestSelection(new Set()); }
|
if (wordStudy.suggestModal) { wordStudy.setSuggestModal(null); wordStudy.setSuggestSelection(new Set()); }
|
||||||
|
else if (editable && !comparing) event.target.blur(); // leave a note box and go back to keyboard moves
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (editable || wordStudy.suggestModal) return;
|
if (editable || wordStudy.suggestModal || comparing || event.metaKey || event.ctrlKey || event.altKey) return;
|
||||||
if (event.key === 'ArrowLeft') { event.preventDefault(); goPrev(); }
|
if (event.key === 'ArrowLeft') { event.preventDefault(); goPrev(); }
|
||||||
else if (event.key === 'ArrowRight') { event.preventDefault(); goNext(); }
|
else if (event.key === 'ArrowRight') { event.preventDefault(); goNext(); }
|
||||||
|
else if (NOTE_KEYS[event.key]) { event.preventDefault(); showHalf('notes'); setFocusRequest({ field: NOTE_KEYS[event.key], n: Date.now() }); }
|
||||||
|
else if (TAB_KEYS[event.key.toLowerCase()]) { event.preventDefault(); showHalf('resources'); setTab(TAB_KEYS[event.key.toLowerCase()]); }
|
||||||
|
else if (event.key.toLowerCase() === 'v') { event.preventDefault(); setComparing(true); }
|
||||||
};
|
};
|
||||||
window.addEventListener('keydown', onKey);
|
window.addEventListener('keydown', onKey);
|
||||||
return () => window.removeEventListener('keydown', onKey);
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
@@ -103,6 +132,7 @@ export default function StudyPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 gap-1.5">
|
<div className="flex shrink-0 gap-1.5">
|
||||||
|
<button type="button" onClick={() => setComparing(true)} className="rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-xs hover:bg-black/5" title="Set another passage beside this one">Compare…</button>
|
||||||
<button type="button" onClick={goPrev} disabled={globalIndex <= 0} aria-label="Previous chunk"
|
<button type="button" onClick={goPrev} disabled={globalIndex <= 0} aria-label="Previous chunk"
|
||||||
className="rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-sm hover:bg-black/5 disabled:opacity-40">‹</button>
|
className="rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-sm hover:bg-black/5 disabled:opacity-40">‹</button>
|
||||||
<button type="button" onClick={goNext} disabled={globalIndex >= allChunks.length - 1} aria-label="Next chunk"
|
<button type="button" onClick={goNext} disabled={globalIndex >= allChunks.length - 1} aria-label="Next chunk"
|
||||||
@@ -114,6 +144,8 @@ export default function StudyPage() {
|
|||||||
verses={verses} chapter={chapter}
|
verses={verses} chapter={chapter}
|
||||||
interlinear={resources.interlinearData} interlinearLoading={resources.interlinearLoading}
|
interlinear={resources.interlinearData} interlinearLoading={resources.interlinearLoading}
|
||||||
addedStrongs={addedStrongs} onPickWord={pickWord}
|
addedStrongs={addedStrongs} onPickWord={pickWord}
|
||||||
|
verseNotes={selectedChunk.verseNotes ?? {}}
|
||||||
|
onVerseNote={(key, text) => updateChunk(selectedChunk.id, { verseNotes: { ...(selectedChunk.verseNotes ?? {}), [key]: text } })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -129,25 +161,40 @@ export default function StudyPage() {
|
|||||||
</div>
|
</div>
|
||||||
{selectedChunk && chapter && (
|
{selectedChunk && chapter && (
|
||||||
<>
|
<>
|
||||||
<section aria-label="Notes" className="flex min-h-[22rem] flex-col overflow-hidden rounded-2xl border border-[var(--r-line)] bg-[var(--r-bg)] lg:min-h-0 lg:flex-[11]">
|
<section aria-label="Notes" className={`flex flex-col overflow-hidden rounded-2xl border border-[var(--r-line)] bg-[var(--r-bg)] ${folded.notes ? 'shrink-0' : 'min-h-[22rem] lg:min-h-0 lg:flex-[11]'}`}>
|
||||||
<h2 className="shrink-0 border-b border-[var(--r-line)] px-4 py-2.5 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Your notes</h2>
|
<h2 className="shrink-0">
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
<button type="button" onClick={() => toggleFold('notes')} aria-expanded={!folded.notes}
|
||||||
<NotesPane chunk={selectedChunk} updateChunk={updateChunk} allTags={allTags} templateId={project?.templateDef ?? project?.templateId} saveTemplate={saveUserTemplate} notify={notify} />
|
className="flex w-full items-center justify-between px-4 py-2.5 text-left text-[11px] uppercase tracking-wide text-[var(--r-sub)] hover:bg-black/5">
|
||||||
|
Your notes<span aria-hidden="true">{folded.notes ? '▸' : '▾'}</span>
|
||||||
|
</button>
|
||||||
|
</h2>
|
||||||
|
<div className={`min-h-0 flex-1 overflow-y-auto border-t border-[var(--r-line)] p-3 ${folded.notes ? 'hidden' : ''}`}>
|
||||||
|
<NotesPane chunk={selectedChunk} updateChunk={updateChunk} allTags={allTags} templateId={project?.templateDef ?? project?.templateId} saveTemplate={saveUserTemplate} notify={notify} focusRequest={focusRequest}
|
||||||
|
onPray={() => startPrayerFor(formatChunkReference(project, chapterIndex, selectedChunk, '–'))} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section aria-label="Resources" className="flex min-h-[24rem] flex-col overflow-hidden rounded-2xl border border-[var(--r-line)] bg-[var(--r-bg)] lg:min-h-0 lg:flex-[10]">
|
<section aria-label="Resources" className={`flex flex-col overflow-hidden rounded-2xl border border-[var(--r-line)] bg-[var(--r-bg)] ${folded.resources ? 'shrink-0' : 'min-h-[24rem] lg:min-h-0 lg:flex-[10]'}`}>
|
||||||
<ResourcePane
|
<h2 className="shrink-0">
|
||||||
tab={tab} setTab={setTab} chunk={selectedChunk} chapter={chapter} chapterIndex={chapterIndex} project={project}
|
<button type="button" onClick={() => toggleFold('resources')} aria-expanded={!folded.resources}
|
||||||
updateChunk={updateChunk} notify={notify}
|
className="flex w-full items-center justify-between px-4 py-2.5 text-left text-[11px] uppercase tracking-wide text-[var(--r-sub)] hover:bg-black/5">
|
||||||
resources={resources} wordStudy={wordStudy} crossRefs={crossRefs}
|
Commentary & resources<span aria-hidden="true">{folded.resources ? '▸' : '▾'}</span>
|
||||||
openWordId={openWordId} setOpenWordId={setOpenWordId}
|
</button>
|
||||||
/>
|
</h2>
|
||||||
|
<div className={`flex min-h-0 flex-1 flex-col border-t border-[var(--r-line)] ${folded.resources ? 'hidden' : ''}`}>
|
||||||
|
<ResourcePane
|
||||||
|
tab={tab} setTab={setTab} chunk={selectedChunk} chapter={chapter} chapterIndex={chapterIndex} project={project}
|
||||||
|
updateChunk={updateChunk} updateProject={updateProject} notify={notify}
|
||||||
|
resources={resources} wordStudy={wordStudy} crossRefs={crossRefs}
|
||||||
|
openWordId={openWordId} setOpenWordId={setOpenWordId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{comparing && selectedChunk && <CompareSheet currentChunkId={selectedChunk.id} onClose={() => setComparing(false)} />}
|
||||||
<SuggestWordsSheet wordStudy={wordStudy} />
|
<SuggestWordsSheet wordStudy={wordStudy} />
|
||||||
{statusMessage && (
|
{statusMessage && (
|
||||||
<div role="status" className="fixed bottom-6 left-1/2 z-[60] -translate-x-1/2 rounded-full bg-[var(--r-text)] px-4 py-2 text-sm text-[var(--r-bg)] shadow-lg">
|
<div role="status" className="fixed bottom-6 left-1/2 z-[60] -translate-x-1/2 rounded-full bg-[var(--r-text)] px-4 py-2 text-sm text-[var(--r-bg)] shadow-lg">
|
||||||
|
|||||||
+102
-7
@@ -190,16 +190,16 @@ describe('Home page', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Export stage (useShareLink) and the shared read-only view
|
// Finish stage (useShareLink) and the shared read-only view
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
describe('Project header and sharing', () => {
|
describe('Project header and sharing', () => {
|
||||||
const json = (status, data) => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(data) });
|
const json = (status, data) => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(data) });
|
||||||
|
|
||||||
test('Export stage → Markdown downloads a file', async () => {
|
test('Finish stage → Markdown downloads a file', async () => {
|
||||||
const user = await loadChapter();
|
const user = await loadChapter();
|
||||||
addChunk('Paul, a servant', 'Paul, a servant');
|
addChunk('Paul, a servant', 'Paul, a servant');
|
||||||
await screen.findByText('1 created');
|
await screen.findByText('1 created');
|
||||||
await user.click(screen.getByRole('button', { name: /^export$/i }));
|
await user.click(screen.getByRole('button', { name: /^finish$/i }));
|
||||||
await user.click(await screen.findByRole('button', { name: /export markdown/i }));
|
await user.click(await screen.findByRole('button', { name: /export markdown/i }));
|
||||||
expect(URL.createObjectURL).toHaveBeenCalled();
|
expect(URL.createObjectURL).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -216,7 +216,7 @@ describe('Project header and sharing', () => {
|
|||||||
const user = await loadChapter();
|
const user = await loadChapter();
|
||||||
addChunk('Paul, a servant', 'Paul, a servant');
|
addChunk('Paul, a servant', 'Paul, a servant');
|
||||||
await screen.findByText('1 created');
|
await screen.findByText('1 created');
|
||||||
await user.click(screen.getByRole('button', { name: /^export$/i }));
|
await user.click(screen.getByRole('button', { name: /^finish$/i }));
|
||||||
await user.click(await screen.findByRole('button', { name: /enable sharing/i }));
|
await user.click(await screen.findByRole('button', { name: /enable sharing/i }));
|
||||||
expect(await screen.findByDisplayValue(/\?share=tok123$/)).toBeInTheDocument();
|
expect(await screen.findByDisplayValue(/\?share=tok123$/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -310,12 +310,12 @@ describe('Chunk builder', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Stage bar (Chunks / Study / Outline / Script / Export)
|
// Stage bar (Chunks / Study / Outline / Script / Finish)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
describe('Stage bar', () => {
|
describe('Stage bar', () => {
|
||||||
test('later stages are disabled until a chunk exists', async () => {
|
test('later stages are disabled until a chunk exists', async () => {
|
||||||
await loadChapter();
|
await loadChapter();
|
||||||
for (const name of [/^study$/i, /^outline$/i, /^script$/i, /^export$/i]) {
|
for (const name of [/^study$/i, /^outline$/i, /^script$/i, /^finish$/i]) {
|
||||||
expect(screen.getByRole('button', { name })).toBeDisabled();
|
expect(screen.getByRole('button', { name })).toBeDisabled();
|
||||||
}
|
}
|
||||||
expect(screen.getByRole('button', { name: /^chunks$/i })).not.toBeDisabled();
|
expect(screen.getByRole('button', { name: /^chunks$/i })).not.toBeDisabled();
|
||||||
@@ -419,12 +419,108 @@ describe('Study workspace', () => {
|
|||||||
|
|
||||||
test('commentary: "Add to notes" quotes a paragraph with its citation', async () => {
|
test('commentary: "Add to notes" quotes a paragraph with its citation', async () => {
|
||||||
const user = await studyOneChunk([['/api/c/matthew-henry/TIT/1.json', { chapter: { content: [{ type: 'verse', number: 1, content: ['Paul was an apostle.'] }] } }]]);
|
const user = await studyOneChunk([['/api/c/matthew-henry/TIT/1.json', { chapter: { content: [{ type: 'verse', number: 1, content: ['Paul was an apostle.'] }] } }]]);
|
||||||
|
expect(screen.getByText('Observe first')).toBeInTheDocument(); // commentary waits for your own notes
|
||||||
|
await user.click(screen.getByRole('button', { name: /show it anyway/i }));
|
||||||
await user.click(await screen.findByRole('button', { name: /add to notes/i }));
|
await user.click(await screen.findByRole('button', { name: /add to notes/i }));
|
||||||
expect(await screen.findByRole('status')).toHaveTextContent(/background notes/i);
|
expect(await screen.findByRole('status')).toHaveTextContent(/background notes/i);
|
||||||
await user.click(screen.getByRole('button', { name: /^more/i }));
|
await user.click(screen.getByRole('button', { name: /^more/i }));
|
||||||
expect(screen.getByLabelText(/background/i)).toHaveValue('Paul was an apostle. (Matthew Henry, Titus 1:1)');
|
expect(screen.getByLabelText(/background/i)).toHaveValue('Paul was an apostle. (Matthew Henry, Titus 1:1)');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('commentary opens once you have written enough of your own Observe notes', async () => {
|
||||||
|
await studyOneChunk([['/api/c/matthew-henry/TIT/1.json', { chapter: { content: [{ type: 'verse', number: 1, content: ['Paul was an apostle.'] }] } }]]);
|
||||||
|
expect(screen.getByText('Observe first')).toBeInTheDocument();
|
||||||
|
fireEvent.change(screen.getByRole('textbox', { name: 'Observe' }), { target: { value: Array.from({ length: 15 }, (_, i) => `word${i}`).join(' ') } });
|
||||||
|
expect(screen.queryByText('Observe first')).not.toBeInTheDocument();
|
||||||
|
expect(await screen.findByRole('button', { name: /add to notes/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('either half of the right column can be folded, and the other always stays open', async () => {
|
||||||
|
const user = await studyOneChunk();
|
||||||
|
const notes = screen.getByRole('button', { name: /your notes/i });
|
||||||
|
const resources = screen.getByRole('button', { name: /commentary & resources/i });
|
||||||
|
expect(notes).toHaveAttribute('aria-expanded', 'true');
|
||||||
|
await user.click(notes);
|
||||||
|
expect(notes).toHaveAttribute('aria-expanded', 'false');
|
||||||
|
expect(resources).toHaveAttribute('aria-expanded', 'true');
|
||||||
|
await user.click(resources); // folding the last open half opens the other one
|
||||||
|
expect(resources).toHaveAttribute('aria-expanded', 'false');
|
||||||
|
expect(notes).toHaveAttribute('aria-expanded', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a long paste asks if it is from AI; "I wrote it" takes the tag back', async () => {
|
||||||
|
const user = await studyOneChunk();
|
||||||
|
const observe = screen.getByRole('textbox', { name: 'Observe' });
|
||||||
|
const pasted = Array.from({ length: 20 }, (_, i) => `pasted${i}`).join(' ');
|
||||||
|
fireEvent.paste(observe, { clipboardData: { getData: () => pasted } });
|
||||||
|
fireEvent.change(observe, { target: { value: pasted } });
|
||||||
|
expect(await screen.findByText(/is this from ai\?/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/20 words here were pasted/i)).toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole('button', { name: /i wrote it/i }));
|
||||||
|
expect(screen.queryByText(/is this from ai\?/i)).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/were pasted/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a short paste is not asked about', async () => {
|
||||||
|
await studyOneChunk();
|
||||||
|
const observe = screen.getByRole('textbox', { name: 'Observe' });
|
||||||
|
fireEvent.paste(observe, { clipboardData: { getData: () => 'just a few words' } });
|
||||||
|
expect(screen.queryByText(/is this from ai\?/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a note keeps earlier versions that can be restored', async () => {
|
||||||
|
const user = await studyOneChunk();
|
||||||
|
const observe = screen.getByRole('textbox', { name: 'Observe' });
|
||||||
|
fireEvent.focus(observe);
|
||||||
|
fireEvent.change(observe, { target: { value: 'First thought about the passage.' } });
|
||||||
|
fireEvent.blur(observe);
|
||||||
|
fireEvent.focus(observe);
|
||||||
|
fireEvent.change(observe, { target: { value: 'Completely rewritten.' } });
|
||||||
|
fireEvent.blur(observe);
|
||||||
|
await user.click(screen.getByRole('button', { name: 'History' }));
|
||||||
|
const list = screen.getByRole('list', { name: /observe history/i });
|
||||||
|
expect(within(list).getByText(/first thought about the passage/i)).toBeInTheDocument();
|
||||||
|
await user.click(within(list).getAllByRole('button', { name: 'Restore' }).find((b) => !b.disabled));
|
||||||
|
expect(screen.getByRole('textbox', { name: 'Observe' })).toHaveValue('First thought about the passage.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keyboard: 1 / 2 / 3 jump into a note, Esc leaves it, and V opens Compare', async () => {
|
||||||
|
const user = await studyOneChunk();
|
||||||
|
await user.keyboard('2');
|
||||||
|
expect(screen.getByRole('textbox', { name: 'Interpret' })).toHaveFocus();
|
||||||
|
await user.keyboard('{Escape}');
|
||||||
|
expect(screen.getByRole('textbox', { name: 'Interpret' })).not.toHaveFocus();
|
||||||
|
await user.keyboard('3');
|
||||||
|
expect(screen.getByRole('textbox', { name: 'Apply' })).toHaveFocus();
|
||||||
|
await user.keyboard('{Escape}');
|
||||||
|
await user.keyboard('v');
|
||||||
|
expect(screen.getByRole('dialog', { name: /compare/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('compare translations: the Observe / Interpret / Apply boxes edit the same notes', async () => {
|
||||||
|
const user = await studyOneChunk();
|
||||||
|
await user.click(screen.getByRole('button', { name: /compare…/i }));
|
||||||
|
await user.click(screen.getByRole('tab', { name: /translations/i }));
|
||||||
|
const dialog = screen.getByRole('dialog', { name: /compare/i });
|
||||||
|
expect(within(dialog).getByRole('textbox', { name: 'Interpret' })).toBeInTheDocument();
|
||||||
|
expect(within(dialog).getByRole('textbox', { name: 'Apply' })).toBeInTheDocument();
|
||||||
|
fireEvent.change(within(dialog).getByRole('textbox', { name: 'Observe' }), { target: { value: 'Written beside the translations.' } });
|
||||||
|
await user.click(within(dialog).getByRole('button', { name: /close/i }));
|
||||||
|
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 }));
|
||||||
@@ -514,4 +610,3 @@ describe('Reading plan with more days than chapters', () => {
|
|||||||
expect(screen.queryByText(/back to today.s reading/i)).not.toBeInTheDocument();
|
expect(screen.queryByText(/back to today.s reading/i)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ describe('Back to the dashboard', () => {
|
|||||||
await dashboard();
|
await dashboard();
|
||||||
});
|
});
|
||||||
|
|
||||||
test.each(['Chunks', 'Study', 'Outline', 'Script', 'Export'])('the %s stage of a study', async (stage) => {
|
test.each(['Chunks', 'Study', 'Outline', 'Script', 'Finish'])('the %s stage of a study', async (stage) => {
|
||||||
const u = await loadChapter();
|
const u = await loadChapter();
|
||||||
addChunk('Paul, a servant', 'In hope of eternal');
|
addChunk('Paul, a servant', 'In hope of eternal');
|
||||||
await screen.findByText('1 created');
|
await screen.findByText('1 created');
|
||||||
|
|||||||
@@ -89,3 +89,14 @@ export function mergeReadingPlans(local, incoming) {
|
|||||||
activity: [...new Set([...(local.activity ?? []), ...(incoming.activity ?? [])])].sort(),
|
activity: [...new Set([...(local.activity ?? []), ...(incoming.activity ?? [])])].sort(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When you last downloaded a backup on this device, so Home can say so (and nudge when it has been a while).
|
||||||
|
const LAST_BACKUP_KEY = 'last-backup-at';
|
||||||
|
export const markBackedUp = (now = Date.now()) => { try { localStorage.setItem(LAST_BACKUP_KEY, String(now)); } catch { /* storage unavailable */ } };
|
||||||
|
export const lastBackupAt = () => { try { return Number(localStorage.getItem(LAST_BACKUP_KEY)) || null; } catch { return null; } };
|
||||||
|
export function backupAge(at, now = Date.now()) {
|
||||||
|
if (!at) return { text: 'You haven’t downloaded a backup yet', stale: true };
|
||||||
|
const days = Math.floor((now - at) / 86400000);
|
||||||
|
if (days < 1) return { text: 'Backed up today', stale: false };
|
||||||
|
return { text: `Last backup ${days} day${days === 1 ? '' : 's'} ago`, stale: days >= 14 };
|
||||||
|
}
|
||||||
@@ -68,3 +68,13 @@ test('prayers are in the backup, deleted ones are not, and they survive validati
|
|||||||
expect(checked.backup.prayers[0].text).toBe('Peace');
|
expect(checked.backup.prayers[0].text).toBe('Peace');
|
||||||
expect(validateBackup({ app: 'bible-study-project', version: 1, prayers: [{ id: 'x' }, 5, { id: 'y', text: ' ' }] }).backup.prayers).toEqual([]);
|
expect(validateBackup({ app: 'bible-study-project', version: 1, prayers: [{ id: 'x' }, 5, { id: 'y', text: ' ' }] }).backup.prayers).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
import { backupAge } from './backup.js';
|
||||||
|
|
||||||
|
test('the backup age says how long ago, and flags a long gap', () => {
|
||||||
|
const now = 1_000_000_000_000;
|
||||||
|
expect(backupAge(null, now)).toMatchObject({ stale: true });
|
||||||
|
expect(backupAge(now - 3600_000, now)).toEqual({ text: 'Backed up today', stale: false });
|
||||||
|
expect(backupAge(now - 5 * 86400000, now)).toEqual({ text: 'Last backup 5 days ago', stale: false });
|
||||||
|
expect(backupAge(now - 20 * 86400000, now).stale).toBe(true);
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// Comparing translations of one chunk: which chapter(s) and verses to fetch, and the verses lined up by number.
|
||||||
|
import { getChunkVerseEntries } from './bibleUtils.jsx';
|
||||||
|
import { diffVerses } from './textDiff.js';
|
||||||
|
|
||||||
|
// -> [{ bookAbbrev, book, chapter, from, to }]: one entry per chapter the chunk covers (two when it spills over).
|
||||||
|
export function translationSpans(project, chapterIndex, chunk) {
|
||||||
|
const spans = [];
|
||||||
|
for (const v of getChunkVerseEntries(project, chapterIndex, chunk)) {
|
||||||
|
const last = spans[spans.length - 1];
|
||||||
|
if (last && last.chapter === v.chapter && last.book === v.book) { last.to = Math.max(last.to, v.number); last.from = Math.min(last.from, v.number); continue; }
|
||||||
|
const chapter = project.chapters.find((c) => c.book === v.book && String(c.chapter) === String(v.chapter));
|
||||||
|
spans.push({ bookAbbrev: chapter?.bookAbbrev, book: v.book, chapter: v.chapter, from: v.number, to: v.number });
|
||||||
|
}
|
||||||
|
return spans;
|
||||||
|
}
|
||||||
|
|
||||||
|
// byTranslation: { BSB: { verses: [{ number, text }] }, … } for the chapters in `spans`; `loaded` is keyed by
|
||||||
|
// `${chapter}` inside each translation. -> [{ key, label, texts: { BSB: 'text' | null } }] one row per verse, in order.
|
||||||
|
export function alignVerses(spans, byTranslation) {
|
||||||
|
const rows = [];
|
||||||
|
for (const span of spans) {
|
||||||
|
for (let n = span.from; n <= span.to; n += 1) {
|
||||||
|
const texts = {};
|
||||||
|
for (const [id, chapters] of Object.entries(byTranslation)) {
|
||||||
|
texts[id] = chapters?.[`${span.book}|${span.chapter}`]?.find((v) => v.number === n)?.text ?? null;
|
||||||
|
}
|
||||||
|
rows.push({ key: `${span.book}${span.chapter}:${n}`, noteKey: `${span.chapter}:${n}`, label: spans.length > 1 ? `${span.chapter}:${n}` : String(n), texts });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For one verse in several translations: which words in each text the others lack, measured against the first
|
||||||
|
// translation (and the first against the second). -> { BSB: [{ start, end }], NET: [...] }
|
||||||
|
export function differencesFor(texts, ids) {
|
||||||
|
const out = Object.fromEntries(ids.map((id) => [id, []]));
|
||||||
|
const present = ids.filter((id) => texts[id]);
|
||||||
|
if (present.length < 2) return out;
|
||||||
|
const [base, ...rest] = present;
|
||||||
|
out[base] = diffVerses(texts[base], texts[rest[0]]).a;
|
||||||
|
for (const id of rest) out[id] = diffVerses(texts[base], texts[id]).b;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cuts a text at the ranges -> [{ text, mark }] so a view can wrap the marked parts.
|
||||||
|
export function markRanges(text, ranges) {
|
||||||
|
const parts = [];
|
||||||
|
let at = 0;
|
||||||
|
for (const r of [...(ranges ?? [])].sort((a, b) => a.start - b.start)) {
|
||||||
|
if (r.start > at) parts.push({ text: text.slice(at, r.start), mark: false });
|
||||||
|
parts.push({ text: text.slice(r.start, r.end), mark: true });
|
||||||
|
at = r.end;
|
||||||
|
}
|
||||||
|
if (at < text.length) parts.push({ text: text.slice(at), mark: false });
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { test, expect } from 'vitest';
|
||||||
|
import { translationSpans, alignVerses } from './compareTranslations.js';
|
||||||
|
|
||||||
|
const verses = (from, to) => Array.from({ length: to - from + 1 }, (_, i) => ({ number: from + i, text: `v${from + i}` }));
|
||||||
|
const project = { chapters: [
|
||||||
|
{ book: 'John', bookAbbrev: 'JHN', chapter: '3', verses: verses(1, 36), chunks: [] },
|
||||||
|
{ book: 'John', bookAbbrev: 'JHN', chapter: '4', verses: verses(1, 54), chunks: [] },
|
||||||
|
] };
|
||||||
|
|
||||||
|
test('a chunk inside one chapter is a single span', () => {
|
||||||
|
expect(translationSpans(project, 0, { startVerse: 16, endVerse: 18, spilloverEndVerse: null }))
|
||||||
|
.toEqual([{ bookAbbrev: 'JHN', book: 'John', chapter: '3', from: 16, to: 18 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a chunk that runs into the next chapter has a span for each', () => {
|
||||||
|
const spans = translationSpans(project, 0, { startVerse: 35, endVerse: 36, spilloverEndVerse: 2 });
|
||||||
|
expect(spans.map((s) => [s.chapter, s.from, s.to])).toEqual([['3', 35, 36], ['4', 1, 2]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('verses are lined up by number, with null where a translation lacks one', () => {
|
||||||
|
const spans = [{ book: 'John', chapter: '3', from: 16, to: 17 }];
|
||||||
|
const rows = alignVerses(spans, {
|
||||||
|
BSB: { 'John|3': [{ number: 16, text: 'a' }, { number: 17, text: 'b' }] },
|
||||||
|
NET: { 'John|3': [{ number: 16, text: 'c' }] },
|
||||||
|
});
|
||||||
|
expect(rows.map((r) => r.label)).toEqual(['16', '17']);
|
||||||
|
expect(rows[0].texts).toEqual({ BSB: 'a', NET: 'c' });
|
||||||
|
expect(rows[1].texts).toEqual({ BSB: 'b', NET: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
import { differencesFor, markRanges } from './compareTranslations.js';
|
||||||
|
|
||||||
|
test('differences are the words a translation has that the first one lacks', () => {
|
||||||
|
const d = differencesFor({ BSB: 'For God so loved the world', NET: 'For this is the way God loved the world', ESV: 'For God so loved the world' }, ['BSB', 'NET', 'ESV']);
|
||||||
|
expect(d.ESV).toEqual([]); // same words as the first
|
||||||
|
const text = 'For this is the way God loved the world';
|
||||||
|
expect(markRanges(text, d.NET).filter((p) => p.mark).map((p) => p.text.trim()).join(' ')).toContain('this is the way');
|
||||||
|
expect(differencesFor({ BSB: 'a b', NET: null }, ['BSB', 'NET']).BSB).toEqual([]); // nothing to compare against
|
||||||
|
});
|
||||||
|
|
||||||
|
test('markRanges splits a text into plain and marked pieces that rejoin to the original', () => {
|
||||||
|
const parts = markRanges('one two three four', [{ start: 4, end: 7 }, { start: 14, end: 18 }]);
|
||||||
|
expect(parts.map((p) => p.text).join('')).toBe('one two three four');
|
||||||
|
expect(parts.filter((p) => p.mark).map((p) => p.text)).toEqual(['two', 'four']);
|
||||||
|
});
|
||||||
@@ -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\.$/);
|
||||||
|
});
|
||||||
+16
-7
@@ -30,16 +30,24 @@ export function questionsFor(chunk) {
|
|||||||
return fallback.length ? { kind: 'think', items: fallback } : { kind: 'none', items: [] };
|
return fallback.length ? { kind: 'think', items: fallback } : { kind: 'none', items: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
function sessionHtml(project, session, { answerLines }) {
|
// layout: 'standard' (everything), 'questions' (passage + questions only, one page), 'large' (big print), 'notes' (passage and a blank page to write).
|
||||||
|
export const HANDOUT_LAYOUTS = [
|
||||||
|
{ id: 'standard', label: 'Standard: passage, outline, questions' },
|
||||||
|
{ id: 'questions', label: 'Questions only: passage and discussion questions' },
|
||||||
|
{ id: 'large', label: 'Large print: passage and questions' },
|
||||||
|
{ id: 'notes', label: 'Note-taking: passage and lots of lines' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function sessionHtml(project, session, { answerLines, layout = 'standard' }) {
|
||||||
const { chunk, chapterIndex, number, reference } = session;
|
const { chunk, chapterIndex, number, reference } = session;
|
||||||
const series = (project.series ?? '').trim();
|
const series = (project.series ?? '').trim();
|
||||||
const title = (chunk.episodeTitle ?? '').trim();
|
const title = (chunk.episodeTitle ?? '').trim();
|
||||||
const num = (chunk.episodeNumber ?? '').toString().trim() || number;
|
const num = (chunk.episodeNumber ?? '').toString().trim() || number;
|
||||||
const verses = getChunkVerseEntries(project, chapterIndex, chunk);
|
const verses = getChunkVerseEntries(project, chapterIndex, chunk);
|
||||||
const outline = flatten(outlineForSession(project, chunk));
|
const outline = layout === 'standard' ? flatten(outlineForSession(project, chunk)) : [];
|
||||||
const q = questionsFor(chunk);
|
const q = layout === 'notes' ? { kind: 'none', items: [] } : questionsFor(chunk);
|
||||||
const blank = (n) => Array.from({ length: n }, () => '<div class="rule"></div>').join('');
|
const blank = (n) => Array.from({ length: n }, () => '<div class="rule"></div>').join('');
|
||||||
return `<section class="page">
|
return `<section class="page${layout === 'standard' ? '' : ` ${layout}`}">
|
||||||
<header>
|
<header>
|
||||||
<p class="kicker">${esc(series || project.title)}</p>
|
<p class="kicker">${esc(series || project.title)}</p>
|
||||||
<h1>Session ${esc(num)}${title ? `: ${esc(title)}` : ''}</h1>
|
<h1>Session ${esc(num)}${title ? `: ${esc(title)}` : ''}</h1>
|
||||||
@@ -49,13 +57,13 @@ function sessionHtml(project, session, { answerLines }) {
|
|||||||
${verses.length ? `<div class="passage">${verses.map((v) => `<p><sup>${esc(v.number)}</sup> ${esc(v.text)}</p>`).join('')}</div>` : ''}
|
${verses.length ? `<div class="passage">${verses.map((v) => `<p><sup>${esc(v.number)}</sup> ${esc(v.text)}</p>`).join('')}</div>` : ''}
|
||||||
${outline.length ? `<h2>Outline</h2><ul class="outline">${outline.map(({ node, depth, label }) => `<li style="margin-left:${depth * 1.2}em"><b>${esc(label)}</b> ${esc(node.text || '')}${node.ref ? ` <i>(${esc(node.ref)})</i>` : ''}</li>`).join('')}</ul>` : ''}
|
${outline.length ? `<h2>Outline</h2><ul class="outline">${outline.map(({ node, depth, label }) => `<li style="margin-left:${depth * 1.2}em"><b>${esc(label)}</b> ${esc(node.text || '')}${node.ref ? ` <i>(${esc(node.ref)})</i>` : ''}</li>`).join('')}</ul>` : ''}
|
||||||
${q.items.length ? `<h2>${q.kind === 'questions' ? 'Discussion questions' : 'Think about it'}</h2><ol class="questions">${q.items.map((item) => `<li>${esc(item)}${answerLines ? blank(2) : ''}</li>`).join('')}</ol>` : ''}
|
${q.items.length ? `<h2>${q.kind === 'questions' ? 'Discussion questions' : 'Think about it'}</h2><ol class="questions">${q.items.map((item) => `<li>${esc(item)}${answerLines ? blank(2) : ''}</li>`).join('')}</ol>` : ''}
|
||||||
${answerLines ? `<h2>Notes</h2>${blank(q.items.length ? 4 : 10)}` : ''}
|
${answerLines || layout === 'notes' ? `<h2>Notes</h2>${blank(layout === 'notes' ? 16 : q.items.length ? 4 : 10)}` : ''}
|
||||||
<footer>${esc(project.title)}</footer>
|
<footer>${esc(project.title)}</footer>
|
||||||
</section>`;
|
</section>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ids: the chunk ids to include, or null for every session
|
// ids: the chunk ids to include, or null for every session
|
||||||
export function buildHandoutHtml(project, ids = null, { answerLines = true } = {}) {
|
export function buildHandoutHtml(project, ids = null, { answerLines = true, layout = 'standard' } = {}) {
|
||||||
const sessions = sessionsFor(project).filter((s) => !ids || ids.includes(s.chunk.id));
|
const sessions = sessionsFor(project).filter((s) => !ids || ids.includes(s.chunk.id));
|
||||||
return `<!doctype html><html><head><meta charset="utf-8"><title>${esc(project.title)} — handout</title><style>
|
return `<!doctype html><html><head><meta charset="utf-8"><title>${esc(project.title)} — handout</title><style>
|
||||||
@page { margin: 18mm 16mm; }
|
@page { margin: 18mm 16mm; }
|
||||||
@@ -71,8 +79,9 @@ export function buildHandoutHtml(project, ids = null, { answerLines = true } = {
|
|||||||
.outline { list-style: none; padding: 0; margin: 0; } .outline li { margin-bottom: .25em; } .outline b { color: #a84e27; }
|
.outline { list-style: none; padding: 0; margin: 0; } .outline li { margin-bottom: .25em; } .outline b { color: #a84e27; }
|
||||||
.questions { padding-left: 1.4em; } .questions li { margin-bottom: .7em; }
|
.questions { padding-left: 1.4em; } .questions li { margin-bottom: .7em; }
|
||||||
.rule { border-bottom: 1px solid #c9bfa8; height: 1.7em; }
|
.rule { border-bottom: 1px solid #c9bfa8; height: 1.7em; }
|
||||||
|
.large { font-size: 17pt; line-height: 1.55; } .large h1 { font-size: 30pt; } .large .rule { height: 2.2em; }
|
||||||
footer { position: absolute; bottom: 0; left: 0; right: 0; text-align: center; color: #9b9484; font: 9pt -apple-system, Helvetica, Arial, sans-serif; }
|
footer { position: absolute; bottom: 0; left: 0; right: 0; text-align: center; color: #9b9484; font: 9pt -apple-system, Helvetica, Arial, sans-serif; }
|
||||||
</style></head><body>${sessions.map((s) => sessionHtml(project, s, { answerLines })).join('')}</body></html>`;
|
</style></head><body>${sessions.map((s) => sessionHtml(project, s, { answerLines, layout })).join('')}</body></html>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function printHandout(project, ids = null, options) {
|
export function printHandout(project, ids = null, options) {
|
||||||
|
|||||||
@@ -46,3 +46,19 @@ test('one session only, and no writing lines when switched off', () => {
|
|||||||
expect(html).toContain('Session 2');
|
expect(html).toContain('Session 2');
|
||||||
expect(html).not.toContain('class="rule"');
|
expect(html).not.toContain('class="rule"');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
import { buildHandoutHtml as buildLayoutHtml } from './handout.js';
|
||||||
|
|
||||||
|
test('layouts change what the handout prints', () => {
|
||||||
|
const project = { id: 'p', title: 'T', translation: 'BSB', outline: [], chapters: [{ book: 'John', bookAbbrev: 'Jhn', chapter: '3', verses: [{ number: 16, text: 'For God so loved.' }], chunks: [{ id: 'c', startVerse: 16, endVerse: 16, discussionQuestions: 'Why did he give?', application: 'Trust him.', outline: [{ id: 'n', text: 'Point one', children: [] }] }] }] };
|
||||||
|
const standard = buildLayoutHtml(project);
|
||||||
|
const questions = buildLayoutHtml(project, null, { layout: 'questions' });
|
||||||
|
const notes = buildLayoutHtml(project, null, { layout: 'notes' });
|
||||||
|
const large = buildLayoutHtml(project, null, { layout: 'large' });
|
||||||
|
expect(standard).toContain('Point one');
|
||||||
|
expect(questions).not.toContain('Point one');
|
||||||
|
expect(questions).toContain('Why did he give?');
|
||||||
|
expect(notes).not.toContain('Why did he give?');
|
||||||
|
expect(notes.match(/class="rule"/g).length).toBeGreaterThanOrEqual(16);
|
||||||
|
expect(large).toContain('class="page large"');
|
||||||
|
});
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
// Bring notes written elsewhere (a Word file or Markdown from another app) into a study. A passage heading starts
|
||||||
|
// each section ("## John 3:16–18"), and labelled lines or sub-headings ("Observe: …", "### Interpret") hold the
|
||||||
|
// notes. Sections are matched to your chunks by passage. Nothing already written is overwritten.
|
||||||
|
import { parsePassageRef } from './passageRef.js';
|
||||||
|
import { withPaste } from './ownWork.js';
|
||||||
|
|
||||||
|
const LABELS = [
|
||||||
|
[/^observ(?:e|ation|ations)$/i, 'observation'],
|
||||||
|
[/^interpret(?:ation)?$/i, 'interpretation'],
|
||||||
|
[/^appl(?:y|ication)$/i, 'application'],
|
||||||
|
[/^(?:background|notes?|general notes)$/i, 'generalNotes'],
|
||||||
|
[/^(?:discussion )?questions$/i, 'discussionQuestions'],
|
||||||
|
];
|
||||||
|
const FIELD_NAMES = { observation: 'Observe', interpretation: 'Interpret', application: 'Apply', generalNotes: 'Background', discussionQuestions: 'Questions' };
|
||||||
|
export const importFieldName = (f) => FIELD_NAMES[f] ?? f;
|
||||||
|
|
||||||
|
const clean = (line) => line.replace(/^\s*#+\s*/, '').replace(/\*\*|__/g, '').trim();
|
||||||
|
const labelOf = (word) => LABELS.find(([re]) => re.test(word.trim()))?.[1] ?? null;
|
||||||
|
|
||||||
|
// A heading that is just a passage ("John 3:16-18", "Session 2: Romans 8:1–4") -> { book, chapter, verse, text } or null.
|
||||||
|
export function passageHeading(line) {
|
||||||
|
const text = clean(line).replace(/^(?:session|lesson|week|episode|part)\s*\d+\s*[:.\-–—]\s*/i, '');
|
||||||
|
const m = text.match(/^((?:[1-3]\s*)?[A-Za-z][A-Za-z .]*?)\s+(\d{1,3})(?:\s*:\s*(\d{1,3}))?(?:\s*[-–—]\s*\d{1,3}(?:\s*:\s*\d{1,3})?)?\s*$/);
|
||||||
|
if (!m || text.length > 60) return null;
|
||||||
|
const ref = parsePassageRef(`${m[1]} ${m[2]}${m[3] ? `:${m[3]}` : ''}`)[0];
|
||||||
|
return ref ? { book: ref.book, chapter: ref.chapter, verse: m[3] ? Number(m[3]) : null, text } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -> [{ heading, ref, observation?, interpretation?, application?, generalNotes?, discussionQuestions? }]
|
||||||
|
export function parseNotesText(raw) {
|
||||||
|
const sections = [];
|
||||||
|
let current = null;
|
||||||
|
let field = null;
|
||||||
|
const append = (value) => {
|
||||||
|
if (!current || !field) return;
|
||||||
|
current[field] = current[field] ? `${current[field]}\n${value}` : value;
|
||||||
|
};
|
||||||
|
for (const line of String(raw ?? '').replace(/\r/g, '').split('\n')) {
|
||||||
|
const heading = passageHeading(line);
|
||||||
|
if (heading) { current = { heading: heading.text, ref: heading }; sections.push(current); field = null; continue; }
|
||||||
|
if (!current) continue;
|
||||||
|
const stripped = clean(line).replace(/^[-*•]\s+/, '');
|
||||||
|
const labelled = stripped.match(/^([A-Za-z ]{3,22}?)\s*[:\-–]\s*(.*)$/);
|
||||||
|
const bare = /^\s*(?:#+\s|\*\*|__)/.test(line) ? labelOf(stripped.replace(/[:\-–]\s*$/, '')) : null; // "### Observe", "**Observe**"
|
||||||
|
if (labelled && labelOf(labelled[1])) { field = labelOf(labelled[1]); current[field] = current[field] ?? ''; if (labelled[2].trim()) append(labelled[2].trim()); continue; }
|
||||||
|
if (bare) { field = bare; current[field] = current[field] ?? ''; continue; }
|
||||||
|
if (field) append(line.trim());
|
||||||
|
}
|
||||||
|
return sections.map((s) => Object.fromEntries(Object.entries(s).map(([k, v]) => [k, typeof v === 'string' ? v.replace(/^\n+|\n+$/g, '').trim() : v])))
|
||||||
|
.filter((s) => Object.keys(FIELD_NAMES).some((f) => s[f]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which chunk a section belongs to: the one whose verses include the heading's verse (or the first in that chapter).
|
||||||
|
export function matchChunk(project, ref) {
|
||||||
|
for (const chapter of project?.chapters ?? []) {
|
||||||
|
if (chapter.bookAbbrev !== ref.book || Number(chapter.chapter) !== ref.chapter) continue;
|
||||||
|
const chunks = chapter.chunks ?? [];
|
||||||
|
const hit = ref.verse == null ? chunks[0] : chunks.find((c) => ref.verse >= c.startVerse && ref.verse <= c.endVerse);
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MARKER = '— imported —';
|
||||||
|
|
||||||
|
// -> { project, matched: [{ heading, fields }], unmatched: [heading] }. Where a note already has text, the imported
|
||||||
|
// text is added below a marker instead of replacing it. `own: false` records the text as pasted, not your own work.
|
||||||
|
export function applyImportedNotes(project, sections, { own = true, now = Date.now() } = {}) {
|
||||||
|
const patches = new Map(); // chunk id -> merged chunk
|
||||||
|
const matched = [];
|
||||||
|
const unmatched = [];
|
||||||
|
for (const section of sections) {
|
||||||
|
const chunk = matchChunk(project, section.ref);
|
||||||
|
if (!chunk) { unmatched.push(section.heading); continue; }
|
||||||
|
const next = { ...(patches.get(chunk.id) ?? chunk) };
|
||||||
|
const fields = [];
|
||||||
|
for (const f of Object.keys(FIELD_NAMES)) {
|
||||||
|
const incoming = section[f];
|
||||||
|
if (!incoming) continue;
|
||||||
|
const have = (next[f] ?? '').trim();
|
||||||
|
if (have.includes(incoming)) continue;
|
||||||
|
next[f] = have ? `${next[f].replace(/\s+$/, '')}\n\n${MARKER}\n${incoming}` : incoming;
|
||||||
|
if (!own && ['observation', 'interpretation', 'application'].includes(f)) next.aiPastes = withPaste({ aiPastes: next.aiPastes }, f, incoming) ?? { ...(next.aiPastes ?? {}), [f]: [...(next.aiPastes?.[f] ?? []), incoming] };
|
||||||
|
fields.push(importFieldName(f));
|
||||||
|
}
|
||||||
|
if (fields.length) { next.editedAt = now; patches.set(chunk.id, next); matched.push({ heading: section.heading, fields }); }
|
||||||
|
}
|
||||||
|
const out = { ...project, chapters: project.chapters.map((ch) => ({ ...ch, chunks: ch.chunks.map((c) => patches.get(c.id) ?? c) })) };
|
||||||
|
return { project: out, matched, unmatched };
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { test, expect } from 'vitest';
|
||||||
|
import { parseNotesText, passageHeading, applyImportedNotes, matchChunk } from './importNotes.js';
|
||||||
|
import { ownWords } from './ownWork.js';
|
||||||
|
|
||||||
|
const SAMPLE = `# My John study
|
||||||
|
|
||||||
|
## John 3:16-18
|
||||||
|
Observe: God loved the world.
|
||||||
|
He gave his Son.
|
||||||
|
Interpret: Love is shown by giving.
|
||||||
|
### Apply
|
||||||
|
Trust him this week.
|
||||||
|
|
||||||
|
## Session 2: John 3:19–21
|
||||||
|
**Observation:** Light and darkness.
|
||||||
|
Questions:
|
||||||
|
- Why do people love darkness?
|
||||||
|
`;
|
||||||
|
|
||||||
|
test('headings that are a passage are recognised, others are not', () => {
|
||||||
|
expect(passageHeading('## John 3:16-18')).toMatchObject({ book: 'JHN', chapter: 3, verse: 16 });
|
||||||
|
expect(passageHeading('Session 2: John 3:19–21')).toMatchObject({ chapter: 3, verse: 19 });
|
||||||
|
expect(passageHeading('Romans 8')).toMatchObject({ book: 'ROM', chapter: 8, verse: null });
|
||||||
|
expect(passageHeading('# My John study')).toBeNull();
|
||||||
|
expect(passageHeading('Observe: God loved')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sections and their labelled notes are read from Markdown', () => {
|
||||||
|
const sections = parseNotesText(SAMPLE);
|
||||||
|
expect(sections).toHaveLength(2);
|
||||||
|
expect(sections[0]).toMatchObject({ observation: 'God loved the world.\nHe gave his Son.', interpretation: 'Love is shown by giving.', application: 'Trust him this week.' });
|
||||||
|
expect(sections[1].observation).toBe('Light and darkness.');
|
||||||
|
expect(sections[1].discussionQuestions).toContain('Why do people love darkness?');
|
||||||
|
});
|
||||||
|
|
||||||
|
const project = () => ({ id: 'p', title: 'T', chapters: [{ book: 'John', bookAbbrev: 'JHN', chapter: '3', verses: [], chunks: [
|
||||||
|
{ id: 'a', startVerse: 16, endVerse: 18, observation: '', interpretation: 'Mine already.' },
|
||||||
|
{ id: 'b', startVerse: 19, endVerse: 21, observation: '' },
|
||||||
|
] }] });
|
||||||
|
|
||||||
|
test('notes land in the chunk that holds the verse, and nothing written is overwritten', () => {
|
||||||
|
const sections = [...parseNotesText(SAMPLE), { heading: 'Romans 8', ref: { book: 'ROM', chapter: 8, verse: null }, observation: 'x' }];
|
||||||
|
const { project: out, matched, unmatched } = applyImportedNotes(project(), sections, { now: 5 });
|
||||||
|
const a = out.chapters[0].chunks[0];
|
||||||
|
expect(a.observation).toContain('God loved the world.');
|
||||||
|
expect(a.interpretation).toBe('Mine already.\n\n— imported —\nLove is shown by giving.');
|
||||||
|
expect(out.chapters[0].chunks[1].observation).toBe('Light and darkness.');
|
||||||
|
expect(matched).toHaveLength(2);
|
||||||
|
expect(unmatched).toEqual(['Romans 8']);
|
||||||
|
expect(matchChunk(project(), { book: 'JHN', chapter: 3, verse: 20 }).id).toBe('b');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('importing as "not mine" records the text as pasted, so it is not counted as your own work', () => {
|
||||||
|
const long = Array.from({ length: 30 }, (_, i) => `w${i}`).join(' ');
|
||||||
|
const sections = [{ heading: 'John 3:16', ref: { book: 'JHN', chapter: 3, verse: 16 }, observation: long }];
|
||||||
|
const mine = applyImportedNotes(project(), sections, { own: true }).project.chapters[0].chunks[0];
|
||||||
|
const notMine = applyImportedNotes(project(), sections, { own: false }).project.chapters[0].chunks[0];
|
||||||
|
expect(ownWords(mine, 'observation')).toBe(30);
|
||||||
|
expect(ownWords(notMine, 'observation')).toBe(0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// Looks across every study at once: this week's own work, tags as themes, the original-language words you
|
||||||
|
// keep returning to, what slipped in recalls, and follow-ups still open. `projects` are full study projects.
|
||||||
|
import { formatChunkReference } from './bibleUtils.jsx';
|
||||||
|
import { NOTE_FIELDS, ownShare, ownWords } from './ownWork.js';
|
||||||
|
import { WEEK_MS } from './recall.js';
|
||||||
|
|
||||||
|
const lines = (text) => String(text ?? '').split('\n').map((l) => l.replace(/^\s*(?:[-*•–]|\d{1,2}[.)])\s+/, '').trim()).filter(Boolean);
|
||||||
|
|
||||||
|
// Every chunk with where it lives.
|
||||||
|
function everyChunk(projects) {
|
||||||
|
return (projects ?? []).flatMap((project) => (project?.chapters ?? []).flatMap((chapter, chapterIndex) => (chapter.chunks ?? []).map((chunk) => ({
|
||||||
|
project, chunk, projectId: project.id, projectTitle: project.title, chunkId: chunk.id,
|
||||||
|
reference: formatChunkReference(project, chapterIndex, chunk, '–'),
|
||||||
|
}))));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chunks edited in the last 7 days: how many, your own words in them, the share that is yours, and recalls done.
|
||||||
|
export function weeklySummary(projects, now = Date.now()) {
|
||||||
|
const all = everyChunk(projects);
|
||||||
|
const recent = all.filter(({ chunk }) => now - (chunk.editedAt ?? 0) < WEEK_MS && (chunk.editedAt ?? 0) > 0);
|
||||||
|
const ownWordsTotal = recent.reduce((n, { chunk }) => n + NOTE_FIELDS.reduce((m, f) => m + ownWords(chunk, f), 0), 0);
|
||||||
|
const shares = recent.map(({ chunk }) => ownShare(chunk)).filter((s) => s !== null);
|
||||||
|
return {
|
||||||
|
chunksTouched: recent.length,
|
||||||
|
ownWords: ownWordsTotal,
|
||||||
|
ownShare: shares.length ? Math.round(shares.reduce((a, b) => a + b, 0) / shares.length) : null,
|
||||||
|
recalls: all.reduce((n, { chunk }) => n + (chunk.recalls ?? []).filter((r) => now - (r.at ?? 0) < WEEK_MS).length, 0),
|
||||||
|
fiveMinutes: all.reduce((n, { chunk }) => n + (chunk.fiveMinutes ?? []).filter((f) => now - (f.at ?? 0) < WEEK_MS).length, 0),
|
||||||
|
teachBacks: recent.filter(({ chunk }) => (chunk.teachBack ?? '').trim()).length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const snippet = (chunk) => {
|
||||||
|
const raw = (chunk.observation || chunk.interpretation || chunk.application || '').replace(/\s+/g, ' ').trim();
|
||||||
|
return raw.length > 110 ? `${raw.slice(0, 110)}…` : raw;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tags across studies: [{ tag, count, items: [{ projectId, projectTitle, chunkId, reference, snippet }] }], most used first.
|
||||||
|
export function themeIndex(projects) {
|
||||||
|
const map = new Map();
|
||||||
|
for (const entry of everyChunk(projects)) {
|
||||||
|
for (const tag of entry.chunk.tags ?? []) {
|
||||||
|
const key = tag.trim().toLowerCase();
|
||||||
|
if (!key) continue;
|
||||||
|
if (!map.has(key)) map.set(key, { tag: tag.trim(), items: [] });
|
||||||
|
map.get(key).items.push({ projectId: entry.projectId, projectTitle: entry.projectTitle, chunkId: entry.chunkId, reference: entry.reference, snippet: snippet(entry.chunk) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...map.values()].map((t) => ({ ...t, count: t.items.length })).sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Original-language words studied in more than one place (or all, with minCount 1): most often first.
|
||||||
|
export function wordIndex(projects, { minCount = 2 } = {}) {
|
||||||
|
const map = new Map();
|
||||||
|
for (const entry of everyChunk(projects)) {
|
||||||
|
for (const w of entry.chunk.greekWords ?? []) {
|
||||||
|
const strong = (w.strongNumber ?? '').toUpperCase();
|
||||||
|
const key = strong || (w.lexeme ?? w.query ?? '').toLowerCase();
|
||||||
|
if (!key) continue;
|
||||||
|
if (!map.has(key)) map.set(key, { key, strong, lexeme: w.lexeme || w.query || key, gloss: w.englishGloss || w.shortDefinition || '', transliteration: w.transliteration ?? '', places: [] });
|
||||||
|
map.get(key).places.push({ projectId: entry.projectId, projectTitle: entry.projectTitle, chunkId: entry.chunkId, reference: entry.reference });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...map.values()].map((w) => ({ ...w, count: w.places.length })).filter((w) => w.count >= minCount).sort((a, b) => b.count - a.count || a.lexeme.localeCompare(b.lexeme));
|
||||||
|
}
|
||||||
|
|
||||||
|
// What you said slipped in your recalls, newest first: [{ at, reference, projectId, chunkId, items: [line] }].
|
||||||
|
export function slippedList(projects) {
|
||||||
|
return everyChunk(projects).flatMap((e) => (e.chunk.recalls ?? [])
|
||||||
|
.map((r) => ({ at: r.at ?? 0, reference: e.reference, projectId: e.projectId, projectTitle: e.projectTitle, chunkId: e.chunkId, items: lines(r.gaps) }))
|
||||||
|
.filter((r) => r.items.length)).sort((a, b) => b.at - a.at);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Follow-ups across studies: open ones first (then done), newest chunk first.
|
||||||
|
export function followUpList(projects) {
|
||||||
|
return everyChunk(projects).flatMap((e) => (e.chunk.followUps ?? []).map((f) => ({
|
||||||
|
id: f.id, text: f.text, done: !!f.done, reference: e.reference, projectId: e.projectId, projectTitle: e.projectTitle, chunkId: e.chunkId, editedAt: e.chunk.editedAt ?? 0,
|
||||||
|
}))).sort((a, b) => Number(a.done) - Number(b.done) || b.editedAt - a.editedAt);
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { test, expect } from 'vitest';
|
||||||
|
import { weeklySummary, themeIndex, wordIndex, slippedList, followUpList } from './insights.js';
|
||||||
|
import { WEEK_MS } from './recall.js';
|
||||||
|
|
||||||
|
const NOW = 100 * WEEK_MS;
|
||||||
|
const text = (n) => Array.from({ length: n }, (_, i) => `w${i}`).join(' ');
|
||||||
|
const chunk = (id, extra = {}) => ({ id, startVerse: 1, endVerse: 5, observation: text(20), editedAt: NOW - 1000, ...extra });
|
||||||
|
const study = (chunks, id = 'p1', title = 'Romans') => ({ id, title, translation: 'BSB', chapters: [{ book: 'Romans', bookAbbrev: 'Rom', chapter: 8, chunks }] });
|
||||||
|
|
||||||
|
test('the weekly summary counts only chunks edited this week', () => {
|
||||||
|
const pasted = text(10);
|
||||||
|
const projects = [study([
|
||||||
|
chunk('a', { teachBack: 'x', recalls: [{ at: NOW - 1000, rating: 'solid' }] }),
|
||||||
|
chunk('b', { observation: `${text(10)} ${pasted}`, aiPastes: { observation: [pasted] }, editedAt: NOW - 500 }),
|
||||||
|
chunk('old', { editedAt: NOW - 3 * WEEK_MS, recalls: [{ at: NOW - 3 * WEEK_MS, rating: 'solid' }] }),
|
||||||
|
])];
|
||||||
|
const s = weeklySummary(projects, NOW);
|
||||||
|
expect(s).toMatchObject({ chunksTouched: 2, ownWords: 30, recalls: 1, teachBacks: 1 });
|
||||||
|
expect(s.ownShare).toBe(75); // 100% and 50%, averaged
|
||||||
|
expect(weeklySummary([], NOW).ownShare).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tags group notes from different studies into themes, most used first', () => {
|
||||||
|
const p1 = study([chunk('a', { tags: ['Grace'] }), chunk('b', { tags: ['hope'] })]);
|
||||||
|
const p2 = study([chunk('c', { tags: ['grace '] })], 'p2', 'John');
|
||||||
|
const themes = themeIndex([p1, p2]);
|
||||||
|
expect(themes.map((t) => [t.tag, t.count])).toEqual([['Grace', 2], ['hope', 1]]);
|
||||||
|
expect(themes[0].items.map((i) => i.projectTitle)).toEqual(['Romans', 'John']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('words that come up in more than one place are listed', () => {
|
||||||
|
const w = (n, g) => ({ id: n, strongNumber: n, lexeme: g, englishGloss: g });
|
||||||
|
const projects = [study([chunk('a', { greekWords: [w('G26', 'agape'), w('G4102', 'pistis')] }), chunk('b', { greekWords: [w('g26', 'agape')] })])];
|
||||||
|
const list = wordIndex(projects);
|
||||||
|
expect(list).toHaveLength(1);
|
||||||
|
expect(list[0]).toMatchObject({ strong: 'G26', count: 2 });
|
||||||
|
expect(wordIndex(projects, { minCount: 1 })).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('slipped lines and follow-ups are collected, open follow-ups first', () => {
|
||||||
|
const projects = [study([chunk('a', {
|
||||||
|
recalls: [{ at: 5, gaps: '- the serpent\nwho spoke', rating: 'partly' }, { at: 9, gaps: '', rating: 'solid' }],
|
||||||
|
followUps: [{ id: '1', text: 'done thing', done: true }, { id: '2', text: 'call Sam', done: false }],
|
||||||
|
})])];
|
||||||
|
expect(slippedList(projects)).toHaveLength(1);
|
||||||
|
expect(slippedList(projects)[0].items).toEqual(['the serpent', 'who spoke']);
|
||||||
|
expect(followUpList(projects).map((f) => f.text)).toEqual(['call Sam', 'done thing']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('five-minute sessions this week are counted', () => {
|
||||||
|
const p = [study([chunk('a', { fiveMinutes: [{ at: NOW - 1000, field: 'observation' }, { at: NOW - 3 * WEEK_MS, field: 'application' }] })])];
|
||||||
|
expect(weeklySummary(p, NOW).fiveMinutes).toBe(1);
|
||||||
|
});
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
// conflict doesn't force a choice between them. Chunks are matched by id; each chunk carries the time it
|
// conflict doesn't force a choice between them. Chunks are matched by id; each chunk carries the time it
|
||||||
// was last edited here (editedAt). Nothing the person wrote is dropped: where both copies changed the
|
// was last edited here (editedAt). Nothing the person wrote is dropped: where both copies changed the
|
||||||
// same note, the newer one stays and the older one is appended under a marker.
|
// same note, the newer one stays and the older one is appended under a marker.
|
||||||
const TEXT_FIELDS = ['observation', 'interpretation', 'application', 'generalNotes', 'finalScript', 'episodeNumber', 'episodeTitle'];
|
const TEXT_FIELDS = ['observation', 'interpretation', 'application', 'generalNotes', 'finalScript', 'episodeNumber', 'episodeTitle', 'teachBack', 'attendees', 'groupNotes'];
|
||||||
const LIST_FIELDS = ['tags', 'crossReferences', 'greekWords'];
|
const LIST_FIELDS = ['tags', 'crossReferences', 'greekWords'];
|
||||||
export const MERGE_MARKER = '— from your other device —';
|
export const MERGE_MARKER = '— from your other device —';
|
||||||
|
|
||||||
@@ -22,6 +22,12 @@ function union(a = [], b = []) {
|
|||||||
return [...a, ...b].filter((item) => { const k = JSON.stringify(item); if (seen.has(k)) return false; seen.add(k); return true; });
|
return [...a, ...b].filter((item) => { const k = JSON.stringify(item); if (seen.has(k)) return false; seen.add(k); return true; });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Follow-ups are matched by id; the newer copy's wording and done-state win, nothing is dropped.
|
||||||
|
function mergeById(newer = [], older = []) {
|
||||||
|
const seen = new Set(newer.map((i) => i.id));
|
||||||
|
return [...newer, ...older.filter((i) => !seen.has(i.id))];
|
||||||
|
}
|
||||||
|
|
||||||
export function mergeChunk(mine, theirs) {
|
export function mergeChunk(mine, theirs) {
|
||||||
if (same(mine, theirs)) return { chunk: mine, changed: false };
|
if (same(mine, theirs)) return { chunk: mine, changed: false };
|
||||||
const mineNewer = (mine.editedAt ?? 0) >= (theirs.editedAt ?? 0);
|
const mineNewer = (mine.editedAt ?? 0) >= (theirs.editedAt ?? 0);
|
||||||
@@ -31,6 +37,12 @@ export function mergeChunk(mine, theirs) {
|
|||||||
LIST_FIELDS.forEach((f) => { out[f] = union(newer[f], older[f]); });
|
LIST_FIELDS.forEach((f) => { out[f] = union(newer[f], older[f]); });
|
||||||
// Pastes follow their text: keep the union per field so merged notes still show what was pasted.
|
// Pastes follow their text: keep the union per field so merged notes still show what was pasted.
|
||||||
out.aiPastes = Object.fromEntries(['observation', 'interpretation', 'application'].map((f) => [f, union(newer.aiPastes?.[f], older.aiPastes?.[f])]));
|
out.aiPastes = Object.fromEntries(['observation', 'interpretation', 'application'].map((f) => [f, union(newer.aiPastes?.[f], older.aiPastes?.[f])]));
|
||||||
|
// Verse notes are kept per verse (the newer wording wins, an older different one is appended); the earliest first draft is kept.
|
||||||
|
const noteKeys = new Set([...Object.keys(newer.verseNotes ?? {}), ...Object.keys(older.verseNotes ?? {})]);
|
||||||
|
out.verseNotes = Object.fromEntries([...noteKeys].map((k) => [k, mergeText(newer.verseNotes?.[k], older.verseNotes?.[k])]));
|
||||||
|
out.firstDraft = [newer.firstDraft, older.firstDraft].filter(Boolean).sort((a, b) => a.at - b.at)[0];
|
||||||
|
out.fiveMinutes = union(newer.fiveMinutes, older.fiveMinutes);
|
||||||
|
out.followUps = mergeById(newer.followUps, older.followUps);
|
||||||
out.recalls = union(newer.recalls, older.recalls);
|
out.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;
|
||||||
return { chunk: out, changed: !same(out, mine) };
|
return { chunk: out, changed: !same(out, mine) };
|
||||||
@@ -66,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();
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// Earlier versions of a single note (Observe, Interpret, Apply…), kept on this device so one note can be rolled
|
||||||
|
// back without restoring the whole study. A version is saved when you start and when you stop editing a note, so
|
||||||
|
// whatever was there before a big rewrite is always a click away.
|
||||||
|
export const MAX_VERSIONS = 12;
|
||||||
|
const keyFor = (chunkId, field) => `note-history:${chunkId}:${field}`;
|
||||||
|
|
||||||
|
// -> [{ at, text }], newest first
|
||||||
|
export function listNoteVersions(chunkId, field) {
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(localStorage.getItem(keyFor(chunkId, field)) || '[]');
|
||||||
|
return Array.isArray(raw) ? raw.filter((v) => v && typeof v.text === 'string') : [];
|
||||||
|
} catch { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keeps `text` as the newest version unless it is blank or the same as the newest one. Returns the list.
|
||||||
|
export function recordNoteVersion(chunkId, field, text, now = Date.now()) {
|
||||||
|
const list = listNoteVersions(chunkId, field);
|
||||||
|
const value = text ?? '';
|
||||||
|
if (!value.trim() || list[0]?.text === value) return list;
|
||||||
|
const next = [{ at: now, text: value }, ...list].slice(0, MAX_VERSIONS);
|
||||||
|
try { localStorage.setItem(keyFor(chunkId, field), JSON.stringify(next)); } catch { /* storage full or unavailable */ }
|
||||||
|
return next;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { beforeEach, expect, test } from 'vitest';
|
||||||
|
import { MAX_VERSIONS, listNoteVersions, recordNoteVersion } from './noteHistory.js';
|
||||||
|
|
||||||
|
beforeEach(() => localStorage.clear());
|
||||||
|
|
||||||
|
test('versions are kept newest first, and blanks and repeats are skipped', () => {
|
||||||
|
recordNoteVersion('c1', 'observation', 'first', 1);
|
||||||
|
recordNoteVersion('c1', 'observation', 'first', 2); // same as the newest
|
||||||
|
recordNoteVersion('c1', 'observation', ' ', 3); // blank
|
||||||
|
recordNoteVersion('c1', 'observation', 'second', 4);
|
||||||
|
expect(listNoteVersions('c1', 'observation').map((v) => v.text)).toEqual(['second', 'first']);
|
||||||
|
expect(listNoteVersions('c1', 'application')).toEqual([]);
|
||||||
|
expect(listNoteVersions('c2', 'observation')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('only the latest versions are kept', () => {
|
||||||
|
for (let i = 0; i < MAX_VERSIONS + 5; i += 1) recordNoteVersion('c1', 'observation', `v${i}`, i);
|
||||||
|
const list = listNoteVersions('c1', 'observation');
|
||||||
|
expect(list).toHaveLength(MAX_VERSIONS);
|
||||||
|
expect(list[0].text).toBe(`v${MAX_VERSIONS + 4}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a damaged store reads as empty', () => {
|
||||||
|
localStorage.setItem('note-history:c1:observation', '{nope');
|
||||||
|
expect(listNoteVersions('c1', 'observation')).toEqual([]);
|
||||||
|
});
|
||||||
@@ -43,3 +43,18 @@ export async function downloadChapters(translation, refs, { onProgress, signal,
|
|||||||
await Promise.all(Array.from({ length: Math.min(concurrency, refs.length) }, worker));
|
await Promise.all(Array.from({ length: Math.min(concurrency, refs.length) }, worker));
|
||||||
return { done, failed };
|
return { done, failed };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// What a study needs from the network to open offline. Your notes are always kept on this device; only the
|
||||||
|
// Bible text must have been saved. `saved` is loadSavedBooks(). -> { state: 'ready' | 'partial' | 'licensed',
|
||||||
|
// translation, missing: [{ abbrev, name }] }
|
||||||
|
export function offlineStatus(project, saved = loadSavedBooks()) {
|
||||||
|
const translation = project?.translation ?? 'BSB';
|
||||||
|
if (!canSaveOffline(translation)) return { state: 'licensed', translation, missing: [] };
|
||||||
|
const books = [];
|
||||||
|
for (const chapter of project?.chapters ?? []) {
|
||||||
|
if (chapter.bookAbbrev && !books.some((b) => b.abbrev === chapter.bookAbbrev)) books.push({ abbrev: chapter.bookAbbrev, name: chapter.book });
|
||||||
|
}
|
||||||
|
const have = saved[translation] ?? [];
|
||||||
|
const missing = books.filter((b) => !have.includes(b.abbrev));
|
||||||
|
return { state: missing.length ? 'partial' : 'ready', translation, missing };
|
||||||
|
}
|
||||||
@@ -42,3 +42,18 @@ test('stops when told to', async () => {
|
|||||||
await downloadChapters('BSB', bookRefs('PSA'), { signal: controller.signal, concurrency: 1 });
|
await downloadChapters('BSB', bookRefs('PSA'), { signal: controller.signal, concurrency: 1 });
|
||||||
expect(fetchMock.mock.calls.length).toBeLessThan(5);
|
expect(fetchMock.mock.calls.length).toBeLessThan(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
import { offlineStatus } from './offline.js';
|
||||||
|
|
||||||
|
test('a study works offline once every book in it is saved in its translation', () => {
|
||||||
|
const project = { translation: 'BSB', chapters: [{ book: 'John', bookAbbrev: 'JHN' }, { book: 'John', bookAbbrev: 'JHN' }, { book: 'Titus', bookAbbrev: 'TIT' }] };
|
||||||
|
expect(offlineStatus(project, { BSB: ['JHN', 'TIT'] })).toMatchObject({ state: 'ready', missing: [] });
|
||||||
|
const partial = offlineStatus(project, { BSB: ['JHN'] });
|
||||||
|
expect(partial.state).toBe('partial');
|
||||||
|
expect(partial.missing).toEqual([{ abbrev: 'TIT', name: 'Titus' }]);
|
||||||
|
expect(offlineStatus(project, { NET: ['JHN', 'TIT'] }).state).toBe('partial'); // saved in a different translation
|
||||||
|
});
|
||||||
|
|
||||||
|
test('licensed translations cannot be saved offline', () => {
|
||||||
|
expect(offlineStatus({ translation: 'ESV', chapters: [{ book: 'John', bookAbbrev: 'JHN' }] }, {}).state).toBe('licensed');
|
||||||
|
});
|
||||||
@@ -47,3 +47,17 @@ export function thinFields(chunk) {
|
|||||||
export function chunksNeedingWork(chunks) {
|
export function chunksNeedingWork(chunks) {
|
||||||
return (chunks ?? []).map((chunk) => ({ chunk, missing: thinFields(chunk) })).filter((entry) => entry.missing.length > 0);
|
return (chunks ?? []).map((chunk) => ({ chunk, missing: thinFields(chunk) })).filter((entry) => entry.missing.length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Commentary and study aids stay folded away until you've written this much of your own Observe notes.
|
||||||
|
export const OBSERVE_FIRST_MIN = 15;
|
||||||
|
export const observeFirstDone = (chunk) => ownWords(chunk, 'observation') >= OBSERVE_FIRST_MIN;
|
||||||
|
|
||||||
|
// "No, I wrote it": take one remembered paste back out, so those words count as your own again.
|
||||||
|
export function withoutPaste(chunk, field, pasted) {
|
||||||
|
const text = (pasted ?? '').trim();
|
||||||
|
const all = chunk?.aiPastes ?? {};
|
||||||
|
const list = [...(all[field] ?? [])];
|
||||||
|
const at = list.lastIndexOf(text);
|
||||||
|
if (at >= 0) list.splice(at, 1);
|
||||||
|
return { ...all, [field]: list };
|
||||||
|
}
|
||||||
@@ -56,3 +56,14 @@ test('merging two copies keeps the pastes from both', () => {
|
|||||||
const b = { id: 'x', observation: 'b', editedAt: 1, aiPastes: { observation: ['p2'] } };
|
const b = { id: 'x', observation: 'b', editedAt: 1, aiPastes: { observation: ['p2'] } };
|
||||||
expect(mergeChunk(a, b).chunk.aiPastes.observation).toEqual(['p1', 'p2']);
|
expect(mergeChunk(a, b).chunk.aiPastes.observation).toEqual(['p1', 'p2']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
import { withoutPaste } from './ownWork.js';
|
||||||
|
|
||||||
|
test('"No, I wrote it" takes one paste back out so the words count as yours again', () => {
|
||||||
|
const pasted = text(30);
|
||||||
|
const chunk = { observation: pasted, aiPastes: { observation: [pasted, 'other'] } };
|
||||||
|
const after = { ...chunk, aiPastes: withoutPaste(chunk, 'observation', ` ${pasted} `) };
|
||||||
|
expect(after.aiPastes.observation).toEqual(['other']);
|
||||||
|
expect(ownWords(after, 'observation')).toBe(30);
|
||||||
|
expect(withoutPaste({}, 'observation', 'x')).toEqual({ observation: [] });
|
||||||
|
});
|
||||||
@@ -13,18 +13,55 @@ const snippetAround = (raw, at, len) => {
|
|||||||
return (start > 0 ? '…' : '') + raw.slice(start, end).replace(/\s+/g, ' ') + (end < raw.length ? '…' : '');
|
return (start > 0 ? '…' : '') + raw.slice(start, end).replace(/\s+/g, ' ') + (end < raw.length ? '…' : '');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const DAY = 86400000;
|
||||||
|
|
||||||
|
// "grace in Romans last year" -> { text: 'grace', book: 'Romans', since, until }. Words it understands:
|
||||||
|
// "in <book>", "last|this week|month|year" (last = the past 7 / 30 / 365 days; this year = since 1 January) and a
|
||||||
|
// four-digit year. Whatever is left is the text to look for.
|
||||||
|
export function parseSearchQuery(query, now = Date.now()) {
|
||||||
|
let text = ` ${query} `;
|
||||||
|
let book = null;
|
||||||
|
let since = null;
|
||||||
|
let until = null;
|
||||||
|
for (const b of [...bookOptions].sort((x, y) => y.name.length - x.name.length)) {
|
||||||
|
const re = new RegExp(`\\s(?:in|from)\\s+${b.name.toLowerCase().replace(/\s+/g, '\\s+')}(?=\\s)`, 'i');
|
||||||
|
if (re.test(text)) { book = b.name; text = text.replace(re, ' '); break; }
|
||||||
|
}
|
||||||
|
const rel = text.match(/\s(last|this)\s+(week|month|year)(?=\s)/i);
|
||||||
|
if (rel) {
|
||||||
|
const [, which, unit] = rel;
|
||||||
|
const span = { week: 7, month: 30, year: 365 }[unit.toLowerCase()];
|
||||||
|
since = which.toLowerCase() === 'last' ? now - span * DAY : (unit.toLowerCase() === 'year' ? new Date(new Date(now).getFullYear(), 0, 1).getTime() : now - span * DAY);
|
||||||
|
text = text.replace(rel[0], ' ');
|
||||||
|
}
|
||||||
|
const year = text.match(/\s(20\d\d)(?=\s)/);
|
||||||
|
if (year) {
|
||||||
|
since = new Date(Number(year[1]), 0, 1).getTime();
|
||||||
|
until = new Date(Number(year[1]) + 1, 0, 1).getTime();
|
||||||
|
text = text.replace(year[0], ' ');
|
||||||
|
}
|
||||||
|
return { text: text.replace(/\s+/g, ' ').trim(), book, since, until };
|
||||||
|
}
|
||||||
|
|
||||||
// projects: full project objects. -> [{ projectId, projectTitle, chunkId, ref, field, snippet }], at most
|
// projects: full project objects. -> [{ projectId, projectTitle, chunkId, ref, field, snippet }], at most
|
||||||
// `limit`, one hit per chunk (the first field that matches).
|
// `limit`, one hit per chunk (the first field that matches).
|
||||||
export function searchNotes(projects, query, { limit = 20 } = {}) {
|
export function searchNotes(projects, query, { limit = 20, now = Date.now() } = {}) {
|
||||||
const q = query.trim().toLowerCase();
|
const parsed = parseSearchQuery(query, now);
|
||||||
if (q.length < 3) return [];
|
const q = parsed.text.toLowerCase();
|
||||||
|
const filtered = !!(parsed.book || parsed.since);
|
||||||
|
if (q.length < 3 && !(filtered && q.length === 0)) return [];
|
||||||
const hits = [];
|
const hits = [];
|
||||||
for (const project of projects) {
|
for (const project of projects) {
|
||||||
for (const [chapterIndex, chapter] of (project.chapters ?? []).entries()) {
|
for (const [chapterIndex, chapter] of (project.chapters ?? []).entries()) {
|
||||||
|
if (parsed.book && chapter.book !== parsed.book) continue;
|
||||||
for (const chunk of chapter.chunks ?? []) {
|
for (const chunk of chapter.chunks ?? []) {
|
||||||
|
const when = chunk.editedAt ?? project.lastEdited ?? 0;
|
||||||
|
if (parsed.since && when < parsed.since) continue;
|
||||||
|
if (parsed.until && when >= parsed.until) continue;
|
||||||
for (const [field, label] of NOTE_FIELDS) {
|
for (const [field, label] of NOTE_FIELDS) {
|
||||||
const raw = chunk[field] ?? '';
|
const raw = chunk[field] ?? '';
|
||||||
const at = raw.toLowerCase().indexOf(q);
|
if (!q && !raw.trim()) continue; // a passage/date search lists chunks that have something written
|
||||||
|
const at = q ? raw.toLowerCase().indexOf(q) : 0;
|
||||||
if (at === -1) continue;
|
if (at === -1) continue;
|
||||||
hits.push({
|
hits.push({
|
||||||
projectId: project.id, projectTitle: project.title, chunkId: chunk.id,
|
projectId: project.id, projectTitle: project.title, chunkId: chunk.id,
|
||||||
|
|||||||
@@ -40,3 +40,25 @@ test('finds highlights by their words or colour name, and bookmarks by reference
|
|||||||
expect(searchMarks(marks, 'promise')[0]).toMatchObject({ kind: 'highlight' });
|
expect(searchMarks(marks, 'promise')[0]).toMatchObject({ kind: 'highlight' });
|
||||||
expect(searchMarks(marks, 'john 3')[0]).toMatchObject({ kind: 'bookmark', chapter: 3, verse: 16 });
|
expect(searchMarks(marks, 'john 3')[0]).toMatchObject({ kind: 'bookmark', chapter: 3, verse: 16 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
import { parseSearchQuery as parseQ } from './personalSearch.js';
|
||||||
|
|
||||||
|
test('search understands "in <book>" and time words', () => {
|
||||||
|
const now = new Date('2026-10-14T12:00:00Z').getTime();
|
||||||
|
const q = parseQ('grace in Romans last year', now);
|
||||||
|
expect(q.text).toBe('grace');
|
||||||
|
expect(q.book).toBe('Romans');
|
||||||
|
expect(q.since).toBe(now - 365 * 86400000);
|
||||||
|
expect(parseQ('hope 2025', now)).toMatchObject({ text: 'hope', since: new Date(2025, 0, 1).getTime(), until: new Date(2026, 0, 1).getTime() });
|
||||||
|
expect(parseQ('plain words', now)).toMatchObject({ text: 'plain words', book: null, since: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a passage and date search filters notes, even with no words to match', () => {
|
||||||
|
const now = new Date('2026-10-14T12:00:00Z').getTime();
|
||||||
|
const day = 86400000;
|
||||||
|
const make = (book, editedAt, observation) => ({ id: book + editedAt, title: 'S', lastEdited: now, chapters: [{ book, bookAbbrev: book.slice(0, 3), chapter: '1', chunks: [{ id: `${book}${editedAt}`, startVerse: 1, endVerse: 3, observation, editedAt }] }] });
|
||||||
|
const projects = [make('Romans', now - 10 * day, 'grace abounds'), make('Romans', now - 400 * day, 'grace long ago'), make('John', now - 10 * day, 'grace again')];
|
||||||
|
expect(searchNotes(projects, 'grace in Romans last year', { now })).toHaveLength(1);
|
||||||
|
expect(searchNotes(projects, 'in John last month', { now })).toHaveLength(1);
|
||||||
|
expect(searchNotes(projects, 'grace', { now })).toHaveLength(3);
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -41,3 +41,22 @@ export const paceText = (p) => {
|
|||||||
if (Math.abs(d) < 20) return 'On pace';
|
if (Math.abs(d) < 20) return 'On pace';
|
||||||
return d > 0 ? `${formatClock(d)} ahead` : `${formatClock(-d)} behind`;
|
return d > 0 ? `${formatClock(d)} ahead` : `${formatClock(-d)} behind`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// How the planned minutes compare with the time you have. `limit` is the session length (null/0 = not set).
|
||||||
|
// -> { state: 'unset' | 'under' | 'on' | 'over', diff (minutes, + to spare, - over), text }
|
||||||
|
export function planStatus(total, limit) {
|
||||||
|
const t = Number(total) || 0;
|
||||||
|
const l = Number(limit) || 0;
|
||||||
|
if (!l) return { state: 'unset', diff: 0, text: '' };
|
||||||
|
const diff = l - t;
|
||||||
|
if (t === 0) return { state: 'under', diff, text: `No minutes planned yet for ${l} min` };
|
||||||
|
if (diff === 0) return { state: 'on', diff, text: `${t} of ${l} min planned, right on time` };
|
||||||
|
if (diff > 0) return { state: 'under', diff, text: `${t} of ${l} min planned, ${diff} min to spare` };
|
||||||
|
return { state: 'over', diff, text: `${t} of ${l} min planned, ${-diff} min over` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// The first point that carries the plan past the session length, or null.
|
||||||
|
export function firstOverPoint(steps, limit) {
|
||||||
|
const l = Number(limit) || 0;
|
||||||
|
return l ? steps.find((s) => s.plannedEnd > l) ?? null : null;
|
||||||
|
}
|
||||||
@@ -28,3 +28,20 @@ test('pace: ahead before a point\'s window, behind after it, on pace inside it',
|
|||||||
expect(pacing(outlineSteps([n('x', 'No minutes')]), 0, 10)).toBeNull();
|
expect(pacing(outlineSteps([n('x', 'No minutes')]), 0, 10)).toBeNull();
|
||||||
expect(paceText(null)).toBe('');
|
expect(paceText(null)).toBe('');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
import { firstOverPoint, outlineSteps as stepsOf, planStatus } from './present.js';
|
||||||
|
|
||||||
|
test('the plan is compared with the session length', () => {
|
||||||
|
expect(planStatus(40, null).state).toBe('unset');
|
||||||
|
expect(planStatus(0, 45)).toMatchObject({ state: 'under' });
|
||||||
|
expect(planStatus(42, 45)).toEqual({ state: 'under', diff: 3, text: '42 of 45 min planned, 3 min to spare' });
|
||||||
|
expect(planStatus(45, 45).state).toBe('on');
|
||||||
|
expect(planStatus(52, 45)).toEqual({ state: 'over', diff: -7, text: '52 of 45 min planned, 7 min over' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the first point that runs past the session length is found', () => {
|
||||||
|
const tree = [{ id: 'a', text: 'A', minutes: 20, children: [] }, { id: 'b', text: 'B', minutes: 20, children: [] }, { id: 'c', text: 'C', minutes: 10, children: [] }];
|
||||||
|
expect(firstOverPoint(stepsOf(tree), 45).id).toBe('c');
|
||||||
|
expect(firstOverPoint(stepsOf(tree), 60)).toBeNull();
|
||||||
|
expect(firstOverPoint(stepsOf(tree), null)).toBeNull();
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
// Present mode on two screens: the presenter window sends what the audience may see to a second "audience"
|
||||||
|
// window (the one dragged to the projector) over a BroadcastChannel. Private things (a point's speaker notes, the
|
||||||
|
// script, planned minutes, the timer) are never put in the message, so the audience window cannot show them.
|
||||||
|
export const CHANNEL = 'bible-study-present';
|
||||||
|
export const AUDIENCE_PARAM = 'audience';
|
||||||
|
|
||||||
|
export const audienceUrl = (loc = window.location) => `${loc.origin}${loc.pathname}?${AUDIENCE_PARAM}=1`;
|
||||||
|
export const isAudienceWindow = (search = typeof window === 'undefined' ? '' : window.location.search) => new URLSearchParams(search).has(AUDIENCE_PARAM);
|
||||||
|
|
||||||
|
// null where the browser has no BroadcastChannel (then there is no audience window, only the one screen)
|
||||||
|
export function openChannel() {
|
||||||
|
try { return typeof BroadcastChannel === 'undefined' ? null : new BroadcastChannel(CHANNEL); } catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// The parts of an outline point that belong on the projector.
|
||||||
|
export function publicStep(step) {
|
||||||
|
if (!step) return null;
|
||||||
|
return {
|
||||||
|
label: step.label ?? '', kind: step.kind ?? '', text: step.text ?? '', ref: step.ref ?? '',
|
||||||
|
scripture: step.scripture?.text ? { text: step.scripture.text, ref: step.scripture.ref ?? '' } : null,
|
||||||
|
attachments: (step.attachments ?? []).map((a) => ({ type: a.type, label: a.label })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// The message the audience window draws from. In script mode the audience gets only the title (the script is for the
|
||||||
|
// speaker); `blank` blacks the projector out.
|
||||||
|
export function audienceState({ title, subtitle = '', mode, step, index = 0, total = 0, dark = true, fontPx = 40, blank = false }) {
|
||||||
|
return {
|
||||||
|
type: 'state', title, subtitle, mode, dark, fontPx, blank: !!blank, index, total,
|
||||||
|
step: mode === 'outline' ? publicStep(step) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { test, expect } from 'vitest';
|
||||||
|
import { audienceState, audienceUrl, isAudienceWindow, publicStep } from './presentSync.js';
|
||||||
|
|
||||||
|
const step = { id: 'a', label: 'I.', depth: 0, text: 'Welcome', ref: 'Rom 1:16', note: 'SECRET speaker note', minutes: 7, plannedEnd: 7, kind: 'quote',
|
||||||
|
scripture: { text: 'For I am not ashamed', ref: 'Romans 1:16', translation: 'BSB' }, attachments: [{ id: 'x', type: 'word', label: 'pistis', detail: 'PRIVATE detail' }] };
|
||||||
|
|
||||||
|
test('what the audience gets of a point never includes the private note, minutes or details', () => {
|
||||||
|
const pub = publicStep(step);
|
||||||
|
expect(pub).toEqual({ label: 'I.', kind: 'quote', text: 'Welcome', ref: 'Rom 1:16', scripture: { text: 'For I am not ashamed', ref: 'Romans 1:16' }, attachments: [{ type: 'word', label: 'pistis' }] });
|
||||||
|
const message = JSON.stringify(audienceState({ title: 'Romans', mode: 'outline', step, index: 0, total: 3 }));
|
||||||
|
expect(message).not.toMatch(/SECRET|PRIVATE|minutes|plannedEnd/);
|
||||||
|
expect(message).toContain('Welcome');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('in script mode the audience gets the title only, and blank blacks the screen out', () => {
|
||||||
|
const state = audienceState({ title: 'Romans 8', subtitle: 'Session 1', mode: 'script', step, blank: true });
|
||||||
|
expect(state.step).toBeNull();
|
||||||
|
expect(state.blank).toBe(true);
|
||||||
|
expect(JSON.stringify(state)).not.toMatch(/SECRET|Welcome/);
|
||||||
|
expect(publicStep(null)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the audience window is recognised by its address', () => {
|
||||||
|
expect(audienceUrl({ origin: 'https://study.test', pathname: '/' })).toBe('https://study.test/?audience=1');
|
||||||
|
expect(isAudienceWindow('?audience=1')).toBe(true);
|
||||||
|
expect(isAudienceWindow('?share=abc')).toBe(false);
|
||||||
|
expect(isAudienceWindow('')).toBe(false);
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
const FIELD_NAMES = {
|
const FIELD_NAMES = {
|
||||||
observation: 'Observe', interpretation: 'Interpret', application: 'Apply', generalNotes: 'Background', finalScript: 'Script',
|
observation: 'Observe', interpretation: 'Interpret', application: 'Apply', generalNotes: 'Background', finalScript: 'Script',
|
||||||
discussionQuestions: 'Questions', episodeNumber: 'Session number', episodeTitle: 'Session title',
|
discussionQuestions: 'Questions', episodeNumber: 'Session number', episodeTitle: 'Session title',
|
||||||
|
teachBack: 'Teach it back', attendees: 'Who came', groupNotes: 'Group notes',
|
||||||
};
|
};
|
||||||
const same = (a, b) => JSON.stringify(a ?? '') === JSON.stringify(b ?? '');
|
const same = (a, b) => JSON.stringify(a ?? '') === JSON.stringify(b ?? '');
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
// Slides for a session (or the whole study): a title slide, then for each session its passage, one slide per outline
|
||||||
|
// point (with the verse the point quotes) and the discussion questions. Built as a printable 16:9 page set ("Save as
|
||||||
|
// PDF" in the print dialog) from the same sessions, outlines and questions as the lesson handout.
|
||||||
|
import { getChunkVerseEntries } from './bibleUtils.jsx';
|
||||||
|
import { flatten } from './outline.js';
|
||||||
|
import { compactPassages } from './passageSummary.js';
|
||||||
|
import { buildChapterSummary } from './projectStorage.js';
|
||||||
|
import { outlineForSession, questionsFor, sessionsFor } from './handout.js';
|
||||||
|
|
||||||
|
const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||||
|
|
||||||
|
// -> [{ kind: 'title' | 'section' | 'passage' | 'point' | 'questions', ... }] in the order they are shown.
|
||||||
|
// ids: the chunk ids to include, or null for every session.
|
||||||
|
export function buildSlides(project, ids = null) {
|
||||||
|
const slides = [{ kind: 'title', title: project.title ?? '', subtitle: compactPassages(buildChapterSummary(project)) }];
|
||||||
|
for (const session of sessionsFor(project).filter((s) => !ids || ids.includes(s.chunk.id))) {
|
||||||
|
const { chunk, chapterIndex, number, reference } = session;
|
||||||
|
const name = (chunk.episodeTitle ?? '').trim();
|
||||||
|
slides.push({ kind: 'section', title: name || reference, subtitle: name ? reference : '', number: (chunk.episodeNumber ?? '').toString().trim() || number });
|
||||||
|
const verses = getChunkVerseEntries(project, chapterIndex, chunk);
|
||||||
|
if (verses.length) slides.push({ kind: 'passage', reference, translation: project.translation ?? 'BSB', verses: verses.map((v) => ({ number: v.number, text: v.text })) });
|
||||||
|
for (const { node, label, depth } of flatten(outlineForSession(project, chunk))) {
|
||||||
|
slides.push({ kind: 'point', label, depth, text: node.text ?? '', ref: node.ref ?? '', quote: node.scripture?.text ? { text: node.scripture.text, ref: node.scripture.ref ?? '' } : null });
|
||||||
|
}
|
||||||
|
const q = questionsFor(chunk);
|
||||||
|
if (q.items.length) slides.push({ kind: 'questions', title: q.kind === 'questions' ? 'Discussion' : 'Think about it', items: q.items });
|
||||||
|
}
|
||||||
|
return slides;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Longer text gets a smaller size so it stays on the slide.
|
||||||
|
const sizeFor = (chars, steps) => steps.find(([max]) => chars <= max)?.[1] ?? steps[steps.length - 1][1];
|
||||||
|
|
||||||
|
function slideHtml(s) {
|
||||||
|
if (s.kind === 'title') return `<section class="slide center"><h1>${esc(s.title)}</h1>${s.subtitle ? `<p class="sub">${esc(s.subtitle)}</p>` : ''}</section>`;
|
||||||
|
if (s.kind === 'section') return `<section class="slide center"><p class="kicker">Session ${esc(s.number)}</p><h1>${esc(s.title)}</h1>${s.subtitle ? `<p class="sub">${esc(s.subtitle)}</p>` : ''}</section>`;
|
||||||
|
if (s.kind === 'passage') {
|
||||||
|
const chars = s.verses.reduce((n, v) => n + v.text.length, 0);
|
||||||
|
const size = sizeFor(chars, [[300, 34], [550, 28], [900, 23], [1400, 19], [Infinity, 16]]);
|
||||||
|
return `<section class="slide"><p class="kicker">${esc(s.reference)} · ${esc(s.translation)}</p><div class="passage" style="font-size:${size}pt">${s.verses.map((v) => `<p><sup>${esc(v.number)}</sup> ${esc(v.text)}</p>`).join('')}</div></section>`;
|
||||||
|
}
|
||||||
|
if (s.kind === 'point') {
|
||||||
|
const chars = s.text.length + (s.quote?.text.length ?? 0);
|
||||||
|
const size = sizeFor(chars, [[80, 46], [160, 38], [300, 30], [Infinity, 24]]);
|
||||||
|
return `<section class="slide"><p class="kicker">${s.depth ? 'Point ' : ''}${esc(s.label)}</p><h2 style="font-size:${size}pt">${esc(s.text || '(untitled)')}</h2>${s.ref ? `<p class="sub">${esc(s.ref)}</p>` : ''}${s.quote ? `<blockquote style="font-size:${Math.max(16, size - 14)}pt">“${esc(s.quote.text)}”${s.quote.ref ? ` <span>— ${esc(s.quote.ref)}</span>` : ''}</blockquote>` : ''}</section>`;
|
||||||
|
}
|
||||||
|
return `<section class="slide"><p class="kicker">${esc(s.title)}</p><ol class="qs">${s.items.map((i) => `<li>${esc(i)}</li>`).join('')}</ol></section>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSlidesHtml(project, ids = null, { dark = false } = {}) {
|
||||||
|
const bg = dark ? '#16130f' : '#faf7f0';
|
||||||
|
const fg = dark ? '#f2efe8' : '#2b2620';
|
||||||
|
return `<!doctype html><html><head><meta charset="utf-8"><title>${esc(project.title)} — slides</title><style>
|
||||||
|
@page { size: 13.333in 7.5in; margin: 0; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { margin: 0; background: ${bg}; color: ${fg}; font-family: Georgia, 'Times New Roman', serif; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||||
|
.slide { width: 13.333in; height: 7.5in; padding: 0.7in 0.9in; page-break-after: always; break-after: page; display: flex; flex-direction: column; justify-content: center; overflow: hidden; background: ${bg}; }
|
||||||
|
.slide:last-child { page-break-after: auto; break-after: auto; }
|
||||||
|
.center { align-items: center; text-align: center; }
|
||||||
|
.kicker { margin: 0 0 .5em; font: 600 14pt -apple-system, Helvetica, Arial, sans-serif; letter-spacing: .16em; text-transform: uppercase; color: #a84e27; }
|
||||||
|
h1 { margin: 0; font-size: 54pt; font-weight: 600; line-height: 1.15; }
|
||||||
|
h2 { margin: 0; font-weight: 600; line-height: 1.25; }
|
||||||
|
.sub { margin: .6em 0 0; font: 20pt -apple-system, Helvetica, Arial, sans-serif; opacity: .7; }
|
||||||
|
.passage p { margin: .35em 0; line-height: 1.45; } sup { color: #a84e27; font: 60% -apple-system, Helvetica, Arial, sans-serif; }
|
||||||
|
blockquote { margin: .9em 0 0; padding-left: .8em; border-left: 6px solid #a84e27; font-style: italic; line-height: 1.4; } blockquote span { font-style: normal; opacity: .65; }
|
||||||
|
.qs { font-size: 30pt; line-height: 1.4; padding-left: 1.2em; margin: 0; } .qs li { margin: .5em 0; }
|
||||||
|
@media screen { body { padding: 1em; } .slide { margin: 0 auto 1em; box-shadow: 0 2px 12px rgba(0,0,0,.2); } }
|
||||||
|
</style></head><body>${buildSlides(project, ids).map(slideHtml).join('')}</body></html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function printSlides(project, ids = null, options) {
|
||||||
|
const win = window.open('', '_blank');
|
||||||
|
if (!win) return false;
|
||||||
|
win.document.write(buildSlidesHtml(project, ids, options));
|
||||||
|
win.document.close();
|
||||||
|
win.onload = () => { win.focus(); win.print(); };
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { test, expect } from 'vitest';
|
||||||
|
import { buildSlides, buildSlidesHtml } from './slides.js';
|
||||||
|
|
||||||
|
const project = () => ({
|
||||||
|
id: 'p', title: 'John <study>', translation: 'BSB', outline: [],
|
||||||
|
chapters: [{ book: 'John', bookAbbrev: 'JHN', chapter: '3', verses: [{ number: 16, text: 'For God so loved the world.' }, { number: 17, text: 'God sent not his Son to condemn.' }], chunks: [
|
||||||
|
{ id: 'a', startVerse: 16, endVerse: 17, episodeTitle: 'God so loved', discussionQuestions: 'Why did he give?\nWho is "the world"?',
|
||||||
|
outline: [{ id: 'n1', text: 'Love gives', children: [{ id: 'n2', text: 'He gave', scripture: { text: 'For God so loved', ref: 'John 3:16' }, children: [] }] }] },
|
||||||
|
{ id: 'b', startVerse: 17, endVerse: 17, application: 'Trust him.', outline: [] },
|
||||||
|
] }],
|
||||||
|
});
|
||||||
|
|
||||||
|
test('slides run title, then per session: section, passage, points, questions', () => {
|
||||||
|
const kinds = buildSlides(project()).map((s) => s.kind);
|
||||||
|
expect(kinds).toEqual(['title', 'section', 'passage', 'point', 'point', 'questions', 'section', 'passage', 'questions']);
|
||||||
|
const slides = buildSlides(project());
|
||||||
|
expect(slides[1]).toMatchObject({ title: 'God so loved', subtitle: 'John 3:16–17' });
|
||||||
|
expect(slides[4]).toMatchObject({ depth: 1, text: 'He gave', quote: { ref: 'John 3:16' } });
|
||||||
|
expect(slides[8]).toMatchObject({ title: 'Think about it', items: ['Trust him.'] }); // Application notes stand in for questions
|
||||||
|
});
|
||||||
|
|
||||||
|
test('one session only', () => {
|
||||||
|
const kinds = buildSlides(project(), ['b']).map((s) => s.kind);
|
||||||
|
expect(kinds).toEqual(['title', 'section', 'passage', 'questions']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the printable page has a 16:9 slide per entry, escapes text, and can be dark', () => {
|
||||||
|
const html = buildSlidesHtml(project());
|
||||||
|
expect(html.match(/class="slide/g)).toHaveLength(9);
|
||||||
|
expect(html).toContain('John <study>');
|
||||||
|
expect(html).toContain('size: 13.333in 7.5in');
|
||||||
|
expect(html).not.toContain('<study>');
|
||||||
|
expect(buildSlidesHtml(project(), null, { dark: true })).toContain('#16130f');
|
||||||
|
});
|
||||||
@@ -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