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:
prosolis
2026-06-26 16:41:28 -07:00
parent 8aa437ec82
commit 4161830da6
9 changed files with 256 additions and 98 deletions

View File

@@ -20,6 +20,7 @@ type Word struct {
ID string `json:"id"`
Word string `json:"word"`
Gloss string `json:"gloss"`
Definition string `json:"definition"` // English fallback meaning when there's no Chinese gloss
Phonetic string `json:"phonetic"`
Example string `json:"example"`
DocID *string `json:"doc_id"`
@@ -51,7 +52,7 @@ func (h *Handler) Routes() chi.Router {
return r
}
const vocabColumns = `id, word, gloss, phonetic, example, doc_id,
const vocabColumns = `id, word, gloss, definition, phonetic, example, doc_id,
due_at, interval_days, ease, reps, lapses, last_reviewed, created_at`
func scanWord(s interface {
@@ -59,7 +60,7 @@ func scanWord(s interface {
}) (Word, error) {
var w Word
err := s.Scan(
&w.ID, &w.Word, &w.Gloss, &w.Phonetic, &w.Example, &w.DocID,
&w.ID, &w.Word, &w.Gloss, &w.Definition, &w.Phonetic, &w.Example, &w.DocID,
&w.DueAt, &w.IntervalDays, &w.Ease, &w.Reps, &w.Lapses, &w.LastReviewed, &w.CreatedAt,
)
return w, err
@@ -93,15 +94,20 @@ func (h *Handler) queryList(w http.ResponseWriter, query string, args ...any) {
}
out = append(out, word)
}
if err := rows.Err(); err != nil {
serverError(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
type captureRequest struct {
Word string `json:"word"`
Gloss string `json:"gloss"`
Phonetic string `json:"phonetic"`
Example string `json:"example"`
DocID *string `json:"doc_id"`
Word string `json:"word"`
Gloss string `json:"gloss"`
Definition string `json:"definition"`
Phonetic string `json:"phonetic"`
Example string `json:"example"`
DocID *string `json:"doc_id"`
}
// capture records a looked-up word. It's an idempotent upsert keyed on the word:
@@ -114,7 +120,11 @@ func (h *Handler) capture(w http.ResponseWriter, r *http.Request) {
errorJSON(w, http.StatusBadRequest, "invalid body")
return
}
word := strings.TrimSpace(req.Word)
// Normalize the same way the lexicon does (internal/lexicon: lower+trim) so
// the UNIQUE(user_id, word) upsert is genuinely idempotent — otherwise
// "Apple" at a sentence start and "apple" mid-line would create two separate
// cards on independent schedules.
word := strings.ToLower(strings.TrimSpace(req.Word))
if word == "" {
errorJSON(w, http.StatusBadRequest, "word is required")
return
@@ -124,14 +134,15 @@ func (h *Handler) capture(w http.ResponseWriter, r *http.Request) {
// schedule (due_at/reps/interval/ease) alone so re-looking-up a word never
// resets its progress.
_, err := h.DB.Exec(
`INSERT INTO vocab_words (user_id, word, gloss, phonetic, example, doc_id, due_at, interval_days)
VALUES (?, ?, ?, ?, ?, ?, datetime('now', '+1 day'), 1)
`INSERT INTO vocab_words (user_id, word, gloss, definition, phonetic, example, doc_id, due_at, interval_days)
VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now', '+1 day'), 1)
ON CONFLICT(user_id, word) DO UPDATE SET
gloss = excluded.gloss,
phonetic = excluded.phonetic,
example = CASE WHEN excluded.example != '' THEN excluded.example ELSE vocab_words.example END,
doc_id = COALESCE(excluded.doc_id, vocab_words.doc_id)`,
db.LocalUserID, word, req.Gloss, req.Phonetic, req.Example, req.DocID,
gloss = excluded.gloss,
definition = excluded.definition,
phonetic = excluded.phonetic,
example = CASE WHEN excluded.example != '' THEN excluded.example ELSE vocab_words.example END,
doc_id = COALESCE(excluded.doc_id, vocab_words.doc_id)`,
db.LocalUserID, word, req.Gloss, req.Definition, req.Phonetic, req.Example, req.DocID,
)
if err != nil {
serverError(w, err)

View File

@@ -141,6 +141,48 @@ func TestCaptureValidation(t *testing.T) {
}
}
// TestCaptureStoresDefinitionFallback proves a word with an English definition
// but no Chinese gloss still round-trips its definition, so review has a meaning
// to reveal instead of a blank card.
func TestCaptureStoresDefinitionFallback(t *testing.T) {
srv, _ := newTestServer(t)
rec := do(t, srv, http.MethodPost, "/vocab",
`{"word":"ineffable","definition":"too great to be expressed in words"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("capture: code=%d body=%s", rec.Code, rec.Body)
}
var w Word
_ = json.Unmarshal(rec.Body.Bytes(), &w)
if w.Gloss != "" {
t.Fatalf("expected no gloss, got %q", w.Gloss)
}
if w.Definition != "too great to be expressed in words" {
t.Fatalf("definition not stored, got %q", w.Definition)
}
}
// TestCaptureCaseInsensitive proves "Apple" (sentence start) and "apple"
// (mid-line) land in the SAME garden card — capture normalizes to lower+trim
// like the lexicon, so the idempotent upsert isn't defeated by casing.
func TestCaptureCaseInsensitive(t *testing.T) {
srv, _ := newTestServer(t)
if rec := do(t, srv, http.MethodPost, "/vocab", `{"word":"Apple","gloss":"苹果"}`); rec.Code != http.StatusCreated {
t.Fatalf("capture Apple: code=%d body=%s", rec.Code, rec.Body)
}
if rec := do(t, srv, http.MethodPost, "/vocab", `{"word":" apple ","gloss":"苹果"}`); rec.Code != http.StatusCreated {
t.Fatalf("capture apple: code=%d body=%s", rec.Code, rec.Body)
}
rec := do(t, srv, http.MethodGet, "/vocab", "")
var all []Word
_ = json.Unmarshal(rec.Body.Bytes(), &all)
if len(all) != 1 {
t.Fatalf("Apple/apple should be one card, got %d", len(all))
}
if all[0].Word != "apple" {
t.Fatalf("word should be normalized to lowercase, got %q", all[0].Word)
}
}
// TestDocLinkSurvivesDocDelete proves the ON DELETE SET NULL keeps a word in the
// garden when its source document is removed.
func TestDocLinkSurvivesDocDelete(t *testing.T) {

View File

@@ -23,10 +23,17 @@ var ladder = []int{1, 3, 7, 16, 35}
const (
minEase = 1.3
maxEase = 3.0 // ease ceiling — keeps "easy" from compounding growth without bound
startEase = 2.5
easyBonus = 1.3 // multiplier applied on top of a "good" step for "easy"
easeAgainDelta = -0.20 // ease nudged down on a lapse (still floored at minEase)
easeEasyDelta = 0.15 // ease nudged up when a card feels easy
// maxInterval caps how far a card can drift into the future. Even a word
// graded "easy" many times resurfaces about once a year, so nothing silently
// leaves the garden forever (the whole point is to keep words in gentle
// rotation, not to retire them).
maxInterval = 365
)
// State is the spaced-repetition state of one card. It mirrors the scheduling
@@ -61,7 +68,7 @@ func (s State) next(grade Grade) State {
reps := s.Reps + 1
return State{
Reps: reps,
Interval: int(math.Round(float64(goodInterval(reps, s.Interval, ease)) * easyBonus)),
Interval: clampInterval(int(math.Round(float64(goodInterval(reps, s.Interval, ease)) * easyBonus))),
Ease: ease,
Lapses: s.Lapses,
}
@@ -69,7 +76,7 @@ func (s State) next(grade Grade) State {
reps := s.Reps + 1
return State{
Reps: reps,
Interval: goodInterval(reps, s.Interval, ease),
Interval: clampInterval(goodInterval(reps, s.Interval, ease)),
Ease: ease,
Lapses: s.Lapses,
}
@@ -93,5 +100,15 @@ func clampEase(e float64) float64 {
if e < minEase {
return minEase
}
if e > maxEase {
return maxEase
}
return e
}
func clampInterval(d int) int {
if d > maxInterval {
return maxInterval
}
return d
}

View File

@@ -51,6 +51,28 @@ func TestAgainStepsBackGently(t *testing.T) {
}
}
// TestCapsBoundGrowth proves runaway "easy" grading can't push ease or the
// interval past their ceilings, so a word always resurfaces within a year.
func TestCapsBoundGrowth(t *testing.T) {
s := State{Ease: startEase}
for i := 0; i < 40; i++ {
s = s.next(GradeEasy)
if s.Ease > maxEase {
t.Fatalf("step %d: ease %v exceeded cap %v", i, s.Ease, maxEase)
}
if s.Interval > maxInterval {
t.Fatalf("step %d: interval %d exceeded cap %d", i, s.Interval, maxInterval)
}
}
// After enough "easy" reviews it should be pinned at the ceilings.
if s.Interval != maxInterval {
t.Fatalf("interval should saturate at %d, got %d", maxInterval, s.Interval)
}
if s.Ease != maxEase {
t.Fatalf("ease should saturate at %v, got %v", maxEase, s.Ease)
}
}
// TestEasyAdvancesFurther proves "easy" both bumps ease and lands a longer
// interval than a plain "good" at the same rung.
func TestEasyAdvancesFurther(t *testing.T) {