Add pinned Daily Practice card + one-tap mode

- Daily Practice (all 31 prepositions) is pinned to the top of the
  Prepositions tab once the "Alle Präpositionen" lesson is passed
  (>=70% over a full round).
- One-tap mode: after answering, the app flashes right/wrong and
  auto-advances. Always on in Daily Practice; a persisted "One-tap"
  toggle in the practice header enables it for any other level.
This commit is contained in:
2026-07-17 07:23:36 +00:00
parent cc19ea09e0
commit aa781b72bb
+72 -32
View File
@@ -60,7 +60,10 @@ const TABS = [
{id:'pronoun', label:'Pronouns'},
];
const FREE_LEVEL_IDS = new Set([2, 3, 4, 8, 9, 10, 18]);
// 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]);
const CORE_ARTICLE_CASES = ['nominative', 'accusative', 'dative'];
const PAYMENTS_ENABLED = import.meta.env.VITE_PAYMENTS_ENABLED === 'true';
const STRIPE_PLANS = PAYMENTS_ENABLED ? [
@@ -220,7 +223,7 @@ export default function App() {
const nextLevelInTab = (id) => {
const lvObj = LEVELS.find(l => l.id === id);
if (!lvObj) return null;
const tabLevels = LEVELS.filter(l => l.tab === lvObj.tab).sort((a,b) => a.id - b.id);
const tabLevels = LEVELS.filter(l => l.tab === lvObj.tab && !l.daily).sort((a,b) => a.id - b.id);
const idx = tabLevels.findIndex(l => l.id === id);
if (idx === -1 || idx === tabLevels.length - 1) return null;
return tabLevels[idx + 1];
@@ -297,12 +300,30 @@ 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.
useEffect(() => {
if (scr !== 'practice' || !rev) return;
const quick = lv === DAILY_ID || !!st.auto;
if (!quick) return;
const t = setTimeout(() => { nextRef.current?.(); }, ok ? 750 : 1650);
return () => clearTimeout(t);
}, [scr, rev, ok, lv, st.auto]);
if (!loaded) return <div style={S.wrap}><p style={{color:'#999',textAlign:'center',paddingTop:120}}>Loading...</p></div>;
const rc = res.filter(Boolean).length;
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;
return (
<div style={S.wrap}>
@@ -314,6 +335,14 @@ export default function App() {
? <button style={S.linkBtn} onClick={()=>setScr('menu')}> back</button>
: <div style={S.logo}>Beug</div>}
<div style={{display:'flex',gap:14,alignItems:'center'}}>
{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}); }}>
{quickActive ? 'One-tap' : 'One-tap off'}
</button>
)}
{scr === 'practice' && <button style={S.linkBtn} onClick={()=>setChart(true)}>charts</button>}
{PAYMENTS_ENABLED && hasPaidAccess && <span style={S.stat}>Unlocked</span>}
<StreakBadge st={st}/>
@@ -332,36 +361,47 @@ export default function App() {
{scr === 'menu' && (
<div>
<div style={S.tabs}>
{TABS.map(t => (
<button key={t.id}
style={{...S.tab, ...(tab===t.id ? S.tabActive : {})}}
onClick={()=>setTab(t.id)}>
{t.label}
</button>
))}
{TABS.map(t => (
<button key={t.id}
style={{...S.tab, ...(tab===t.id ? S.tabActive : {})}}
onClick={()=>setTab(t.id)}>
{t.label}
</button>
))}
</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>}
</div>
{dailyPct !== null && <div style={{...S.lvPct, color:dailyPct>=70?GREEN:RED}}>{dailyPct}%</div>}
</div>
)}
{tab === 'prep' && (
<div style={{...S.lvRow, cursor:'pointer'}}
onClick={()=>{ setLearnStep(0); setScr('learn'); }}>
onClick={()=>{ setLearnStep(0); setScr('learn'); }}>
<div style={{flex:1, minWidth:0}}>
<div style={S.lvName}>📚 Lernen</div>
<div style={S.lvDesc}>Chunk the lists first Akkusativ, Dativ, Wechsel, Genitiv</div>
</div>
</div>
)}
{LEVELS.filter(l => l.tab === tab).sort((a,b)=>a.id-b.id).map(l => {
const u = unlocked(l.id);
const s = st.sc[l.id];
const p = s ? Math.round(s.c/s.t*100) : null;
return (
<div key={l.id}
style={{...S.lvRow, ...(u ? {} : S.lvRowBlurred), cursor:'pointer'}}
onClick={()=>startLv(l.id)}>
<div style={{flex:1, minWidth:0}}>
<div style={S.lvName}>{l.name}</div>
<div style={S.lvDesc}>{l.desc}</div>
{LEVELS.filter(l => l.tab === tab && !l.daily).sort((a,b)=>a.id-b.id).map(l => {
const u = unlocked(l.id);
const s = st.sc[l.id];
const p = s ? Math.round(s.c/s.t*100) : null;
return (
<div key={l.id}
style={{...S.lvRow, ...(u ? {} : S.lvRowBlurred), cursor:'pointer'}}
onClick={()=>startLv(l.id)}>
<div style={{flex:1, minWidth:0}}>
<div style={S.lvName}>{l.name}</div>
<div style={S.lvDesc}>{l.desc}</div>
{s && <div style={{fontSize:11,color:'#bbb',marginTop:3}}>{s.c}/{s.t}</div>}
</div>
{p !== null && <div style={{...S.lvPct, color:p>=70?GREEN:RED}}>{p}%</div>}
@@ -437,11 +477,11 @@ export default function App() {
<audio controls preload="none" src={a.src} style={{width:'100%', height:36}}/>
</div>
))}
</div>
)}
</div>
</div>
)}
</div>
<div style={{display:'flex', gap:10, justifyContent:'center', marginTop:18}}>
<div style={{display:'flex', gap:10, justifyContent:'center', marginTop:18}}>
{learnStep > 0 && (
<button style={{...S.nextBtn, background:'#fff', color:'#666', border:'1px solid #ddd'}}
onClick={()=>setLearnStep(s=>s-1)}> Zurück</button>
@@ -467,7 +507,7 @@ export default function App() {
color: rev?(ok?GREEN:RED):'#1a1a1a',
minWidth: q.type==='poss' ? 110 : 80,
}}>
{rev ? q.ans : (inp || '\u00A0\u00A0\u00A0\u00A0\u00A0')}
{rev ? q.ans : (inp || '     ')}
</span>
)}
</span>
@@ -652,8 +692,8 @@ export default function App() {
)}
{/* SUMMARY */}
{scr === 'summary' && (
<div style={{textAlign:'center', paddingTop:60}}>
{scr === 'summary' && (
<div style={{textAlign:'center', paddingTop:60}}>
<div style={{fontSize:12,color:'#999',textTransform:'uppercase',letterSpacing:2,fontWeight:600}}>Complete</div>
<div style={{fontSize:56, fontWeight:700, color:'#1a1a1a', margin:'10px 0'}}>
{rc}<span style={{fontSize:24, color:'#bbb'}}>/{ROUND}</span>
@@ -677,11 +717,11 @@ export default function App() {
<div style={{marginTop:16}}>
<button style={S.linkBtn} onClick={()=>setScr('menu')}>back to menu</button>
</div>
</div>
)}
</div>
)}
{scr !== 'practice' && <FeatureRequestFooter />}
</div>
{scr !== 'practice' && <FeatureRequestFooter />}
</div>
{chart && <ChartModal section={chartSection} q={q} onClose={()=>setChart(false)}/>}