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)