Add Insights, Observe-first, compare, handout layouts, richer search, backup nudge

- Commentary and study aids wait for 15 words of your own Observe notes.
- Insights page: this week's own work, themes across studies, words you keep
  studying, what slipped in recalls, open follow-ups.
- Recall can record what slipped; session notes (teach it back, who came,
  group notes, follow-ups); "Pray about this" from Apply.
- Compare any two passages side by side.
- Handout layouts: standard, questions only, large print, note-taking.
- Search understands "in <book>", "last week/month/year" and a year.
- Home shows the last backup age and nudges after two weeks.
- Phone: the stage bar scrolls instead of clipping Script and Export.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-14 12:42:11 -04:00
1 parent 2ddc4e1ff6
commit d8e522dd06
24 files changed
+540 -28

No files matched your search

+107
View File
@@ -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 && <p className="mt-2 text-xs text-[var(--r-sub)]">{week.teachBacks} teach-it-back summar{week.teachBacks === 1 ? 'y' : 'ies'} written.</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>
);
}