From c4c28f46ed697b4c9c56a64112dc0bf8999aab44 Mon Sep 17 00:00:00 2001 From: George Turkington Date: Tue, 21 Jul 2026 07:09:40 +0000 Subject: [PATCH] Daily Practice per section: mixed drills, stricter unlock, tick-and-skip one-tap - One-tap mode no longer shows the answer when you're right: a green tick flashes and it skips straight to the next question. Wrong answers still show the full correction and now wait for an explicit Next tap. - Every tab (Prepositions, Articles, Possessives, Pronouns) gets its own pinned Daily Practice card. Sessions mix questions from every lesson in the tab via a shuffle-bag over lesson ids. - Daily Practice is locked until every lesson in its section has been passed (one full round at 70%); the locked card shows x/y progress. - Finishing a daily session offers 'Continue:
Daily Practice' cycling to the next tab with an unlocked daily, with play again and back to menu as secondary options. --- src/App.jsx | 147 +++++++++++++++++++++++++++++++++------------ src/engine.js | 30 +++++++-- src/engine.test.js | 26 ++++++++ 3 files changed, 159 insertions(+), 44 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index cd10a1f..6c92730 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -60,10 +60,10 @@ const TABS = [ {id:'pronoun', label:'Pronouns'}, ]; -// Daily Practice pseudo-level: pinned to the top of the Prepositions tab once -// the "Alle Präpositionen" lesson (id 10) is completed. Always free. -const DAILY_ID = 10.5; -const FREE_LEVEL_IDS = new Set([2, 3, 4, 8, 9, 10, 10.5, 18]); +// Daily Practice pseudo-levels are always free: unlocking one requires +// passing every lesson in its tab, so gated tabs stay gated anyway. +const DAILY_IDS = LEVELS.filter(l => l.daily).map(l => l.id); +const FREE_LEVEL_IDS = new Set([2, 3, 4, 8, 9, 10, 18, ...DAILY_IDS]); const CORE_ARTICLE_CASES = ['nominative', 'accusative', 'dative']; const PAYMENTS_ENABLED = import.meta.env.VITE_PAYMENTS_ENABLED === 'true'; const STRIPE_PLANS = PAYMENTS_ENABLED ? [ @@ -231,6 +231,8 @@ export default function App() { const startLv = id => { if (!unlocked(id)) { setPayMsg(''); setScr('paywall'); return; } + const lvObj = LEVELS.find(l => l.id === id); + if (lvObj?.tab) setTab(lvObj.tab); sessionRef.current = createSession(id); setLv(id); setRes([]); setInp(''); setRev(false); setQ(sessionRef.current.next()); setScr('practice'); }; @@ -245,6 +247,7 @@ export default function App() { const submitRef = useRef(null); const nextRef = useRef(null); + const summaryRef = useRef(null); const doSubmit = useCallback((overrideInput) => { const userAns = (overrideInput ?? inp).trim(); @@ -277,7 +280,7 @@ export default function App() { useEffect(() => { const h = e => { if (chart) { if (e.key === 'Escape') setChart(false); return; } - if (scr === 'summary' && e.key === 'Enter') { e.preventDefault(); startLv(lv); return; } + if (scr === 'summary' && e.key === 'Enter') { e.preventDefault(); summaryRef.current?.(); return; } if (scr !== 'practice') return; if (!rev && (q?.type === 'article' || q?.type === 'chunk') && q.choices) { const idx = ['1','2','3','4'].indexOf(e.key); @@ -300,14 +303,15 @@ export default function App() { return () => window.removeEventListener('keydown', h); }); - // One-tap mode: after an answer is revealed, auto-advance to the next - // question. Always on in Daily Practice; opt-in (st.auto) elsewhere. Correct - // answers flash briefly; wrong answers linger so the correction can register. + // One-tap mode: always on in Daily Practice, opt-in (st.auto) elsewhere. + // A correct answer never shows the solution — just a green tick, then it + // skips straight to the next question. A wrong answer shows the correction + // and waits for an explicit Next, so it can actually register. useEffect(() => { - if (scr !== 'practice' || !rev) return; - const quick = lv === DAILY_ID || !!st.auto; + if (scr !== 'practice' || !rev || !ok) return; + const quick = !!LEVELS.find(l => l.id === lv)?.daily || !!st.auto; if (!quick) return; - const t = setTimeout(() => { nextRef.current?.(); }, ok ? 750 : 1650); + const t = setTimeout(() => { nextRef.current?.(); }, 550); return () => clearTimeout(t); }, [scr, rev, ok, lv, st.auto]); @@ -317,13 +321,28 @@ export default function App() { const cur = LEVELS.find(l=>l.id===lv); const chartSection = scr === 'menu' ? tab : (q?.type === 'wechselContext' ? 'prep' : q?.type === 'chunk' ? 'article' : (q?.type || tab)); const nextInTab = nextLevelInTab(lv); - const quickActive = lv === DAILY_ID || !!st.auto; - // Daily Practice unlocks once the all-31 lesson (id 10) is passed: - // at least one full round with the app's 70% pass rate. - const prepMastery = st.sc?.[10]; - const dailyUnlocked = !!(prepMastery && prepMastery.t >= ROUND && prepMastery.c / prepMastery.t >= 0.7); - const dailySc = st.sc?.[DAILY_ID]; - const dailyPct = dailySc ? Math.round(dailySc.c / dailySc.t * 100) : null; + const quickActive = !!cur?.daily || !!st.auto; + const quickTick = quickActive && rev && ok; + // Each tab's Daily Practice unlocks once every lesson in the tab is passed: + // at least one full round each, at the app's 70% pass rate. + const passedLv = id => { const s = st.sc?.[id]; return !!(s && s.t >= ROUND && s.c / s.t >= 0.7); }; + const tabLessons = t => LEVELS.filter(l => l.tab === t && !l.daily); + const dailyFor = t => LEVELS.find(l => l.tab === t && l.daily); + const dailyUnlockedFor = t => tabLessons(t).every(l => passedLv(l.id)); + // The next tab (cycling through the top tabs) with an unlocked Daily Practice. + const nextDailyAfter = t => { + const i = TABS.findIndex(x => x.id === t); + for (let k = 1; k < TABS.length; k++) { + const cand = TABS[(i + k) % TABS.length]; + if (dailyUnlockedFor(cand.id)) return { tab: cand, level: dailyFor(cand.id) }; + } + return null; + }; + const nextDaily = cur?.daily ? nextDailyAfter(cur.tab) : null; + summaryRef.current = () => { + if (cur?.daily && nextDaily) startLv(nextDaily.level.id); + else startLv(lv); + }; return (
@@ -338,8 +357,8 @@ export default function App() { {scr === 'practice' && ( )} @@ -371,17 +390,33 @@ export default function App() {
- {tab === 'prep' && dailyUnlocked && ( -
startLv(DAILY_ID)}> -
-
⚡ Daily Practice
-
All 31 prepositions · one-tap · keep them sharp
- {dailySc &&
{dailySc.c}/{dailySc.t}
} + {(() => { + const daily = dailyFor(tab); + if (!daily) return null; + const lessons = tabLessons(tab); + const done = lessons.filter(l => passedLv(l.id)).length; + if (!dailyUnlockedFor(tab)) return ( +
+
+
🔒 Daily Practice
+
Pass every lesson in this section to unlock · {done}/{lessons.length} done
+
- {dailyPct !== null &&
=70?GREEN:RED}}>{dailyPct}%
} -
- )} + ); + const sc = st.sc?.[daily.id]; + const pct = sc ? Math.round(sc.c / sc.t * 100) : null; + return ( +
startLv(daily.id)}> +
+
⚡ Daily Practice
+
{daily.desc} · keep it sharp
+ {sc &&
{sc.c}/{sc.t}
} +
+ {pct !== null &&
=70?GREEN:RED}}>{pct}%
} +
+ ); + })()} {tab === 'prep' && (
{ setLearnStep(0); setScr('learn'); }}> @@ -567,6 +602,8 @@ export default function App() {
or press Enter
) + ) : quickTick ? ( + ) : ( q.type === 'pronoun' ? @@ -596,6 +633,8 @@ export default function App() { ))}
+ ) : quickTick ? ( + ) : ( )} @@ -639,6 +678,8 @@ export default function App() { ))}
+ ) : quickTick ? ( + ) : ( )} @@ -666,6 +707,8 @@ export default function App() {
or press Enter
+ ) : quickTick ? ( + ) : ( )} @@ -683,7 +726,7 @@ export default function App() { )} - {scr === 'practice' && rev && ( + {scr === 'practice' && rev && !quickTick && (
- {rc>=7 && nextInTab && unlocked(nextInTab.id) && ( - + {cur?.daily && nextDaily ? ( + <> + +
+ +
+ + ) : ( + <> + {rc>=7 && nextInTab && unlocked(nextInTab.id) && ( +
{nextInTab.name} unlocked.
+ )} + + {rc>=7 && nextInTab && unlocked(nextInTab.id) && ( + + )} + )}
@@ -894,6 +950,17 @@ function PaywallScreen({ message, onCheckout, onBack }) { // FEEDBACK COMPONENTS // ═══════════════════════════════════════════ +// One-tap correct answer: no solution shown, just a tick before auto-skip. +function QuickTick() { + return ( +
+
+
+ ); +} + const FB_GENDERS = ['masculine','feminine','neuter','plural']; const FB_CASES = ['nominative','accusative','dative','genitive']; const normAns = s => (s || '').trim().toLowerCase(); diff --git a/src/engine.js b/src/engine.js index fe212cf..fbe8ceb 100644 --- a/src/engine.js +++ b/src/engine.js @@ -237,25 +237,29 @@ export const LEVELS = [ {id:5, tab:'article', name:'Genitiv', desc:'trotz, während, wegen, statt...', cases:['genitive'], preps:PREPS_BY_CASE.genitive, type:'article', artType:'definite'}, {id:6, tab:'article', name:'Alles gemischt', desc:'All prepositions, definite articles', cases:['accusative','dative','genitive'], preps:[...SENTENCE_ACC_PREPS,...PREPS_BY_CASE.dative,...PREPS_BY_CASE.genitive,...PREPS_BY_CASE.wechsel], type:'article', artType:'definite', mixed:true}, {id:7, tab:'article', name:'ein-Wörter', desc:'Everything with ein/eine/einem...', cases:['accusative','dative','genitive'], preps:[...SENTENCE_ACC_PREPS,...PREPS_BY_CASE.dative,...PREPS_BY_CASE.genitive,...PREPS_BY_CASE.wechsel], type:'article', artType:'indefinite', mixed:true}, + // Daily Practice pseudo-levels (one per tab): a one-tap drill that mixes + // questions from every lesson in the tab. Rendered pinned at the top of each + // tab by App.jsx (not inline in the level list) and locked until every + // lesson in the tab has been passed. + {id:7.5, tab:'article', name:'Daily Practice', desc:'Every article lesson, mixed · one-tap', daily:true}, {id:8, tab:'prep', name:'Häufige Präpositionen', desc:'16 most common prepositions', preps:COMMON_PREPS, type:'prep'}, {id:9, tab:'prep', name:'Seltene Präpositionen', desc:'15 less common prepositions', preps:RARE_PREPS, type:'prep'}, {id:10, tab:'prep', name:'Alle Präpositionen', desc:'All 31 prepositions mixed', preps:ALL_PREPS, type:'prep'}, - // Daily Practice: unlocked once "Alle Präpositionen" is completed. Drills all - // 31 prepositions and defaults to one-tap mode. Rendered pinned at the top of - // the Prepositions tab by App.jsx, not inline in the level list. - {id:10.5, tab:'prep', name:'Daily Practice', desc:'All 31 prepositions · one-tap daily drill', preps:ALL_PREPS, type:'prep', daily:true}, + {id:10.5, tab:'prep', name:'Daily Practice', desc:'Every preposition lesson, mixed · one-tap', daily:true}, {id:18, tab:'prep', name:'Wechsel im Kontext', desc:'Real sentences: Akkusativ or Dativ?', preps:PREPS_BY_CASE.wechsel, type:'wechselContext'}, {id:19, tab:'prep', name:'Kontraktionen', desc:'Contractions: am, ins, zum, beim…', type:'contraction'}, {id:11, tab:'poss', name:'Häufige Possessive', desc:'mein · dein · sein — Akkusativ & Dativ', possessives:['mein','dein','sein'], cases:['accusative','dative'], type:'poss'}, {id:12, tab:'poss', name:'Schwierige Possessive', desc:'ihr · unser · euer — Akkusativ & Dativ', possessives:['ihr','unser','euer'], cases:['accusative','dative'], type:'poss'}, {id:13, tab:'poss', name:'Alle gemischt', desc:'mein, dein, sein, ihr, unser, euer — incl. Genitiv', possessives:['mein','dein','sein','ihr','unser','euer'], cases:['accusative','dative','genitive'], type:'poss'}, + {id:13.5, tab:'poss', name:'Daily Practice', desc:'Every possessive lesson, mixed · one-tap', daily:true}, {id:14, tab:'pronoun', name:'Singular Pronomen', desc:'mich, mir, dich, dir…', pronouns:SG_PRONOUN_KEYS, cases:['accusative','dative'], type:'pronoun'}, {id:15, tab:'pronoun', name:'Plural Pronomen', desc:'uns, euch, ihnen…', pronouns:PL_PRONOUN_KEYS, cases:['accusative','dative'], type:'pronoun'}, {id:16, tab:'pronoun', name:'Sie (formal)', desc:'Formal Sie — singular and plural', pronouns:FORMAL_PRONOUN_KEYS, cases:['accusative','dative'], type:'pronoun'}, {id:17, tab:'pronoun', name:'Alle gemischt', desc:'All 10 pronouns, Akkusativ & Dativ', pronouns:ALL_PRONOUN_KEYS, cases:['accusative','dative'], type:'pronoun'}, + {id:17.5, tab:'pronoun', name:'Daily Practice', desc:'Every pronoun lesson, mixed · one-tap', daily:true}, ]; // Contractions (preposition + article). pref:true = preferred even in writing. @@ -440,7 +444,25 @@ export function newState() { } // A practice session: one shuffle-bag + anti-repeat memory per round. +// Daily levels mix questions from every lesson in their tab: a shuffle-bag +// deals the lesson ids so no lesson repeats back-to-back, and each lesson +// keeps its own state (their bag pools differ, so they can't share one). export function createSession(levelId) { + const lv = LEVELS.find(l => l.id === levelId); + if (lv?.daily) { + const lessonIds = LEVELS.filter(l => l.tab === lv.tab && !l.daily).map(l => l.id); + const meta = newState(); + const states = {}; + return { + next: () => { + const id = drawBag(meta, 'lvl', lessonIds, meta.last.lvl); + meta.last.lvl = id; + if (!states[id]) states[id] = newState(); + return gen(id, states[id]); + }, + state: meta, + }; + } const state = newState(); return { next: () => gen(levelId, state), state }; } diff --git a/src/engine.test.js b/src/engine.test.js index 7001573..4872a01 100644 --- a/src/engine.test.js +++ b/src/engine.test.js @@ -258,6 +258,32 @@ describe('no nominative anywhere', () => { }); }); +// ── Daily practice (per-tab mixed sessions) ───────────────── + +describe('daily practice', () => { + const dailies = LEVELS.filter(l => l.daily); + + it('every tab has exactly one daily level', () => { + expect(dailies.map(l => l.tab).sort()).toEqual(['article', 'poss', 'prep', 'pronoun']); + }); + + it.each(dailies.map(l => [l.id, l.tab]))('daily %s mixes every lesson type of the %s tab', (id, tab) => { + const expected = new Set(LEVELS.filter(l => l.tab === tab && !l.daily).map(l => l.type)); + const seen = new Set(runSession(id, 80).map(q => q.type)); + // Mixed definite-article lessons legitimately emit chunk questions too. + for (const t of seen) expect([...expected, 'chunk'], `unexpected type ${t} in ${tab} daily`).toContain(t); + for (const t of expected) expect(seen, `${tab} daily never produced a ${t} question`).toContain(t); + }); + + it('daily questions always carry an answer', () => { + for (const d of dailies) { + for (const q of runSession(d.id, 100)) { + expect(q.ans).toBeTruthy(); + } + } + }); +}); + // ── Every level generates without crashing ────────────────── describe('all levels', () => {