feat(phase3): learning helpers — summary, glossary, flashcards (SM-2), quizzes
CI / test (push) Successful in 16s
CI / build-apk (push) Has been skipped
CI / deploy-web (push) Successful in 30s

Deterministic, on-device, no model:
- src/lib/learn pure modules (tokenize, summary [TextRank-ish], glossary
  [definition-pattern + frequency], flashcards [cloze/Q-A], srs [SM-2], quiz
  [MCQ with distractors]) — 37 unit tests.
- Flashcard persistence: Dexie v4 + native v4 `flashcards` table; create/list/
  listDue/updateSrs/delete/counts; cascades (transcript delete, course->Unsorted).
- UI: transcript "Study aids" (generate summary+glossary, click-to-seek; create
  flashcards), Study screen (SM-2 review + Anki CSV export), per-lecture Quiz,
  library Study link with due-count badge.

215 tests green, 0 tsc errors, web export builds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-14 15:37:42 +02:00
parent 6d2f583136
commit 40858e0025
23 changed files with 2238 additions and 24 deletions
+148 -1
View File
@@ -1,4 +1,4 @@
import { Stack, useLocalSearchParams } from 'expo-router';
import { Link, Stack, useLocalSearchParams } from 'expo-router';
import { useEffect, useMemo, useRef, useState } from 'react';
import {
ActivityIndicator,
@@ -18,9 +18,18 @@ import { getRepo, type Transcript } from '@/lib/db';
import { downloadText } from '@/lib/download';
import { EXPORT_META, formatTranscript, type ExportFormat } from '@/lib/export';
import { formatClock } from '@/lib/format';
import {
cardsFromGlossary,
glossary,
summarize,
type GlossaryEntry,
type SourcedSentence,
} from '@/lib/learn';
import type { Segment } from '@/lib/types';
import { useTranscribe } from '@/stores/transcribeStore';
const ACCENT = '#3c87f7';
export default function TranscriptScreen() {
const theme = useTheme();
const { id, t } = useLocalSearchParams<{ id: string; t?: string }>();
@@ -37,6 +46,14 @@ export default function TranscriptScreen() {
const [saving, setSaving] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
// Study aids (deterministic, computed on demand — no storage, no model).
const [aids, setAids] = useState<{
summary: SourcedSentence[];
glossary: GlossaryEntry[];
} | null>(null);
const [creatingCards, setCreatingCards] = useState(false);
const [cardsAdded, setCardsAdded] = useState<number | null>(null);
const scrollRef = useRef<ScrollView | null>(null);
// Y offset of each rendered segment row, captured via onLayout, for scroll-to.
const segmentYs = useRef<Record<number, number>>({});
@@ -165,6 +182,38 @@ export default function TranscriptScreen() {
downloadText(`${safeName}.${meta.ext}`, meta.mime, content);
};
// Compute summary + glossary on demand (pure helpers, nothing persisted).
const generateAids = () => {
setAids({ summary: summarize(segments), glossary: glossary(segments) });
setCardsAdded(null);
};
// Seeds derived from the current glossary — drives the "Create N flashcards" label.
const cardSeeds = useMemo(
() => (aids ? cardsFromGlossary(aids.glossary) : []),
[aids],
);
const createCards = async () => {
if (cardSeeds.length === 0 || creatingCards) return;
setCreatingCards(true);
try {
const created = await getRepo().createFlashcards(
cardSeeds.map((s) => ({
transcriptId: id,
courseId: transcript?.courseId ?? null,
segmentId: s.segmentId,
start: s.start,
front: s.front,
back: s.back,
})),
);
setCardsAdded(created.length);
} finally {
setCreatingCards(false);
}
};
if (transcript === undefined) return <Centered><ActivityIndicator /></Centered>;
if (transcript === null)
return (
@@ -205,6 +254,96 @@ export default function TranscriptScreen() {
))}
</View>
{/* Study aids — deterministic helpers, computed on demand. */}
<ThemedView type="backgroundElement" style={styles.aidsCard}>
<ThemedText type="smallBold">Study aids</ThemedText>
<View style={styles.aidsRow}>
<Pressable
onPress={generateAids}
disabled={segments.length === 0}
style={({ pressed }) => [
styles.aidBtn,
{ backgroundColor: ACCENT, opacity: segments.length === 0 ? 0.5 : pressed ? 0.85 : 1 },
]}>
<ThemedText style={styles.aidBtnText}>
{aids ? 'Regenerate summary & glossary' : 'Generate summary & glossary'}
</ThemedText>
</Pressable>
<Link href={{ pathname: '/quiz', params: { id } }} asChild>
<Pressable
style={({ pressed }) => [
styles.aidBtn,
{ backgroundColor: theme.backgroundSelected, opacity: pressed ? 0.85 : 1 },
]}>
<ThemedText style={styles.aidBtnTextAlt}>Quiz this lecture</ThemedText>
</Pressable>
</Link>
</View>
{aids && (
<>
{aids.summary.length > 0 ? (
<View style={styles.aidSection}>
<ThemedText type="smallBold" themeColor="textSecondary">Summary</ThemedText>
{aids.summary.map((s, i) => (
<Pressable key={`sum-${i}`} onPress={() => seek(s.start)} hitSlop={4}>
<View style={styles.aidLine}>
<ThemedText type="code" themeColor="textSecondary" style={styles.ts}>
{formatClock(s.start)}
</ThemedText>
<ThemedText type="small" style={styles.flex}>{s.text}</ThemedText>
</View>
</Pressable>
))}
</View>
) : (
<ThemedText type="small" themeColor="textSecondary">No summary could be derived.</ThemedText>
)}
{aids.glossary.length > 0 ? (
<View style={styles.aidSection}>
<ThemedText type="smallBold" themeColor="textSecondary">Glossary</ThemedText>
{aids.glossary.map((g, i) => (
<Pressable key={`glo-${i}`} onPress={() => seek(g.start)} hitSlop={4}>
<View style={styles.aidLine}>
<ThemedText type="code" themeColor="textSecondary" style={styles.ts}>
{formatClock(g.start)}
</ThemedText>
<ThemedText type="small" style={styles.flex}>
<ThemedText type="smallBold">{g.term}</ThemedText>
{`${g.definition}`}
</ThemedText>
</View>
</Pressable>
))}
</View>
) : (
<ThemedText type="small" themeColor="textSecondary">No glossary terms found.</ThemedText>
)}
{cardSeeds.length > 0 && (
<Pressable
onPress={() => void createCards()}
disabled={creatingCards}
style={({ pressed }) => [
styles.aidBtn,
{ backgroundColor: ACCENT, opacity: creatingCards ? 0.6 : pressed ? 0.85 : 1 },
]}>
<ThemedText style={styles.aidBtnText}>
{creatingCards ? 'Adding…' : `Create ${cardSeeds.length} flashcards`}
</ThemedText>
</Pressable>
)}
{cardsAdded !== null && (
<ThemedText type="small" themeColor="textSecondary">
Added {cardsAdded} {cardsAdded === 1 ? 'card' : 'cards'}.
</ThemedText>
)}
</>
)}
</ThemedView>
{segments.map((s, i) => (
<View
key={i}
@@ -272,6 +411,14 @@ const styles = StyleSheet.create({
title: { fontSize: 22, fontWeight: '700', paddingVertical: Spacing.two },
exportRow: { flexDirection: 'row', flexWrap: 'wrap', gap: Spacing.two, marginBottom: Spacing.two },
chip: { paddingHorizontal: Spacing.three, paddingVertical: Spacing.one, borderRadius: 999 },
aidsCard: { padding: Spacing.three, borderRadius: Spacing.three, gap: Spacing.two, marginBottom: Spacing.two },
aidsRow: { flexDirection: 'row', flexWrap: 'wrap', gap: Spacing.two },
aidBtn: { paddingHorizontal: Spacing.three, paddingVertical: Spacing.two, borderRadius: Spacing.two, alignItems: 'center' },
aidBtnText: { color: '#fff', fontWeight: '700', fontSize: 14 },
aidBtnTextAlt: { fontWeight: '700', fontSize: 14 },
aidSection: { gap: Spacing.one, marginTop: Spacing.one },
aidLine: { flexDirection: 'row', gap: Spacing.two, alignItems: 'flex-start', paddingVertical: 2 },
flex: { flex: 1 },
segRow: { flexDirection: 'row', gap: Spacing.two, paddingVertical: Spacing.one, paddingHorizontal: Spacing.two, borderRadius: Spacing.two, alignItems: 'flex-start' },
ts: { paddingTop: 4, minWidth: 52 },
segText: { flex: 1, fontSize: 16, lineHeight: 24, padding: 0 },