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.
|
||||
import { useEffect, useState } from 'react';
|
||||
import DictateButton, { appendSpoken } from '../DictateButton.jsx';
|
||||
import { ownShare, ownWords, pastedWords, withPaste, clearPastes } from '../../utils/ownWork.js';
|
||||
import { templateFromChunk, templatePrompt } from '../../utils/studyTemplates.js';
|
||||
|
||||
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 share = ownShare(chunk);
|
||||
const keepPaste = (field) => (e) => {
|
||||
const next = withPaste(chunk, field, e.clipboardData?.getData('text'));
|
||||
if (next) updateChunk(chunk.id, { aiPastes: next });
|
||||
};
|
||||
|
||||
return (
|
||||
<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 }) => {
|
||||
const isOpen = open === field;
|
||||
const n = words(chunk[field]);
|
||||
const pasted = pastedWords(chunk, field);
|
||||
return (
|
||||
<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}
|
||||
@@ -50,12 +63,23 @@ export default function NotesPane({ chunk, updateChunk, allTags, templateId, sav
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
<span className="text-xs text-[var(--r-sub)]">{hint}</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>
|
||||
{isOpen && (
|
||||
<div className="px-4 pb-4">
|
||||
<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>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user