fix(db): atomic saveMnemonic, const normalizedLetter, updateProfileSeen guard, ?? 0 for avgScore

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-30 00:46:05 +00:00
parent 2d96d37866
commit d8de79ce7d
2 changed files with 27 additions and 15 deletions
+16 -15
View File
@@ -73,7 +73,8 @@ function initDb(dbPath) {
} }
function updateProfileSeen(id) { function updateProfileSeen(id) {
instance.prepare('UPDATE profiles SET last_seen = ? WHERE id = ?').run(Date.now(), id) const result = instance.prepare('UPDATE profiles SET last_seen = ? WHERE id = ?').run(Date.now(), id)
if (result.changes === 0) console.warn(`[db] updateProfileSeen: no profile id=${id}`)
} }
function createMagicToken(token, profileId, expiresAt) { function createMagicToken(token, profileId, expiresAt) {
@@ -128,7 +129,7 @@ function initDb(dbPath) {
} }
function recordAnswer(profileId, letter, correct) { function recordAnswer(profileId, letter, correct) {
letter = letter.toUpperCase() const normalizedLetter = letter.toUpperCase()
const progress = getOrCreateProgress(profileId) const progress = getOrCreateProgress(profileId)
const now = Date.now() const now = Date.now()
const isFirstToday = !progress.last_session_at || const isFirstToday = !progress.last_session_at ||
@@ -156,15 +157,15 @@ function initDb(dbPath) {
// Update letter stats // Update letter stats
const existing = instance const existing = instance
.prepare('SELECT * FROM letter_stats WHERE profile_id = ? AND letter = ?') .prepare('SELECT * FROM letter_stats WHERE profile_id = ? AND letter = ?')
.get(profileId, letter) .get(profileId, normalizedLetter)
if (existing) { if (existing) {
instance.prepare( instance.prepare(
'UPDATE letter_stats SET correct = correct + ?, attempts = attempts + 1, updated_at = ? WHERE profile_id = ? AND letter = ?' 'UPDATE letter_stats SET correct = correct + ?, attempts = attempts + 1, updated_at = ? WHERE profile_id = ? AND letter = ?'
).run(correct ? 1 : 0, now, profileId, letter) ).run(correct ? 1 : 0, now, profileId, normalizedLetter)
} else { } else {
instance.prepare( instance.prepare(
'INSERT INTO letter_stats (profile_id, letter, correct, attempts, updated_at) VALUES (?,?,?,1,?)' 'INSERT INTO letter_stats (profile_id, letter, correct, attempts, updated_at) VALUES (?,?,?,1,?)'
).run(profileId, letter, correct ? 1 : 0, now) ).run(profileId, normalizedLetter, correct ? 1 : 0, now)
} }
return instance.prepare('SELECT * FROM user_progress WHERE profile_id = ?').get(profileId) return instance.prepare('SELECT * FROM user_progress WHERE profile_id = ?').get(profileId)
@@ -181,17 +182,17 @@ function initDb(dbPath) {
const now = Date.now() const now = Date.now()
const upperLetter = letter.toUpperCase() const upperLetter = letter.toUpperCase()
instance.prepare( // Try insert first — changes=1 means new row (first save), changes=0 means update path
`INSERT INTO user_mnemonics (profile_id, letter, phrase, created_at) VALUES (?,?,?,?) const insertResult = instance.prepare(
ON CONFLICT(profile_id, letter) DO UPDATE SET phrase = excluded.phrase` 'INSERT OR IGNORE INTO user_mnemonics (profile_id, letter, phrase, created_at) VALUES (?,?,?,?)'
).run(profileId, upperLetter, phrase, now) ).run(profileId, upperLetter, phrase, now)
// Only award 50 pts on first save — if created_at = now, it was just inserted if (insertResult.changes === 0) {
const row = instance.prepare( // Existing mnemonic — update phrase only, no point award
'SELECT created_at FROM user_mnemonics WHERE profile_id = ? AND letter = ?' instance.prepare('UPDATE user_mnemonics SET phrase = ? WHERE profile_id = ? AND letter = ?')
).get(profileId, upperLetter) .run(phrase, profileId, upperLetter)
} else {
if (row.created_at === now) { // First save — award 50 points
getOrCreateProgress(profileId) getOrCreateProgress(profileId)
instance.prepare('UPDATE user_progress SET score = score + 50, updated_at = ? WHERE profile_id = ?') instance.prepare('UPDATE user_progress SET score = score + 50, updated_at = ? WHERE profile_id = ?')
.run(now, profileId) .run(now, profileId)
@@ -218,7 +219,7 @@ function initDb(dbPath) {
function getAdminStats() { function getAdminStats() {
const totalUsers = instance.prepare('SELECT COUNT(*) as c FROM profiles').get().c const totalUsers = instance.prepare('SELECT COUNT(*) as c FROM profiles').get().c
const avgScore = instance.prepare('SELECT AVG(score) as a FROM user_progress').get().a || 0 const avgScore = instance.prepare('SELECT AVG(score) as a FROM user_progress').get().a ?? 0
const mostMissed = instance.prepare(` const mostMissed = instance.prepare(`
SELECT letter, SUM(attempts - correct) as misses SELECT letter, SUM(attempts - correct) as misses
FROM letter_stats GROUP BY letter ORDER BY misses DESC LIMIT 5 FROM letter_stats GROUP BY letter ORDER BY misses DESC LIMIT 5
+11
View File
@@ -159,6 +159,17 @@ describe('mnemonics', () => {
assert.equal(m.A, 'go WALKING') assert.equal(m.A, 'go WALKING')
assert.equal(m.E, 'go') assert.equal(m.E, 'go')
}) })
test('saveMnemonic awards 50 pts on first save, not on update', () => {
const p1 = db.getOrCreateProgress(profileId)
db.saveMnemonic(profileId, 'T', 'dash') // first save → +50 pts
const p2 = db.getOrCreateProgress(profileId)
assert.equal(p2.score, p1.score + 50)
db.saveMnemonic(profileId, 'T', 'updated dash') // update → no extra pts
const p3 = db.getOrCreateProgress(profileId)
assert.equal(p3.score, p2.score)
})
}) })
describe('letter stats', () => { describe('letter stats', () => {