Remove nominative testing, add hideable hints and week streak dots

- Remove the Nominativ level and drop nominative-case testing from mixed/
  possessive/pronoun levels (nominative kept only in reference charts)
- Add persistent hide/show toggles on the two giveaway hint lines in
  practice (noun gender, case/preposition info); state persists in localStorage
- De-spoil "Wechsel im Kontext": remove motion/location hint from answer
  buttons; put sentence translation + preposition meaning behind a toggle
- Add a 7-day practice-history dot strip beside the streak counter
- Extend engine + tests (61 vitest tests)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
fightme-cmd
2026-07-14 12:36:08 +02:00
parent d30e213104
commit cd0b6c800d
3 changed files with 179 additions and 103 deletions
+89 -26
View File
@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, useRef } from "react";
import {
DEF, INDEF, STRONG, WEAK, PREPS, PREPS_BY_CASE, CL, CASE_C, GENDER_C,
PRONOUNS, POSSESSIVES, POSS_ENDINGS, combinePoss, LEVELS,
wechselExample, createSession, bumpStreak, currentStreak,
wechselExample, createSession, bumpStreak, currentStreak, weekDots,
} from './engine.js';
// ═══════════════════════════════════════════
@@ -60,7 +60,7 @@ const TABS = [
{id:'pronoun', label:'Pronouns'},
];
const FREE_LEVEL_IDS = new Set([1, 2, 3, 4, 8, 9, 10, 18]);
const FREE_LEVEL_IDS = new Set([2, 3, 4, 8, 9, 10, 18]);
const CORE_ARTICLE_CASES = ['nominative', 'accusative', 'dative'];
const PAYMENTS_ENABLED = import.meta.env.VITE_PAYMENTS_ENABLED === 'true';
const STRIPE_PLANS = PAYMENTS_ENABLED ? [
@@ -187,6 +187,8 @@ export default function App() {
useEffect(()=>{(async()=>{
const data = await Storage.get();
// Seed the practice-day history for users from before it was tracked.
if (data?.ld && !data.days) data.days = { [data.ld]: true };
if (data) setSt(data);
let nextAccess = Access.get();
const params = new URLSearchParams(window.location.search);
@@ -204,6 +206,9 @@ export default function App() {
await Storage.set(s);
},[]);
const hintHidden = k => !!st.hide?.[k];
const toggleHint = k => save({...st, hide:{...(st.hide||{}), [k]: !st.hide?.[k]}});
const hasPaidAccess = !!access?.paid;
const hasAccess = !PAYMENTS_ENABLED || hasPaidAccess;
const gated = id => PAYMENTS_ENABLED && !hasPaidAccess && !FREE_LEVEL_IDS.has(id);
@@ -311,7 +316,7 @@ export default function App() {
<div style={{display:'flex',gap:14,alignItems:'center'}}>
{scr === 'practice' && <button style={S.linkBtn} onClick={()=>setChart(true)}>charts</button>}
{PAYMENTS_ENABLED && hasPaidAccess && <span style={S.stat}>Unlocked</span>}
<span style={S.stat}>🔥 {currentStreak(st)}</span>
<StreakBadge st={st}/>
</div>
</div>
@@ -471,29 +476,32 @@ export default function App() {
<div style={S.translation}>{q.translation}</div>
<div style={S.keyInfo}>
{(q.type === 'article' || q.type === 'chunk') && <>
{(q.type === 'article' || q.type === 'chunk') ? (
<Hideable hidden={hintHidden('gender')} onToggle={()=>toggleHint('gender')} label="gender" style={S.keyInfo}>
<span style={{color: GENDER_C[q.noun.g], fontWeight:700}}>{DEF[q.noun.g].nominative}</span>
{' '}{q.noun.w}
<span style={S.keyInfoMute}> ({q.noun.en})</span>
</>}
{q.type === 'poss' && <>
<span style={{fontWeight:700}}>{q.stem}-</span>
<span style={S.keyInfoMute}> ({q.possMeaning})</span>
</>}
{q.type === 'pronoun' && <>
<span style={{fontWeight:700}}>{q.pron.nom}</span>
<span style={S.keyInfoMute}> ({q.pron.en})</span>
<span style={S.keyInfoMute}> · {q.pron.tag}</span>
</>}
</div>
</Hideable>
) : (
<div style={S.keyInfo}>
{q.type === 'poss' && <>
<span style={{fontWeight:700}}>{q.stem}-</span>
<span style={S.keyInfoMute}> ({q.possMeaning})</span>
</>}
{q.type === 'pronoun' && <>
<span style={{fontWeight:700}}>{q.pron.nom}</span>
<span style={S.keyInfoMute}> ({q.pron.en})</span>
<span style={S.keyInfoMute}> · {q.pron.tag}</span>
</>}
</div>
)}
<div style={S.secondaryContext}>
<Hideable hidden={hintHidden('caseInfo')} onToggle={()=>toggleHint('caseInfo')} label="case" style={S.secondaryContext}>
<span style={{color: CASE_C[q.cas], fontWeight:600}}>{CL[q.cas]}</span>
{q.prepKey && <> · {q.prepKey} ({PREPS[q.prepKey].en})</>}
{q.tw !== null && q.tw !== undefined && <> · {q.tw ? '→ motion' : '● location'}</>}
{q.type === 'poss' && <> · <span style={{color:GENDER_C[q.noun.g]}}>{DEF[q.noun.g].nominative}</span> {q.noun.w}</>}
</div>
</Hideable>
{!rev ? (
(q.type === 'article' || q.type === 'chunk') ? (
@@ -558,21 +566,36 @@ export default function App() {
{scr === 'practice' && q?.type === 'wechselContext' && (
<div style={{textAlign:'center', paddingTop:18}}>
<div style={{...S.sentence, animation:shake?'shake 0.3s':'none'}}>{q.sentence}</div>
<div style={S.translation}>{q.translation}</div>
<div style={S.keyInfo}>
<span style={{fontWeight:700}}>{q.prepKey}</span>
<span style={S.keyInfoMute}> ({PREPS[q.prepKey].en})</span>
</div>
{(!hintHidden('meaning') || rev) ? (
<>
<div style={S.translation}>
{q.translation}
{!rev && <button style={{...S.hintToggle, marginLeft:8}} onClick={()=>toggleHint('meaning')}>hide</button>}
</div>
<div style={S.keyInfo}>
<span style={{fontWeight:700}}>{q.prepKey}</span>
<span style={S.keyInfoMute}> ({PREPS[q.prepKey].en})</span>
</div>
</>
) : (
<>
<div style={S.translation}>
<button style={S.hintToggle} onClick={()=>toggleHint('meaning')}>show translation</button>
</div>
<div style={S.keyInfo}>
<span style={{fontWeight:700}}>{q.prepKey}</span>
</div>
</>
)}
<div style={S.secondaryContext}>Wechselpräposition · motion or location?</div>
{!rev ? (
<div style={{display:'flex',gap:10,justifyContent:'center',maxWidth:340,margin:'8px auto 0'}}>
{[['accusative','Akkusativ','1','motion'],['dative','Dativ','2','location']].map(([val,lab,key,hint]) => (
{[['accusative','Akkusativ','1'],['dative','Dativ','2']].map(([val,lab,key]) => (
<button key={val} style={{...S.choiceBtn, borderColor: CASE_C[val]+'40', minWidth:130}}
onClick={()=>doSubmit(val)}>
<div style={{fontWeight:700, color: CASE_C[val]}}>{lab}</div>
<div style={{fontSize:11,color:'#999',marginTop:2}}>{hint} · <kbd style={S.kbdSmall}>{key}</kbd></div>
<div style={{fontSize:11,color:'#999',marginTop:2}}><kbd style={S.kbdSmall}>{key}</kbd></div>
</button>
))}
</div>
@@ -711,6 +734,45 @@ function FeatureRequestFooter() {
);
}
// A hint line with a persistent hide/show toggle. Hidden stays hidden
// (across sessions) until the user shows it again — for authentic testing.
function Hideable({ hidden, onToggle, label, style, children }) {
if (hidden) {
return (
<div style={style}>
<button style={S.hintToggle} onClick={onToggle}>show {label}</button>
</div>
);
}
return (
<div style={style}>
{children}
<button style={{...S.hintToggle, marginLeft:8}} onClick={onToggle}>hide</button>
</div>
);
}
function StreakBadge({ st }) {
const dots = weekDots(st);
const n = currentStreak(st);
const practiced = dots.filter(d=>d.practiced).length;
return (
<span style={{display:'flex', alignItems:'center', gap:7}}
title={`Practiced ${practiced} of the last 7 days · ${n}-day streak`}>
<span style={{display:'flex', gap:3, alignItems:'center'}}>
{dots.map(d=>(
<span key={d.date} style={{
width:6, height:6, borderRadius:3,
background: d.practiced ? GREEN : '#ddd',
boxShadow: d.isToday ? '0 0 0 1.5px #fff, 0 0 0 2.5px #ccc' : 'none',
}}/>
))}
</span>
<span style={S.stat}>🔥 {n}</span>
</span>
);
}
function CoreReference() {
const prepositionGroups = [
['accusative', 'Akkusativ', PREPS_BY_CASE.accusative],
@@ -1196,6 +1258,7 @@ const S = {
checkBtn: { padding:'12px 20px', borderRadius:8, background:GREEN, color:'#fff', border:'none', fontSize:14, fontWeight:700, cursor:'pointer', fontFamily:'inherit' },
hintText: { fontSize:11, color:'#bbb' },
kbd: { background:'#f0f0f0', padding:'1px 5px', borderRadius:3, fontSize:10, color:'#888', border:'1px solid #e0e0e0' },
hintToggle: { background:'none', border:'none', padding:0, color:'#c2c2c2', fontSize:10, fontWeight:600, textTransform:'uppercase', letterSpacing:0.5, cursor:'pointer', fontFamily:'inherit', textDecoration:'underline', textDecorationStyle:'dotted', textUnderlineOffset:3 },
kbdSmall: { background:'#f0f0f0', padding:'1px 4px', borderRadius:3, fontSize:9, color:'#888', border:'1px solid #e0e0e0' },
articleGrid: { display:'grid', gridTemplateColumns:'1fr 1fr', gap:10, maxWidth:320, margin:'12px auto 0' },