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
+44 -4
View File
@@ -60,7 +60,10 @@ const TABS = [
{id:'pronoun', label:'Pronouns'}, {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 CORE_ARTICLE_CASES = ['nominative', 'accusative', 'dative'];
const PAYMENTS_ENABLED = import.meta.env.VITE_PAYMENTS_ENABLED === 'true'; const PAYMENTS_ENABLED = import.meta.env.VITE_PAYMENTS_ENABLED === 'true';
const STRIPE_PLANS = PAYMENTS_ENABLED ? [ const STRIPE_PLANS = PAYMENTS_ENABLED ? [
@@ -220,7 +223,7 @@ export default function App() {
const nextLevelInTab = (id) => { const nextLevelInTab = (id) => {
const lvObj = LEVELS.find(l => l.id === id); const lvObj = LEVELS.find(l => l.id === id);
if (!lvObj) return null; 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); const idx = tabLevels.findIndex(l => l.id === id);
if (idx === -1 || idx === tabLevels.length - 1) return null; if (idx === -1 || idx === tabLevels.length - 1) return null;
return tabLevels[idx + 1]; return tabLevels[idx + 1];
@@ -297,12 +300,30 @@ export default function App() {
return () => window.removeEventListener('keydown', h); 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>; 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 rc = res.filter(Boolean).length;
const cur = LEVELS.find(l=>l.id===lv); 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 chartSection = scr === 'menu' ? tab : (q?.type === 'wechselContext' ? 'prep' : q?.type === 'chunk' ? 'article' : (q?.type || tab));
const nextInTab = nextLevelInTab(lv); 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 ( return (
<div style={S.wrap}> <div style={S.wrap}>
@@ -314,6 +335,14 @@ export default function App() {
? <button style={S.linkBtn} onClick={()=>setScr('menu')}> back</button> ? <button style={S.linkBtn} onClick={()=>setScr('menu')}> back</button>
: <div style={S.logo}>Beug</div>} : <div style={S.logo}>Beug</div>}
<div style={{display:'flex',gap:14,alignItems:'center'}}> <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>} {scr === 'practice' && <button style={S.linkBtn} onClick={()=>setChart(true)}>charts</button>}
{PAYMENTS_ENABLED && hasPaidAccess && <span style={S.stat}>Unlocked</span>} {PAYMENTS_ENABLED && hasPaidAccess && <span style={S.stat}>Unlocked</span>}
<StreakBadge st={st}/> <StreakBadge st={st}/>
@@ -342,6 +371,17 @@ export default function App() {
</div> </div>
<div style={{marginTop:10}}> <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' && ( {tab === 'prep' && (
<div style={{...S.lvRow, cursor:'pointer'}} <div style={{...S.lvRow, cursor:'pointer'}}
onClick={()=>{ setLearnStep(0); setScr('learn'); }}> onClick={()=>{ setLearnStep(0); setScr('learn'); }}>
@@ -351,7 +391,7 @@ export default function App() {
</div> </div>
</div> </div>
)} )}
{LEVELS.filter(l => l.tab === tab).sort((a,b)=>a.id-b.id).map(l => { {LEVELS.filter(l => l.tab === tab && !l.daily).sort((a,b)=>a.id-b.id).map(l => {
const u = unlocked(l.id); const u = unlocked(l.id);
const s = st.sc[l.id]; const s = st.sc[l.id];
const p = s ? Math.round(s.c/s.t*100) : null; const p = s ? Math.round(s.c/s.t*100) : null;
@@ -467,7 +507,7 @@ export default function App() {
color: rev?(ok?GREEN:RED):'#1a1a1a', color: rev?(ok?GREEN:RED):'#1a1a1a',
minWidth: q.type==='poss' ? 110 : 80, minWidth: q.type==='poss' ? 110 : 80,
}}> }}>
{rev ? q.ans : (inp || '\u00A0\u00A0\u00A0\u00A0\u00A0')} {rev ? q.ans : (inp || '     ')}
</span> </span>
)} )}
</span> </span>