Code-review fixes for collocation coach + vocab garden
Correctness: - useCheckpoint: clear the busy flag unconditionally so overlapping explicit passes don't strand each other's spinner; explicit actions now also supersede a queued auto-check and clear the stranded "checking" dot. Deduped runVoice/runCollocation into runExplicitPass. - EditorCore: token-guard the auto-capture so a late capture can't resurrect a removed word; move toggleSaveWord side effects out of the setWordInfo updater (StrictMode double-fire); fix sentenceAround offset desync via shared exampleAt (textBetween + parentOffset, single resolve); optimistic saved state so the heart doesn't flash unsaved. - vocab capture: normalize word to lower+trim (matches lexicon) so "Apple"/"apple" don't make duplicate cards; check rows.Err() in queryList. - GardenPanel: Promise.allSettled so a /due failure doesn't blank the whole garden; scrim click during review ends the review (mirrors Esc); gate footer on !error; O(1) due lookup via a Set. Features requested in review: - Definition-only review card: add vocab_words.definition (migration 0007) as an English fallback meaning, threaded through capture and used by review/garden when there's no Chinese gloss. - Scheduler caps: maxEase 3.0 + maxInterval 365d so "easy" growth can't push a word out of rotation for years. Tests: TestCaptureCaseInsensitive, TestCaptureStoresDefinitionFallback, TestCapsBoundGrowth. go build/vet/test, tsc, vitest 51/51, vite build clean. Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
@@ -51,13 +51,14 @@ export function GardenPanel({ onClose, onOpenDoc }: Props) {
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setError(false)
|
||||
try {
|
||||
const [all, dueNow] = await Promise.all([api.listVocab(), api.dueVocab()])
|
||||
setWords(all)
|
||||
setDue(dueNow)
|
||||
} catch {
|
||||
setError(true)
|
||||
}
|
||||
// Settle the two requests independently: a failed /due shouldn't blank the
|
||||
// whole garden when the word list loaded fine. Only listVocab failing is a
|
||||
// true error state; a dueVocab failure just hides the review button.
|
||||
const [allRes, dueRes] = await Promise.allSettled([api.listVocab(), api.dueVocab()])
|
||||
if (allRes.status === 'fulfilled') setWords(allRes.value)
|
||||
else setError(true)
|
||||
if (dueRes.status === 'fulfilled') setDue(dueRes.value)
|
||||
else setDue([])
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -119,7 +120,11 @@ export function GardenPanel({ onClose, onOpenDoc }: Props) {
|
||||
|
||||
return (
|
||||
<div className="petal-no-print fixed inset-0 z-40 flex justify-end">
|
||||
<div className="absolute inset-0" style={{ background: 'rgba(61, 46, 57, 0.18)' }} onClick={onClose} />
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{ background: 'rgba(61, 46, 57, 0.18)' }}
|
||||
onClick={() => (queue ? setQueue(null) : onClose())}
|
||||
/>
|
||||
|
||||
<aside
|
||||
className="relative flex h-full w-full max-w-[420px] flex-col"
|
||||
@@ -202,6 +207,9 @@ function GardenView({
|
||||
onOpenDoc?: (docId: string) => void
|
||||
onRetry: () => void
|
||||
}) {
|
||||
// Index the due cards once so the per-word "due" check below is O(1), not a
|
||||
// linear scan of `due` for every word in the garden.
|
||||
const dueIds = useMemo(() => new Set(due.map((d) => d.id)), [due])
|
||||
return (
|
||||
<>
|
||||
{due.length > 0 && (
|
||||
@@ -246,7 +254,7 @@ function GardenView({
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{words.map((w) => {
|
||||
const open = expanded === w.id
|
||||
const isDue = due.some((d) => d.id === w.id)
|
||||
const isDue = dueIds.has(w.id)
|
||||
return (
|
||||
<li key={w.id}>
|
||||
<button
|
||||
@@ -263,15 +271,17 @@ function GardenView({
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate text-sm font-bold text-plum">{w.word}</span>
|
||||
{w.gloss && (
|
||||
{(w.gloss || w.definition) && (
|
||||
<span
|
||||
className="truncate text-xs"
|
||||
style={{
|
||||
color: 'var(--color-muted)',
|
||||
fontFamily: "'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC',sans-serif",
|
||||
fontFamily: w.gloss
|
||||
? "'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC',sans-serif"
|
||||
: 'var(--font-body)',
|
||||
}}
|
||||
>
|
||||
{w.gloss}
|
||||
{w.gloss || w.definition}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
@@ -335,7 +345,7 @@ function GardenView({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{words && words.length > 0 && (
|
||||
{!error && words && words.length > 0 && (
|
||||
<div
|
||||
className="shrink-0 px-4 py-2.5 text-center text-[11px]"
|
||||
style={{ borderTop: '1px solid var(--color-border)', color: 'var(--color-muted)' }}
|
||||
@@ -365,16 +375,19 @@ function ReviewSession({
|
||||
onQuit: () => void
|
||||
}) {
|
||||
const card = queue[cursor]
|
||||
// The meaning shown/asked is the Chinese gloss, or the English definition when
|
||||
// a word has no gloss — so definition-only words are still reviewable.
|
||||
const meaning = card?.gloss || card?.definition || ''
|
||||
// Alternate the quiz direction so she practices both recognition (see the
|
||||
// English, recall the meaning) and production (see the meaning, recall the
|
||||
// English word). Parity of the cursor keeps it deterministic within a session.
|
||||
const production = cursor % 2 === 1 && !!card?.gloss
|
||||
const production = cursor % 2 === 1 && !!meaning
|
||||
|
||||
const prompt = useMemo(() => {
|
||||
if (!card) return ''
|
||||
if (production) return card.gloss
|
||||
if (production) return meaning
|
||||
return card.example ? blankOut(card.example, card.word) : card.word
|
||||
}, [card, production])
|
||||
}, [card, production, meaning])
|
||||
|
||||
if (!card) return null
|
||||
|
||||
@@ -432,15 +445,19 @@ function ReviewSession({
|
||||
/{card.phonetic}/
|
||||
</div>
|
||||
)}
|
||||
{card.gloss && (
|
||||
{meaning && (
|
||||
<div
|
||||
className="mt-1 text-sm font-semibold"
|
||||
style={{
|
||||
color: 'var(--color-accent-hover)',
|
||||
fontFamily: "'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC',sans-serif",
|
||||
// Chinese gloss gets the CJK stack; an English definition fallback
|
||||
// reads better in the body font.
|
||||
fontFamily: card.gloss
|
||||
? "'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC',sans-serif"
|
||||
: 'var(--font-body)',
|
||||
}}
|
||||
>
|
||||
{card.gloss}
|
||||
{meaning}
|
||||
</div>
|
||||
)}
|
||||
{card.example && (
|
||||
|
||||
Reference in New Issue
Block a user