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
@@ -29,6 +29,9 @@ export function mergeChunk(mine, theirs) {
|
||||
const out = { ...older, ...newer };
|
||||
TEXT_FIELDS.forEach((f) => { out[f] = mergeText(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;
|
||||
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