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: <section> Daily Practice'
  cycling to the next tab with an unlocked daily, with play again and
  back to menu as secondary options.
This commit is contained in:
2026-07-21 07:09:40 +00:00
parent 80f7e0eca2
commit c4c28f46ed
3 changed files with 159 additions and 44 deletions
+107 -40
View File
@@ -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 (
<div style={S.wrap}>
@@ -338,8 +357,8 @@ export default function App() {
{scr === 'practice' && (
<button
style={{...S.linkBtn, color: quickActive ? GREEN : '#999', fontWeight: quickActive ? 700 : 500}}
title={lv === DAILY_ID ? 'One-tap is always on in Daily Practice' : 'Answer with one tap, then auto-advance'}
onClick={()=>{ if (lv === DAILY_ID) return; save({...st, auto: !st.auto}); }}>
title={cur?.daily ? 'One-tap is always on in Daily Practice' : 'Answer with one tap, then auto-advance'}
onClick={()=>{ if (cur?.daily) return; save({...st, auto: !st.auto}); }}>
{quickActive ? 'One-tap' : 'One-tap off'}
</button>
)}
@@ -371,17 +390,33 @@ export default function App() {
</div>
<div style={{marginTop:10}}>
{tab === 'prep' && dailyUnlocked && (
<div style={{...S.lvRow, cursor:'pointer', border:`1.5px solid ${GREEN}`, background:'#f3ffe8'}}
onClick={()=>startLv(DAILY_ID)}>
<div style={{flex:1, minWidth:0}}>
<div style={S.lvName}> Daily Practice</div>
<div style={S.lvDesc}>All 31 prepositions · one-tap · keep them sharp</div>
{dailySc && <div style={{fontSize:11,color:'#bbb',marginTop:3}}>{dailySc.c}/{dailySc.t}</div>}
{(() => {
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 (
<div style={{...S.lvRow, opacity:0.7}}>
<div style={{flex:1, minWidth:0}}>
<div style={{...S.lvName, color:'#999'}}>🔒 Daily Practice</div>
<div style={S.lvDesc}>Pass every lesson in this section to unlock · {done}/{lessons.length} done</div>
</div>
</div>
{dailyPct !== null && <div style={{...S.lvPct, color:dailyPct>=70?GREEN:RED}}>{dailyPct}%</div>}
</div>
)}
);
const sc = st.sc?.[daily.id];
const pct = sc ? Math.round(sc.c / sc.t * 100) : null;
return (
<div style={{...S.lvRow, cursor:'pointer', border:`1.5px solid ${GREEN}`, background:'#f3ffe8'}}
onClick={()=>startLv(daily.id)}>
<div style={{flex:1, minWidth:0}}>
<div style={S.lvName}> Daily Practice</div>
<div style={S.lvDesc}>{daily.desc} · keep it sharp</div>
{sc && <div style={{fontSize:11,color:'#bbb',marginTop:3}}>{sc.c}/{sc.t}</div>}
</div>
{pct !== null && <div style={{...S.lvPct, color:pct>=70?GREEN:RED}}>{pct}%</div>}
</div>
);
})()}
{tab === 'prep' && (
<div style={{...S.lvRow, cursor:'pointer'}}
onClick={()=>{ setLearnStep(0); setScr('learn'); }}>
@@ -567,6 +602,8 @@ export default function App() {
<div style={S.hintText}>or press <kbd style={S.kbd}>Enter</kbd></div>
</div>
)
) : quickTick ? (
<QuickTick/>
) : (
q.type === 'pronoun'
? <PronounFeedback q={q} inp={inp} ok={ok}/>
@@ -596,6 +633,8 @@ export default function App() {
</button>
))}
</div>
) : quickTick ? (
<QuickTick/>
) : (
<PrepFeedback q={q} inp={inp} ok={ok}/>
)}
@@ -639,6 +678,8 @@ export default function App() {
</button>
))}
</div>
) : quickTick ? (
<QuickTick/>
) : (
<WechselContextFeedback q={q} inp={inp} ok={ok}/>
)}
@@ -666,6 +707,8 @@ export default function App() {
</div>
<div style={S.hintText}>or press <kbd style={S.kbd}>Enter</kbd></div>
</div>
) : quickTick ? (
<QuickTick/>
) : (
<ContractionFeedback q={q} inp={inp} ok={ok}/>
)}
@@ -683,7 +726,7 @@ export default function App() {
</div>
)}
{scr === 'practice' && rev && (
{scr === 'practice' && rev && !quickTick && (
<div style={{textAlign:'center', marginTop:14}}>
<button style={S.nextBtn} onClick={doNext}>
{res.length >= ROUND ? 'See results' : 'Next'} <span style={{opacity:0.5,fontSize:12,marginLeft:4}}></span>
@@ -704,15 +747,28 @@ export default function App() {
<div style={{color:rc>=7?GREEN:'#d97706', fontSize:15, fontWeight:600, margin:'24px 0'}}>
{rc>=9?'Ausgezeichnet!':rc>=7?'Gut gemacht!':rc>=5?'Keep practicing.':'Review the charts.'}
</div>
{rc>=7 && nextInTab && unlocked(nextInTab.id) && (
<div style={{color:GREEN, fontSize:13, marginBottom:16}}>{nextInTab.name} unlocked.</div>
)}
<button style={S.nextBtn} onClick={()=>startLv(lv)}>Play again</button>
{rc>=7 && nextInTab && unlocked(nextInTab.id) && (
<button style={{...S.nextBtn, background:'#fff', color:GREEN, border:`1.5px solid ${GREEN}`, marginTop:10, marginLeft:8}}
onClick={()=>startLv(nextInTab.id)}>
Next: {nextInTab.name}
</button>
{cur?.daily && nextDaily ? (
<>
<button style={S.nextBtn} onClick={()=>startLv(nextDaily.level.id)}>
Continue: {nextDaily.tab.label} Daily Practice <span style={{opacity:0.5,fontSize:12,marginLeft:4}}></span>
</button>
<div style={{marginTop:14}}>
<button style={S.linkBtn} onClick={()=>startLv(lv)}>play again</button>
</div>
</>
) : (
<>
{rc>=7 && nextInTab && unlocked(nextInTab.id) && (
<div style={{color:GREEN, fontSize:13, marginBottom:16}}>{nextInTab.name} unlocked.</div>
)}
<button style={S.nextBtn} onClick={()=>startLv(lv)}>Play again</button>
{rc>=7 && nextInTab && unlocked(nextInTab.id) && (
<button style={{...S.nextBtn, background:'#fff', color:GREEN, border:`1.5px solid ${GREEN}`, marginTop:10, marginLeft:8}}
onClick={()=>startLv(nextInTab.id)}>
Next: {nextInTab.name}
</button>
)}
</>
)}
<div style={{marginTop:16}}>
<button style={S.linkBtn} onClick={()=>setScr('menu')}>back to menu</button>
@@ -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 (
<div style={{textAlign:'center', marginTop:22, animation:'fadeUp 0.15s ease-out'}}>
<div style={{display:'inline-flex', alignItems:'center', justifyContent:'center',
width:60, height:60, borderRadius:30, background:GREEN, color:'#fff',
fontSize:32, fontWeight:700, lineHeight:1}}></div>
</div>
);
}
const FB_GENDERS = ['masculine','feminine','neuter','plural'];
const FB_CASES = ['nominative','accusative','dative','genitive'];
const normAns = s => (s || '').trim().toLowerCase();
+26 -4
View File
@@ -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 };
}
+26
View File
@@ -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', () => {