Add own-work tracking, Claude hand-off check, and weekly recall
- Long pastes into Observe/Interpret/Apply are tagged as pasted; notes show Mine / N pasted and an own-words share. Pastes follow text through merges. - The "-> Claude" buttons on the Script stage ask first when a chunk lacks enough of the user's own (non-pasted) words. - Weekly recall card on Home: write a passage from memory, compare with notes, self-rate; recalls are saved on the chunk and synced. - Recall history on Home with an eight-week strip and the full list. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
edc5a65d2b
commit
0c8a632cff
11 files changed
+429
-5
No files matched your search
@@ -0,0 +1,47 @@
|
|||||||
|
// Home: how your weekly recalls have gone — the last eight weeks at a glance, then every recall you've written.
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useApp } from '../context/AppContext.js';
|
||||||
|
import { loadProjectById } from '../utils/projectStorage.js';
|
||||||
|
import { recallHistory, recallSummary } from '../utils/recall.js';
|
||||||
|
|
||||||
|
const LABEL = { solid: 'Had it', partly: 'Partly', forgot: 'Slipped' };
|
||||||
|
const DOT = { solid: 'bg-[var(--r-accent)] border-[var(--r-accent)]', partly: 'bg-[var(--r-hl)] border-[var(--r-accent)]', forgot: 'border-[var(--r-sub)]' };
|
||||||
|
|
||||||
|
export default function RecallHistory() {
|
||||||
|
const { projectIndex, openStudyChunk } = useApp();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const history = useMemo(() => recallHistory(projectIndex.map((e) => loadProjectById(e.id)).filter(Boolean)), [projectIndex]);
|
||||||
|
if (history.length === 0) return null;
|
||||||
|
const { counts, total, recent } = recallSummary(history);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-label="Recall history" className="mb-4 rounded-xl border border-[var(--r-line)] bg-[var(--r-bar)] px-4 py-3 text-sm">
|
||||||
|
<button type="button" onClick={() => setOpen((o) => !o)} aria-expanded={open} className="flex w-full flex-wrap items-center justify-between gap-3 text-left">
|
||||||
|
<span><span className="font-medium">Recall history</span> <span className="text-xs text-[var(--r-sub)]">{total} recall{total === 1 ? '' : 's'} · {counts.solid} had it · {counts.partly} partly · {counts.forgot} slipped</span></span>
|
||||||
|
<span className="flex items-center gap-1" aria-label="Last eight weeks">
|
||||||
|
{recent.map((w) => (
|
||||||
|
<span key={w.start} title={w.rating ? LABEL[w.rating] : 'No recall that week'}
|
||||||
|
className={`h-3 w-3 rounded-full border ${w.rating ? DOT[w.rating] : 'border-[var(--r-line)]'}`} />
|
||||||
|
))}
|
||||||
|
<span className="ml-1 text-xs text-[var(--r-sub)]">{open ? '▾' : '▸'}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<ul className="mt-3 space-y-2.5">
|
||||||
|
{history.map((h) => (
|
||||||
|
<li key={`${h.chunkId}-${h.at}`} className="rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] p-3">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2 text-xs">
|
||||||
|
<span><span className="font-medium text-sm">{h.reference}</span> <span className="text-[var(--r-sub)]">· {h.projectTitle} · {new Date(h.at).toLocaleDateString()}</span></span>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="rounded-full border border-[var(--r-line)] px-2 py-0.5">{LABEL[h.rating] ?? '—'}</span>
|
||||||
|
<button type="button" onClick={() => openStudyChunk(h.projectId, h.chunkId)} className="text-[var(--r-accent)] hover:underline">Open</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{h.text && <p className="mt-1.5 line-clamp-3 whitespace-pre-line font-serif text-[13px] leading-relaxed text-[var(--r-sub)]">{h.text}</p>}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// Home card: once a week, write what you remember of a passage you studied, then compare it with your notes.
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useApp } from '../context/AppContext.js';
|
||||||
|
import { loadProjectById } from '../utils/projectStorage.js';
|
||||||
|
import { pickRecall } from '../utils/recall.js';
|
||||||
|
|
||||||
|
const RATE = [
|
||||||
|
{ id: 'solid', label: 'I had it' },
|
||||||
|
{ id: 'partly', label: 'Partly' },
|
||||||
|
{ id: 'forgot', label: 'Most of it slipped' },
|
||||||
|
];
|
||||||
|
const box = 'mb-4 rounded-xl border border-[var(--r-line)] bg-[var(--r-bar)] px-4 py-3 text-sm';
|
||||||
|
const noteLabels = [['observation', 'Observe'], ['interpretation', 'Interpret'], ['application', 'Apply']];
|
||||||
|
|
||||||
|
export default function WeeklyRecall() {
|
||||||
|
const { projectIndex, saveChunkRecall, openStudyChunk } = useApp();
|
||||||
|
const [step, setStep] = useState('card'); // card -> write -> compare -> done
|
||||||
|
const [text, setText] = useState('');
|
||||||
|
const pick = useMemo(
|
||||||
|
() => pickRecall(projectIndex.map((e) => loadProjectById(e.id)).filter(Boolean)),
|
||||||
|
[projectIndex],
|
||||||
|
);
|
||||||
|
if (step === 'done') return <div role="status" className={box}>Recall saved. See you next week.</div>;
|
||||||
|
if (!pick) return null;
|
||||||
|
|
||||||
|
const finish = (rating) => {
|
||||||
|
saveChunkRecall(pick.projectId, pick.chunk.id, { at: Date.now(), text: text.trim(), rating });
|
||||||
|
setStep('done');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (step === 'card') {
|
||||||
|
return (
|
||||||
|
<div className={`${box} flex flex-wrap items-center justify-between gap-3`}>
|
||||||
|
<span><span className="font-medium">Weekly recall:</span> {pick.reference} <span className="text-[var(--r-sub)]">· {pick.projectTitle}</span></span>
|
||||||
|
<button type="button" onClick={() => 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="Weekly recall" className={box}>
|
||||||
|
<p className="font-medium">{pick.reference} <span className="font-normal text-[var(--r-sub)]">· {pick.projectTitle}</span></p>
|
||||||
|
{step === 'write' ? (
|
||||||
|
<>
|
||||||
|
<p className="mt-0.5 text-xs text-[var(--r-sub)]">From memory, no peeking: what did this passage say, what did it mean, and what did it ask of you?</p>
|
||||||
|
<textarea aria-label="What you remember" value={text} onChange={(e) => setText(e.target.value)} rows={6}
|
||||||
|
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" />
|
||||||
|
<div className="mt-2 flex gap-2">
|
||||||
|
<button type="button" disabled={!text.trim()} onClick={() => setStep('compare')} className="rounded-full bg-[var(--r-text)] px-3 py-1 text-xs font-medium text-[var(--r-bg)] disabled:opacity-40">Show my notes</button>
|
||||||
|
<button type="button" onClick={() => { setText(''); setStep('card'); }} className="text-xs text-[var(--r-sub)] hover:underline">Not now</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="mt-2 grid gap-3 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<p className="mb-1 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">What you remembered</p>
|
||||||
|
<p className="whitespace-pre-line font-serif text-[13px] leading-relaxed">{text}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="mb-1 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Your notes</p>
|
||||||
|
{noteLabels.map(([f, label]) => pick.chunk[f]?.trim() && (
|
||||||
|
<p key={f} className="mb-1.5 whitespace-pre-line font-serif text-[13px] leading-relaxed"><span className="font-sans text-xs font-medium">{label}: </span>{pick.chunk[f]}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-xs text-[var(--r-sub)]">How did you do?</p>
|
||||||
|
<div className="mt-1 flex flex-wrap gap-2">
|
||||||
|
{RATE.map((r) => (
|
||||||
|
<button key={r.id} type="button" onClick={() => finish(r.id)} className="rounded-full border border-[var(--r-text)] px-3 py-1 text-xs hover:bg-black/5">{r.label}</button>
|
||||||
|
))}
|
||||||
|
<button type="button" onClick={() => openStudyChunk(pick.projectId, pick.chunk.id)} className="text-xs text-[var(--r-accent)] hover:underline">Open this chunk to review</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
// 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, useState } from 'react';
|
||||||
import DictateButton, { appendSpoken } from '../DictateButton.jsx';
|
import DictateButton, { appendSpoken } from '../DictateButton.jsx';
|
||||||
|
import { ownShare, ownWords, pastedWords, withPaste, clearPastes } from '../../utils/ownWork.js';
|
||||||
import { templateFromChunk, templatePrompt } from '../../utils/studyTemplates.js';
|
import { templateFromChunk, templatePrompt } from '../../utils/studyTemplates.js';
|
||||||
|
|
||||||
const SECTIONS = [
|
const SECTIONS = [
|
||||||
@@ -36,11 +37,23 @@ 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 keepPaste = (field) => (e) => {
|
||||||
|
const next = withPaste(chunk, field, e.clipboardData?.getData('text'));
|
||||||
|
if (next) updateChunk(chunk.id, { aiPastes: next });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section aria-label="Study notes" className="space-y-2">
|
<section aria-label="Study notes" className="space-y-2">
|
||||||
|
{share !== null && (
|
||||||
|
<p className="px-1 text-xs text-[var(--r-sub)]" title="Long pastes into a note box count as pasted, not your own">
|
||||||
|
{share === 100 ? 'All of these notes are your own words.' : `${share}% of these notes are your own words.`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{SECTIONS.map(({ field, label, hint, placeholder }) => {
|
{SECTIONS.map(({ field, label, hint, placeholder }) => {
|
||||||
const isOpen = open === field;
|
const isOpen = open === field;
|
||||||
const n = words(chunk[field]);
|
const n = words(chunk[field]);
|
||||||
|
const pasted = pastedWords(chunk, field);
|
||||||
return (
|
return (
|
||||||
<div key={field} className={`overflow-hidden rounded-2xl border ${isOpen ? 'border-[var(--r-accent)]' : 'border-[var(--r-line)]'}`}>
|
<div key={field} className={`overflow-hidden rounded-2xl border ${isOpen ? 'border-[var(--r-accent)]' : 'border-[var(--r-line)]'}`}>
|
||||||
<button type="button" onClick={() => setOpen(isOpen ? null : field)} aria-expanded={isOpen}
|
<button type="button" onClick={() => setOpen(isOpen ? null : field)} aria-expanded={isOpen}
|
||||||
@@ -50,12 +63,23 @@ export default function NotesPane({ chunk, updateChunk, allTags, templateId, sav
|
|||||||
<span className="text-sm font-medium">{label}</span>
|
<span className="text-sm font-medium">{label}</span>
|
||||||
<span className="text-xs text-[var(--r-sub)]">{hint}</span>
|
<span className="text-xs text-[var(--r-sub)]">{hint}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-[var(--r-sub)]">{n ? `${n} word${n === 1 ? '' : 's'}` : ''}</span>
|
<span className="flex items-center gap-2 text-xs text-[var(--r-sub)]">
|
||||||
|
{n > 0 && (pasted > 0
|
||||||
|
? <span className="rounded-full bg-[var(--r-hl)] px-2 py-0.5 text-[var(--r-text)]">{ownWords(chunk, field) === 0 ? 'Pasted' : `${pasted} pasted`}</span>
|
||||||
|
: <span className="rounded-full border border-[var(--r-line)] px-2 py-0.5">Mine</span>)}
|
||||||
|
{n ? `${n} word${n === 1 ? '' : 's'}` : ''}
|
||||||
|
</span>
|
||||||
</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 aria-label={label} value={chunk[field] ?? ''} rows={7} placeholder={templatePrompt(templateId, field) ?? placeholder}
|
||||||
onChange={(e) => updateChunk(chunk.id, { [field]: e.target.value })} className={areaCls} />
|
onPaste={keepPaste(field)} onChange={(e) => updateChunk(chunk.id, { [field]: e.target.value })} className={areaCls} />
|
||||||
|
{pasted > 0 && (
|
||||||
|
<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.{' '}
|
||||||
|
<button type="button" onClick={() => updateChunk(chunk.id, { aiPastes: clearPastes(chunk, field) })} className="text-[var(--r-accent)] hover:underline">That’s mine</button>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<div className="mt-1.5"><DictateButton label={`Dictate ${label.toLowerCase()}`} onText={(said) => updateChunk(chunk.id, { [field]: appendSpoken(chunk[field], said) })} /></div>
|
<div className="mt-1.5"><DictateButton label={`Dictate ${label.toLowerCase()}`} onText={(said) => updateChunk(chunk.id, { [field]: appendSpoken(chunk[field], said) })} /></div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -221,6 +221,32 @@ export function useProjectLibrary({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Saves a weekly recall (what you remembered, and how it went) on one chunk of a stored study.
|
||||||
|
const saveChunkRecall = (id, chunkId, recall) => {
|
||||||
|
const loaded = loadProjectById(id);
|
||||||
|
if (!loaded) return;
|
||||||
|
const updated = {
|
||||||
|
...loaded,
|
||||||
|
lastEdited: Date.now(),
|
||||||
|
chapters: loaded.chapters.map((ch) => ({
|
||||||
|
...ch,
|
||||||
|
chunks: ch.chunks.map((c) => (c.id === chunkId ? { ...c, recalls: [...(c.recalls ?? []), recall] } : c)),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
saveProjectToStorage(updated);
|
||||||
|
unsyncedStudies.add(id);
|
||||||
|
setProjectIndex(loadProjectIndex());
|
||||||
|
if (project?.id === id) setProject(updated);
|
||||||
|
saveRemoteProject(updated, { baseEdited: getSyncBase(id) }).then((result) => {
|
||||||
|
if (result.ok) {
|
||||||
|
const serverEdited = result.data?.lastEdited ?? updated.lastEdited;
|
||||||
|
setSyncBase(id, serverEdited);
|
||||||
|
unsyncedStudies.remove(id);
|
||||||
|
try { saveProjectToStorage({ ...updated, lastEdited: serverEdited }); setProjectIndex(loadProjectIndex()); } catch { /* already saved */ }
|
||||||
|
} else if (result.status === 409) toast('That study changed on another device. Open it to choose which version to keep.', { tone: 'error' });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// 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) => {
|
||||||
@@ -287,6 +313,7 @@ export function useProjectLibrary({
|
|||||||
openStudyChunk,
|
openStudyChunk,
|
||||||
renameProjectInStorage,
|
renameProjectInStorage,
|
||||||
setProjectSeries,
|
setProjectSeries,
|
||||||
|
saveChunkRecall,
|
||||||
deleteProject,
|
deleteProject,
|
||||||
pullLatestFromServer,
|
pullLatestFromServer,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { useEffect, 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 WeeklyRecall from '../components/WeeklyRecall.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';
|
||||||
import PageShell from '../components/study/PageShell.jsx';
|
import PageShell from '../components/study/PageShell.jsx';
|
||||||
@@ -135,6 +137,8 @@ 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">
|
||||||
|
<WeeklyRecall />
|
||||||
|
<RecallHistory />
|
||||||
<IqDailyCard />
|
<IqDailyCard />
|
||||||
{autoRestoredCount !== null && (
|
{autoRestoredCount !== null && (
|
||||||
<div className={notice}>Synced {autoRestoredCount} project{autoRestoredCount > 1 ? 's' : ''} from another device.</div>
|
<div className={notice}>Synced {autoRestoredCount} project{autoRestoredCount > 1 ? 's' : ''} from another device.</div>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useToast } from '../components/Toasts.jsx';
|
|||||||
import StudioShell from '../components/study/StudioShell.jsx';
|
import StudioShell from '../components/study/StudioShell.jsx';
|
||||||
import { formatChunkReference } from '../utils/bibleUtils.jsx';
|
import { formatChunkReference } from '../utils/bibleUtils.jsx';
|
||||||
import { buildClaudePrompt, buildPodcastPrompt } from '../utils/exportBuilders.js';
|
import { buildClaudePrompt, buildPodcastPrompt } from '../utils/exportBuilders.js';
|
||||||
|
import { chunksNeedingWork } from '../utils/ownWork.js';
|
||||||
import { buildScriptDraft, spokenMinutes, wordCount } from '../utils/scriptDraft.js';
|
import { buildScriptDraft, spokenMinutes, wordCount } from '../utils/scriptDraft.js';
|
||||||
|
|
||||||
const has = (s) => (s ?? '').trim().length > 0;
|
const has = (s) => (s ?? '').trim().length > 0;
|
||||||
@@ -23,6 +24,7 @@ export default function ScriptPage() {
|
|||||||
const iq = useIqAvailable();
|
const iq = useIqAvailable();
|
||||||
const [gathering, setGathering] = useState(false);
|
const [gathering, setGathering] = useState(false);
|
||||||
const [presenting, setPresenting] = useState(false);
|
const [presenting, setPresenting] = useState(false);
|
||||||
|
const [heldAction, setHeldAction] = useState(null); // an AI hand-off waiting on the own-work check
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const chunk = allChunks.find((c) => c.id === project?.selectedChunkId) ?? allChunks[0] ?? null;
|
const chunk = allChunks.find((c) => c.id === project?.selectedChunkId) ?? allChunks[0] ?? null;
|
||||||
@@ -54,6 +56,11 @@ export default function ScriptPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// AI hand-offs only go straight through once every chunk has your own notes; otherwise ask first.
|
||||||
|
const needingWork = chunksNeedingWork(allChunks);
|
||||||
|
const guarded = (action) => () => { if (needingWork.length === 0) action(); else setHeldAction(() => action); };
|
||||||
|
const goStudy = (id) => { select(id); setHeldAction(null); setCurrentPage('study'); };
|
||||||
|
|
||||||
// Copies a prompt, then opens claude.ai so it can be pasted straight in.
|
// Copies a prompt, then opens claude.ai so it can be pasted straight in.
|
||||||
const copyPrompt = (prompt) => {
|
const copyPrompt = (prompt) => {
|
||||||
navigator.clipboard.writeText(prompt).then(() => {
|
navigator.clipboard.writeText(prompt).then(() => {
|
||||||
@@ -130,12 +137,12 @@ export default function ScriptPage() {
|
|||||||
<input type="file" accept=".docx" className="hidden" onChange={(e) => { importDocx(e.target.files?.[0]); e.target.value = ''; }} />
|
<input type="file" accept=".docx" className="hidden" onChange={(e) => { importDocx(e.target.files?.[0]); e.target.value = ''; }} />
|
||||||
</label>
|
</label>
|
||||||
<span className="mx-1 h-4 w-px bg-[var(--r-line)]" />
|
<span className="mx-1 h-4 w-px bg-[var(--r-line)]" />
|
||||||
<button type="button" onClick={() => copyPrompt(buildClaudePrompt(project))} className={btn}
|
<button type="button" onClick={guarded(() => copyPrompt(buildClaudePrompt(project)))} className={btn}
|
||||||
title="Copies your study notes as a Claude prompt, then opens claude.ai">Study guide → Claude ↗</button>
|
title="Copies your study notes as a Claude prompt, then opens claude.ai">Study guide → Claude ↗</button>
|
||||||
<button type="button" onClick={() => copyPrompt(buildPodcastPrompt(project, authUser?.podcastName))} className={btn}
|
<button type="button" onClick={guarded(() => copyPrompt(buildPodcastPrompt(project, authUser?.podcastName)))} className={btn}
|
||||||
title="Copies a podcast script prompt for Claude — set your show name in Account Settings first">Podcast → Claude ↗</button>
|
title="Copies a podcast script prompt for Claude — set your show name in Account Settings first">Podcast → Claude ↗</button>
|
||||||
{iq && (
|
{iq && (
|
||||||
<button type="button" disabled={gathering} onClick={copyPodcastWithContext} className={btn}
|
<button type="button" disabled={gathering} onClick={guarded(copyPodcastWithContext)} className={btn}
|
||||||
title={`Adds IQ Bible commentary, cross-references and word glosses for up to ${chunkRefs(project).refs.length} passages (each uses a few API requests)`}>
|
title={`Adds IQ Bible commentary, cross-references and word glosses for up to ${chunkRefs(project).refs.length} passages (each uses a few API requests)`}>
|
||||||
{gathering ? 'Gathering…' : 'Podcast + background → Claude ↗'}
|
{gathering ? 'Gathering…' : 'Podcast + background → Claude ↗'}
|
||||||
</button>
|
</button>
|
||||||
@@ -145,6 +152,25 @@ export default function ScriptPage() {
|
|||||||
<DictateButton label="Dictate script" onText={(said) => set({ finalScript: appendSpoken(script, said) })} />
|
<DictateButton label="Dictate script" onText={(said) => set({ finalScript: appendSpoken(script, said) })} />
|
||||||
{notice && <span role="status" className="text-xs text-[var(--r-accent)]">{notice}</span>}
|
{notice && <span role="status" className="text-xs text-[var(--r-accent)]">{notice}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
{heldAction && (
|
||||||
|
<div role="alert" className="rounded-xl border border-[var(--r-accent)] bg-[var(--r-hl)] p-3 text-sm">
|
||||||
|
<p className="font-medium">Is this your study, or Claude's?</p>
|
||||||
|
<p className="mt-0.5 text-xs text-[var(--r-sub)]">These chunks don't have much of your own thinking yet. Write it first, then let Claude polish it.</p>
|
||||||
|
<ul className="mt-2 space-y-1 text-xs">
|
||||||
|
{needingWork.map(({ chunk: c, missing }) => (
|
||||||
|
<li key={c.id} className="flex items-center justify-between gap-3">
|
||||||
|
<span>{formatChunkReference(project, project.chapters.findIndex((ch) => ch.chunks.some((x) => x.id === c.id)), c, '–')}: {missing.map((m) => `${m.label} ${m.have}/${m.need}`).join(', ')}</span>
|
||||||
|
<button type="button" onClick={() => goStudy(c.id)} className="text-[var(--r-accent)] hover:underline">Write it →</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="mt-2.5 flex gap-2">
|
||||||
|
<button type="button" onClick={() => goStudy(needingWork[0].chunk.id)} className="rounded-full bg-[var(--r-text)] px-3 py-1 text-xs font-medium text-[var(--r-bg)]">Go write my notes</button>
|
||||||
|
<button type="button" onClick={() => { const run = heldAction; setHeldAction(null); run(); }} className="rounded-full border border-[var(--r-text)] px-3 py-1 text-xs hover:bg-black/5">Send to Claude anyway</button>
|
||||||
|
<button type="button" onClick={() => setHeldAction(null)} className="text-xs text-[var(--r-sub)] hover:underline">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<textarea aria-label="Final script" value={script} onChange={(e) => set({ finalScript: e.target.value })}
|
<textarea aria-label="Final script" value={script} onChange={(e) => set({ finalScript: e.target.value })}
|
||||||
placeholder="Write or paste the final script here. “Build outline from notes” starts you from your study notes."
|
placeholder="Write or paste the final script here. “Build outline from notes” starts you from your study notes."
|
||||||
className="min-h-[24rem] flex-1 resize-y rounded-2xl border border-[var(--r-line)] bg-[var(--r-bg)] px-5 py-4 font-serif text-[16px] leading-8 text-[var(--r-text)] placeholder:font-sans placeholder:text-sm placeholder:text-[var(--r-sub)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]" />
|
className="min-h-[24rem] flex-1 resize-y rounded-2xl border border-[var(--r-line)] bg-[var(--r-bg)] px-5 py-4 font-serif text-[16px] leading-8 text-[var(--r-text)] placeholder:font-sans placeholder:text-sm placeholder:text-[var(--r-sub)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]" />
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ export function mergeChunk(mine, theirs) {
|
|||||||
const out = { ...older, ...newer };
|
const out = { ...older, ...newer };
|
||||||
TEXT_FIELDS.forEach((f) => { out[f] = mergeText(newer[f], older[f]); });
|
TEXT_FIELDS.forEach((f) => { out[f] = mergeText(newer[f], older[f]); });
|
||||||
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.
|
||||||
|
out.aiPastes = Object.fromEntries(['observation', 'interpretation', 'application'].map((f) => [f, union(newer.aiPastes?.[f], older.aiPastes?.[f])]));
|
||||||
|
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) };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// "Is this study my own work?" — how much of each chunk's Observe / Interpret /
|
||||||
|
// Apply notes has been written by hand (long pastes don't count as your own). Used to hold back the "→ Claude" prompts
|
||||||
|
// until the thinking has been done by you, not handed to AI.
|
||||||
|
export const OWN_WORK_MINIMUMS = { observation: 20, interpretation: 20, application: 10 };
|
||||||
|
const LABELS = { observation: 'Observe', interpretation: 'Interpret', application: 'Apply' };
|
||||||
|
|
||||||
|
const words = (s) => (s ?? '').trim().split(/\s+/).filter(Boolean).length;
|
||||||
|
|
||||||
|
// A paste this long into a note box is remembered as "not typed by me" (chunk.aiPastes[field] = [text, …]).
|
||||||
|
export const PASTE_MIN_WORDS = 15;
|
||||||
|
export const NOTE_FIELDS = Object.keys(OWN_WORK_MINIMUMS);
|
||||||
|
|
||||||
|
// -> the chunk's new aiPastes after pasting `pasted` into `field`, or null when the paste is too short to count.
|
||||||
|
export function withPaste(chunk, field, pasted) {
|
||||||
|
const text = (pasted ?? '').trim();
|
||||||
|
if (words(text) < PASTE_MIN_WORDS) return null;
|
||||||
|
const all = chunk?.aiPastes ?? {};
|
||||||
|
return { ...all, [field]: [...(all[field] ?? []), text] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Words in `field` that came from a long paste still present in the note (delete or rewrite it and it stops counting).
|
||||||
|
export function pastedWords(chunk, field) {
|
||||||
|
const body = chunk?.[field] ?? '';
|
||||||
|
return (chunk?.aiPastes?.[field] ?? []).filter((t) => body.includes(t)).reduce((n, t) => n + words(t), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ownWords = (chunk, field) => Math.max(0, words(chunk?.[field]) - pastedWords(chunk, field));
|
||||||
|
|
||||||
|
// Share of the three notes' words that are typed or dictated rather than pasted, 0–100 (null when nothing is written).
|
||||||
|
export function ownShare(chunk) {
|
||||||
|
const total = NOTE_FIELDS.reduce((n, f) => n + words(chunk?.[f]), 0);
|
||||||
|
if (!total) return null;
|
||||||
|
return Math.round((NOTE_FIELDS.reduce((n, f) => n + ownWords(chunk, f), 0) / total) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// "That's mine": stop counting the pastes in one field.
|
||||||
|
export const clearPastes = (chunk, field) => ({ ...(chunk?.aiPastes ?? {}), [field]: [] });
|
||||||
|
|
||||||
|
// The note fields of one chunk that are still under their minimum word count.
|
||||||
|
export function thinFields(chunk) {
|
||||||
|
return Object.entries(OWN_WORK_MINIMUMS)
|
||||||
|
.filter(([field, min]) => ownWords(chunk, field) < min)
|
||||||
|
.map(([field]) => ({ field, label: LABELS[field], have: ownWords(chunk, field), need: OWN_WORK_MINIMUMS[field] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chunks that still need your own notes, with what's missing in each.
|
||||||
|
export function chunksNeedingWork(chunks) {
|
||||||
|
return (chunks ?? []).map((chunk) => ({ chunk, missing: thinFields(chunk) })).filter((entry) => entry.missing.length > 0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { test, expect } from 'vitest';
|
||||||
|
import { thinFields, chunksNeedingWork } from './ownWork.js';
|
||||||
|
|
||||||
|
const text = (n) => Array.from({ length: n }, (_, i) => `w${i}`).join(' ');
|
||||||
|
|
||||||
|
test('a field counts as done once it reaches its minimum word count', () => {
|
||||||
|
const chunk = { observation: text(20), interpretation: text(19), application: text(10) };
|
||||||
|
expect(thinFields(chunk).map((f) => f.field)).toEqual(['interpretation']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty or missing notes are all flagged', () => {
|
||||||
|
expect(thinFields({}).map((f) => f.label)).toEqual(['Observe', 'Interpret', 'Apply']);
|
||||||
|
expect(thinFields(null)).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('only chunks with something missing are returned', () => {
|
||||||
|
const done = { id: 'a', observation: text(25), interpretation: text(25), application: text(12) };
|
||||||
|
const thin = { id: 'b', observation: text(25), interpretation: '', application: text(12) };
|
||||||
|
const result = chunksNeedingWork([done, thin]);
|
||||||
|
expect(result.map((r) => r.chunk.id)).toEqual(['b']);
|
||||||
|
expect(result[0].missing[0]).toMatchObject({ label: 'Interpret', have: 0, need: 20 });
|
||||||
|
});
|
||||||
|
|
||||||
|
import { withPaste, pastedWords, ownWords, ownShare, clearPastes } from './ownWork.js';
|
||||||
|
import { mergeChunk } from './mergeStudies.js';
|
||||||
|
|
||||||
|
test('only long pastes are remembered', () => {
|
||||||
|
expect(withPaste({}, 'observation', text(5))).toBeNull();
|
||||||
|
expect(withPaste({}, 'observation', text(15))).toEqual({ observation: [text(15)] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pasted text stops counting once it is removed from the note', () => {
|
||||||
|
const pasted = text(30);
|
||||||
|
const chunk = { observation: `my own thought ${pasted}`, aiPastes: { observation: [pasted] } };
|
||||||
|
expect(pastedWords(chunk, 'observation')).toBe(30);
|
||||||
|
expect(ownWords(chunk, 'observation')).toBe(3);
|
||||||
|
expect(ownWords({ ...chunk, observation: 'now I wrote this myself' }, 'observation')).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pasted words do not satisfy the own-work minimum', () => {
|
||||||
|
const pasted = text(40);
|
||||||
|
const chunk = { observation: pasted, interpretation: text(25), application: text(12), aiPastes: { observation: [pasted] } };
|
||||||
|
expect(thinFields(chunk).map((f) => f.field)).toEqual(['observation']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the own-words share and "That\'s mine"', () => {
|
||||||
|
const pasted = text(30);
|
||||||
|
const chunk = { observation: `${text(10)} ${pasted}`, aiPastes: { observation: [pasted] } };
|
||||||
|
expect(ownShare(chunk)).toBe(25);
|
||||||
|
expect(ownShare({})).toBeNull();
|
||||||
|
expect(ownShare({ ...chunk, aiPastes: clearPastes(chunk, 'observation') })).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('merging two copies keeps the pastes from both', () => {
|
||||||
|
const a = { id: 'x', observation: 'a', editedAt: 2, aiPastes: { observation: ['p1'] } };
|
||||||
|
const b = { id: 'x', observation: 'b', editedAt: 1, aiPastes: { observation: ['p2'] } };
|
||||||
|
expect(mergeChunk(a, b).chunk.aiPastes.observation).toEqual(['p1', 'p2']);
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// Weekly recall check: once a week, pick a passage you studied a while ago and ask you to write what you
|
||||||
|
// remember of it before seeing your notes. Recalls are kept on the chunk (chunk.recalls = [{ at, text, rating }]).
|
||||||
|
import { formatChunkReference } from './bibleUtils.jsx';
|
||||||
|
import { NOTE_FIELDS, ownWords } from './ownWork.js';
|
||||||
|
|
||||||
|
export const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
export const MIN_OWN_WORDS = 30; // a chunk needs this much of your own writing to be worth testing
|
||||||
|
export const RATINGS = ['solid', 'partly', 'forgot'];
|
||||||
|
|
||||||
|
const lastRecallAt = (chunk) => Math.max(0, ...(chunk.recalls ?? []).map((r) => r.at ?? 0));
|
||||||
|
const ownTotal = (chunk) => NOTE_FIELDS.reduce((n, f) => n + ownWords(chunk, f), 0);
|
||||||
|
|
||||||
|
// -> the passage to recall this week, or null when you already did one in the last 7 days or nothing qualifies.
|
||||||
|
// `projects` are full study projects. Studied at least a week ago, never-recalled first, then the longest ago.
|
||||||
|
export function pickRecall(projects, now = Date.now()) {
|
||||||
|
const all = (projects ?? []).flatMap((project) => (project?.chapters ?? []).flatMap((chapter, chapterIndex) => (chapter.chunks ?? []).map((chunk) => ({ project, chapterIndex, chunk }))));
|
||||||
|
if (all.some(({ chunk }) => lastRecallAt(chunk) > 0 && now - lastRecallAt(chunk) < WEEK_MS)) return null;
|
||||||
|
const [best] = all
|
||||||
|
.filter(({ chunk }) => ownTotal(chunk) >= MIN_OWN_WORDS && now - (chunk.editedAt ?? 0) >= WEEK_MS)
|
||||||
|
.sort((a, b) => lastRecallAt(a.chunk) - lastRecallAt(b.chunk) || (a.chunk.editedAt ?? 0) - (b.chunk.editedAt ?? 0));
|
||||||
|
if (!best) return null;
|
||||||
|
return {
|
||||||
|
projectId: best.project.id, projectTitle: best.project.title, chunk: best.chunk,
|
||||||
|
reference: formatChunkReference(best.project, best.chapterIndex, best.chunk, '–'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every recall ever saved, newest first: [{ at, rating, text, reference, projectId, projectTitle, chunkId }].
|
||||||
|
export function recallHistory(projects) {
|
||||||
|
return (projects ?? []).flatMap((project) => (project?.chapters ?? []).flatMap((chapter, chapterIndex) => (chapter.chunks ?? []).flatMap((chunk) => (
|
||||||
|
(chunk.recalls ?? []).map((r) => ({
|
||||||
|
at: r.at ?? 0, rating: r.rating, text: r.text ?? '', chunkId: chunk.id,
|
||||||
|
projectId: project.id, projectTitle: project.title, reference: formatChunkReference(project, chapterIndex, chunk, '–'),
|
||||||
|
}))
|
||||||
|
)))).sort((a, b) => b.at - a.at);
|
||||||
|
}
|
||||||
|
|
||||||
|
// How the recalls went: counts per rating, plus the last `weeks` weeks (oldest first) as { start, rating|null }
|
||||||
|
// where rating is the weakest one that week (so a week isn't flattered by one good recall).
|
||||||
|
export function recallSummary(history, now = Date.now(), weeks = 8) {
|
||||||
|
const counts = { solid: 0, partly: 0, forgot: 0 };
|
||||||
|
history.forEach((h) => { if (h.rating in counts) counts[h.rating] += 1; });
|
||||||
|
const rank = { forgot: 0, partly: 1, solid: 2 };
|
||||||
|
const recent = Array.from({ length: weeks }, (_, i) => {
|
||||||
|
const start = now - (weeks - i) * WEEK_MS;
|
||||||
|
const inWeek = history.filter((h) => h.at > start && h.at <= start + WEEK_MS && h.rating in rank);
|
||||||
|
const weakest = inWeek.sort((a, b) => rank[a.rating] - rank[b.rating])[0];
|
||||||
|
return { start, rating: weakest?.rating ?? null };
|
||||||
|
});
|
||||||
|
return { counts, total: history.length, recent };
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { test, expect } from 'vitest';
|
||||||
|
import { pickRecall, 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(40), editedAt: NOW - 2 * WEEK_MS, ...extra });
|
||||||
|
const study = (chunks, id = 'p1') => ({ id, title: 'Romans', translation: 'BSB', chapters: [{ book: 'Romans', bookAbbrev: 'Rom', chapter: 8, chunks }] });
|
||||||
|
|
||||||
|
test('picks a studied chunk that has enough of your own writing', () => {
|
||||||
|
const pick = pickRecall([study([chunk('a')])], NOW);
|
||||||
|
expect(pick).toMatchObject({ projectId: 'p1', projectTitle: 'Romans' });
|
||||||
|
expect(pick.chunk.id).toBe('a');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('skips thin notes, pasted notes and chunks edited this week', () => {
|
||||||
|
const pasted = text(40);
|
||||||
|
const projects = [study([
|
||||||
|
chunk('thin', { observation: text(10) }),
|
||||||
|
chunk('pasted', { observation: pasted, aiPastes: { observation: [pasted] } }),
|
||||||
|
chunk('fresh', { editedAt: NOW - 1000 }),
|
||||||
|
])];
|
||||||
|
expect(pickRecall(projects, NOW)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('never-recalled chunks come before ones recalled long ago', () => {
|
||||||
|
const old = chunk('old', { recalls: [{ at: NOW - 3 * WEEK_MS, text: 'x', rating: 'solid' }] });
|
||||||
|
expect(pickRecall([study([old, chunk('new')])], NOW).chunk.id).toBe('new');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('one recall a week: nothing is offered after you have done one', () => {
|
||||||
|
const done = chunk('done', { recalls: [{ at: NOW - 1000, text: 'x', rating: 'partly' }] });
|
||||||
|
expect(pickRecall([study([done, chunk('other')])], NOW)).toBeNull();
|
||||||
|
expect(pickRecall([study([done, chunk('other')])], NOW + WEEK_MS)).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
import { recallHistory, recallSummary } from './recall.js';
|
||||||
|
|
||||||
|
test('history lists every recall newest first, with where it came from', () => {
|
||||||
|
const a = chunk('a', { recalls: [{ at: NOW - 3 * WEEK_MS, text: 'old', rating: 'forgot' }, { at: NOW - 1000, text: 'new', rating: 'solid' }] });
|
||||||
|
const h = recallHistory([study([a])]);
|
||||||
|
expect(h.map((x) => x.text)).toEqual(['new', 'old']);
|
||||||
|
expect(h[0]).toMatchObject({ projectTitle: 'Romans', chunkId: 'a' });
|
||||||
|
expect(h[0].reference).toContain('Romans');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the summary counts ratings and marks each of the last eight weeks by its weakest recall', () => {
|
||||||
|
const history = [
|
||||||
|
{ at: NOW - 1000, rating: 'solid' }, { at: NOW - 2000, rating: 'partly' },
|
||||||
|
{ at: NOW - 2 * WEEK_MS + 1000, rating: 'forgot' },
|
||||||
|
];
|
||||||
|
const { counts, total, recent } = recallSummary(history, NOW);
|
||||||
|
expect(counts).toEqual({ solid: 1, partly: 1, forgot: 1 });
|
||||||
|
expect(total).toBe(3);
|
||||||
|
expect(recent).toHaveLength(8);
|
||||||
|
expect(recent[7].rating).toBe('partly'); // this week: the weaker of solid and partly
|
||||||
|
expect(recent[6].rating).toBe('forgot'); // last week
|
||||||
|
expect(recent[0].rating).toBeNull();
|
||||||
|
});
|
||||||
Reference in new issue
Block a user