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
+75 -12
View File
@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, useRef } from "react";
import { import {
DEF, INDEF, STRONG, WEAK, PREPS, PREPS_BY_CASE, CL, CASE_C, GENDER_C, DEF, INDEF, STRONG, WEAK, PREPS, PREPS_BY_CASE, CL, CASE_C, GENDER_C,
PRONOUNS, POSSESSIVES, POSS_ENDINGS, combinePoss, LEVELS, PRONOUNS, POSSESSIVES, POSS_ENDINGS, combinePoss, LEVELS,
wechselExample, createSession, bumpStreak, currentStreak, wechselExample, createSession, bumpStreak, currentStreak, weekDots,
} from './engine.js'; } from './engine.js';
// ═══════════════════════════════════════════ // ═══════════════════════════════════════════
@@ -60,7 +60,7 @@ const TABS = [
{id:'pronoun', label:'Pronouns'}, {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 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 ? [
@@ -187,6 +187,8 @@ export default function App() {
useEffect(()=>{(async()=>{ useEffect(()=>{(async()=>{
const data = await Storage.get(); 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); if (data) setSt(data);
let nextAccess = Access.get(); let nextAccess = Access.get();
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
@@ -204,6 +206,9 @@ export default function App() {
await Storage.set(s); 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 hasPaidAccess = !!access?.paid;
const hasAccess = !PAYMENTS_ENABLED || hasPaidAccess; const hasAccess = !PAYMENTS_ENABLED || hasPaidAccess;
const gated = id => PAYMENTS_ENABLED && !hasPaidAccess && !FREE_LEVEL_IDS.has(id); 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'}}> <div style={{display:'flex',gap:14,alignItems:'center'}}>
{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>}
<span style={S.stat}>🔥 {currentStreak(st)}</span> <StreakBadge st={st}/>
</div> </div>
</div> </div>
@@ -471,12 +476,14 @@ export default function App() {
<div style={S.translation}>{q.translation}</div> <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> <span style={{color: GENDER_C[q.noun.g], fontWeight:700}}>{DEF[q.noun.g].nominative}</span>
{' '}{q.noun.w} {' '}{q.noun.w}
<span style={S.keyInfoMute}> ({q.noun.en})</span> <span style={S.keyInfoMute}> ({q.noun.en})</span>
</>} </Hideable>
) : (
<div style={S.keyInfo}>
{q.type === 'poss' && <> {q.type === 'poss' && <>
<span style={{fontWeight:700}}>{q.stem}-</span> <span style={{fontWeight:700}}>{q.stem}-</span>
<span style={S.keyInfoMute}> ({q.possMeaning})</span> <span style={S.keyInfoMute}> ({q.possMeaning})</span>
@@ -487,13 +494,14 @@ export default function App() {
<span style={S.keyInfoMute}> · {q.pron.tag}</span> <span style={S.keyInfoMute}> · {q.pron.tag}</span>
</>} </>}
</div> </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> <span style={{color: CASE_C[q.cas], fontWeight:600}}>{CL[q.cas]}</span>
{q.prepKey && <> · {q.prepKey} ({PREPS[q.prepKey].en})</>} {q.prepKey && <> · {q.prepKey} ({PREPS[q.prepKey].en})</>}
{q.tw !== null && q.tw !== undefined && <> · {q.tw ? '→ motion' : '● location'}</>} {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}</>} {q.type === 'poss' && <> · <span style={{color:GENDER_C[q.noun.g]}}>{DEF[q.noun.g].nominative}</span> {q.noun.w}</>}
</div> </Hideable>
{!rev ? ( {!rev ? (
(q.type === 'article' || q.type === 'chunk') ? ( (q.type === 'article' || q.type === 'chunk') ? (
@@ -558,21 +566,36 @@ export default function App() {
{scr === 'practice' && q?.type === 'wechselContext' && ( {scr === 'practice' && q?.type === 'wechselContext' && (
<div style={{textAlign:'center', paddingTop:18}}> <div style={{textAlign:'center', paddingTop:18}}>
<div style={{...S.sentence, animation:shake?'shake 0.3s':'none'}}>{q.sentence}</div> <div style={{...S.sentence, animation:shake?'shake 0.3s':'none'}}>{q.sentence}</div>
<div style={S.translation}>{q.translation}</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}> <div style={S.keyInfo}>
<span style={{fontWeight:700}}>{q.prepKey}</span> <span style={{fontWeight:700}}>{q.prepKey}</span>
<span style={S.keyInfoMute}> ({PREPS[q.prepKey].en})</span> <span style={S.keyInfoMute}> ({PREPS[q.prepKey].en})</span>
</div> </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> <div style={S.secondaryContext}>Wechselpräposition · motion or location?</div>
{!rev ? ( {!rev ? (
<div style={{display:'flex',gap:10,justifyContent:'center',maxWidth:340,margin:'8px auto 0'}}> <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}} <button key={val} style={{...S.choiceBtn, borderColor: CASE_C[val]+'40', minWidth:130}}
onClick={()=>doSubmit(val)}> onClick={()=>doSubmit(val)}>
<div style={{fontWeight:700, color: CASE_C[val]}}>{lab}</div> <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> </button>
))} ))}
</div> </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() { function CoreReference() {
const prepositionGroups = [ const prepositionGroups = [
['accusative', 'Akkusativ', PREPS_BY_CASE.accusative], ['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' }, 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' }, hintText: { fontSize:11, color:'#bbb' },
kbd: { background:'#f0f0f0', padding:'1px 5px', borderRadius:3, fontSize:10, color:'#888', border:'1px solid #e0e0e0' }, 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' }, 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' }, articleGrid: { display:'grid', gridTemplateColumns:'1fr 1fr', gap:10, maxWidth:320, margin:'12px auto 0' },
+39 -62
View File
@@ -230,14 +230,13 @@ export const ALL_PRONOUN_KEYS = PRONOUNS.map(p=>p.key);
const SAFE_SUBJECT_KEYS = ['ich','du','er','wir','ihr']; const SAFE_SUBJECT_KEYS = ['ich','du','er','wir','ihr'];
export const LEVELS = [ export const LEVELS = [
{id:1, tab:'article', name:'Nominativ', desc:'Gender basics: der, die, or das?', cases:['nominative'], preps:[], type:'article', artType:'definite'},
{id:2, tab:'article', name:'Akkusativ', desc:'durch, für, gegen, ohne, um', cases:['accusative'], preps:SENTENCE_ACC_PREPS, type:'article', artType:'definite'}, {id:2, tab:'article', name:'Akkusativ', desc:'durch, für, gegen, ohne, um', cases:['accusative'], preps:SENTENCE_ACC_PREPS, type:'article', artType:'definite'},
{id:3, tab:'article', name:'Dativ', desc:'mit, von, zu, aus, bei, nach, seit...', cases:['dative'], preps:PREPS_BY_CASE.dative, type:'article', artType:'definite'}, {id:3, tab:'article', name:'Dativ', desc:'mit, von, zu, aus, bei, nach, seit...', cases:['dative'], preps:PREPS_BY_CASE.dative, type:'article', artType:'definite'},
{id:4, tab:'article', name:'Wechselpräpositionen', desc:'Two-way: motion (Akk) vs location (Dat)', cases:['accusative','dative'], preps:PREPS_BY_CASE.wechsel, type:'article', artType:'definite', twoWay:true}, {id:4, tab:'article', name:'Wechselpräpositionen', desc:'Two-way: motion (Akk) vs location (Dat)', cases:['accusative','dative'], preps:PREPS_BY_CASE.wechsel, type:'article', artType:'definite', twoWay:true},
{id:4.5, tab:'article', name:'Kontraktionen', desc:'Fused forms in context: ins, am, zum…', cases:['accusative','dative'], type:'chunk', artType:'definite'}, {id:4.5, tab:'article', name:'Kontraktionen', desc:'Fused forms in context: ins, am, zum…', cases:['accusative','dative'], type:'chunk', artType:'definite'},
{id:5, tab:'article', name:'Genitiv', desc:'trotz, während, wegen, statt...', cases:['genitive'], preps:PREPS_BY_CASE.genitive, type:'article', artType:'definite'}, {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:['nominative','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: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:['nominative','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}, {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},
{id:8, tab:'prep', name:'Häufige Präpositionen', desc:'16 most common prepositions', preps:COMMON_PREPS, type:'prep'}, {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:9, tab:'prep', name:'Seltene Präpositionen', desc:'15 less common prepositions', preps:RARE_PREPS, type:'prep'},
@@ -245,14 +244,14 @@ export const LEVELS = [
{id:18, tab:'prep', name:'Wechsel im Kontext', desc:'Real sentences: Akkusativ or Dativ?', preps:PREPS_BY_CASE.wechsel, type:'wechselContext'}, {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: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 — all cases', possessives:['mein','dein','sein'], cases:['nominative','accusative','dative'], type:'poss'}, {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 — all cases', possessives:['ihr','unser','euer'], cases:['nominative','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:['nominative','accusative','dative','genitive'], 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:14, tab:'pronoun', name:'Singular Pronomen', desc:'ich, du, er, sie, es — all cases', pronouns:SG_PRONOUN_KEYS, cases:['nominative','accusative','dative'], type:'pronoun'}, {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:'wir, ihr, sie — all cases', pronouns:PL_PRONOUN_KEYS, cases:['nominative','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:['nominative','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, all cases', pronouns:ALL_PRONOUN_KEYS, cases:['nominative','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'},
]; ];
// Contractions (preposition + article). pref:true = preferred even in writing. // Contractions (preposition + article). pref:true = preferred even in writing.
@@ -513,13 +512,6 @@ const enNoun = (r, det) => `${det}${det ? ' ' : ''}${r.noun.en}`;
// QUESTION GENERATORS // QUESTION GENERATORS
// ═══════════════════════════════════════════ // ═══════════════════════════════════════════
const NOM_TEMPLATES = [
{de:'ist hier', en:'is here'},
{de:'ist da', en:'is there'},
{de:'ist groß', en:'is big'},
{de:'ist klein', en:'is small'},
];
function articleChoices(ans, artType) { function articleChoices(ans, artType) {
const pool = artType === 'indefinite' const pool = artType === 'indefinite'
? ['ein','eine','einen','einem','eines','einer','keine','keinen','keiner'] ? ['ein','eine','einen','einem','eines','einer','keine','keinen','keiner']
@@ -530,26 +522,8 @@ function articleChoices(ans, artType) {
return [ans, ...picked].sort(() => Math.random() - 0.5); return [ans, ...picked].sort(() => Math.random() - 0.5);
} }
function nominativeArticleQ(lv, state) {
const noun = NOUN_BY_NAME[drawBag(state, 'noun', NOUNS.map(n=>n.w), state.last.noun)];
state.last.noun = noun.w;
const tbl = lv.artType === 'indefinite' ? INDEF : DEF;
const ans = tbl[noun.g].nominative;
const t = pick(NOM_TEMPLATES);
return {
type:'article', noun, cas:'nominative', prepKey:null, ans,
sentence:`_____ ${noun.w} ${t.de}.`,
translation:`The ${noun.en} ${t.en}.`,
rule:'Subject of the sentence → Nominativ',
tw:null, artType:lv.artType, choices:articleChoices(ans, lv.artType),
};
}
export function genArticleQ(level, state = newState()) { export function genArticleQ(level, state = newState()) {
const lv = LEVELS.find(l=>l.id===level); const lv = LEVELS.find(l=>l.id===level);
if (lv.id === 1) return nominativeArticleQ(lv, state);
if (lv.mixed && Math.random() < 0.12) return nominativeArticleQ(lv, state);
const prepKey = drawBag(state, 'prep', lv.preps, state.last.prep); const prepKey = drawBag(state, 'prep', lv.preps, state.last.prep);
state.last.prep = prepKey; state.last.prep = prepKey;
const pc = PREPS[prepKey].c; const pc = PREPS[prepKey].c;
@@ -638,15 +612,10 @@ export function genPossQ(level, state = newState()) {
} }
} }
// No preposition: simple subject/object sentence. // No preposition: simple object sentence.
const cas = pick(lv.cases.filter(c => c !== 'genitive')) || lv.cases[0]; const cas = pick(lv.cases.filter(c => c !== 'genitive')) || lv.cases[0];
let noun, sentence, translation; let noun, sentence, translation;
if (cas === 'nominative') { if (cas === 'accusative') {
noun = NOUN_BY_NAME[pickAvoid([...POSSESSABLE], state.last.noun)];
const t = pick(NOM_TEMPLATES);
sentence = `_____ ${noun.w} ${t.de}.`;
translation = `${cap(possMeaning)} ${noun.en} ${t.en}.`;
} else if (cas === 'accusative') {
const t = pick(POSS_ACC_TEMPLATES); const t = pick(POSS_ACC_TEMPLATES);
noun = NOUN_BY_NAME[pickAvoid(t.nouns, state.last.noun)]; noun = NOUN_BY_NAME[pickAvoid(t.nouns, state.last.noun)];
sentence = `Ich ${t.de} _____ ${noun.w}.`; sentence = `Ich ${t.de} _____ ${noun.w}.`;
@@ -668,21 +637,8 @@ export function genPronounQ(level, state = newState()) {
state.last.pron = pronKey; state.last.pron = pronKey;
const pron = PRONOUNS.find(p=>p.key===pronKey); const pron = PRONOUNS.find(p=>p.key===pronKey);
const cas = pick(lv.cases); const cas = pick(lv.cases);
const rawAns = pron[{nominative:'nom',accusative:'acc',dative:'dat'}[cas]]; const ans = pron[{accusative:'acc',dative:'dat'}[cas]];
let sentence, translation, ans;
if (cas === 'nominative') {
const tpl = pick([
{v:'kommen', dePart:' aus Berlin', enPart:' from Berlin'},
{v:'sein', dePart:' müde', enPart:' tired'},
{v:'haben', dePart:' einen Hund', enPart:' a dog'},
]);
const vDe = VC[tpl.v].de[pronKey];
const vEn = VC[tpl.v].en[pronKey];
sentence = `_____ ${vDe}${tpl.dePart}.`;
translation = `${cap(pron.en)} ${vEn}${tpl.enPart}.`;
ans = cap(rawAns);
} else {
const subjCandidates = SAFE_SUBJECT_KEYS.filter(k => k !== pronKey); const subjCandidates = SAFE_SUBJECT_KEYS.filter(k => k !== pronKey);
const subjKey = pick(subjCandidates); const subjKey = pick(subjCandidates);
const subj = PRONOUNS.find(p=>p.key===subjKey); const subj = PRONOUNS.find(p=>p.key===subjKey);
@@ -696,10 +652,8 @@ export function genPronounQ(level, state = newState()) {
const sEn = VC[tpl.v].en[subjKey]; const sEn = VC[tpl.v].en[subjKey];
const dePost = tpl.dePost || ''; const dePost = tpl.dePost || '';
const enPost = tpl.enPost || ''; const enPost = tpl.enPost || '';
sentence = `${cap(subj.nom)} ${sDe} _____${dePost}.`; const sentence = `${cap(subj.nom)} ${sDe} _____${dePost}.`;
translation = `${cap(subj.en)} ${sEn} ${pron.enObj}${enPost}.`; const translation = `${cap(subj.en)} ${sEn} ${pron.enObj}${enPost}.`;
ans = rawAns;
}
return {type:'pronoun', pronKey, pron, cas, ans, sentence, translation}; return {type:'pronoun', pronKey, pron, cas, ans, sentence, translation};
} }
@@ -777,11 +731,34 @@ export function localDateStr(d = new Date()) {
} }
const dayDiff = (a, b) => Math.round((Date.parse(b) - Date.parse(a)) / 864e5); const dayDiff = (a, b) => Math.round((Date.parse(b) - Date.parse(a)) / 864e5);
// Only the trailing window needed for the 7-day dots is kept.
function pruneDays(days, today) {
const keep = {};
for (const d of Object.keys(days)) {
const diff = dayDiff(d, today);
if (diff >= 0 && diff < 14) keep[d] = true;
}
return keep;
}
// Called on every answered question. Idempotent within a day. // Called on every answered question. Idempotent within a day.
export function bumpStreak(st, today = localDateStr()) { export function bumpStreak(st, today = localDateStr()) {
if (st.ld === today) return st; if (st.ld === today) {
if (st.days?.[today]) return st;
return { ...st, days: pruneDays({ ...(st.days || {}), [today]: true }, today) };
}
const streak = st.ld && dayDiff(st.ld, today) === 1 ? (st.streak || 0) + 1 : 1; const streak = st.ld && dayDiff(st.ld, today) === 1 ? (st.streak || 0) + 1 : 1;
return { ...st, streak, ld: today }; const days = pruneDays({ ...(st.days || {}), [today]: true }, today);
return { ...st, streak, ld: today, days };
}
// The last 7 days (oldest first, today last), flagged with whether the
// user practiced on each — drives the little dots next to the streak.
export function weekDots(st, today = localDateStr()) {
return Array.from({ length: 7 }, (_, i) => {
const key = new Date(Date.parse(today) - (6 - i) * 864e5).toISOString().slice(0, 10);
return { date: key, practiced: !!st?.days?.[key], isToday: i === 6 };
});
} }
// What to display: a streak is only alive if the last practice was // What to display: a streak is only alive if the last practice was
+40 -4
View File
@@ -4,7 +4,7 @@ import {
NOUNS, nounForm, LEVELS, CONTRACTIONS, CONTRACTABLE, CHUNK_PREPS, NOUNS, nounForm, LEVELS, CONTRACTIONS, CONTRACTABLE, CHUNK_PREPS,
POSS_ENDINGS, combinePoss, PRONOUNS, VERBS, PREP_FRAMES, POSS_ENDINGS, combinePoss, PRONOUNS, VERBS, PREP_FRAMES,
createSession, gen, genContractionContextQ, newState, buildChunkChoices, createSession, gen, genContractionContextQ, newState, buildChunkChoices,
bumpStreak, currentStreak, bumpStreak, currentStreak, weekDots,
} from './engine.js'; } from './engine.js';
const runSession = (levelId, n) => { const runSession = (levelId, n) => {
@@ -238,9 +238,22 @@ describe('pronoun questions', () => {
it.each([14, 15, 16, 17])('level %s: answer matches the pronoun table', (id) => { it.each([14, 15, 16, 17])('level %s: answer matches the pronoun table', (id) => {
for (const q of runSession(id, 200)) { for (const q of runSession(id, 200)) {
const p = PRONOUNS.find(x => x.key === q.pronKey); const p = PRONOUNS.find(x => x.key === q.pronKey);
const expected = p[{nominative:'nom',accusative:'acc',dative:'dat'}[q.cas]]; expect(q.ans).toBe(p[{accusative:'acc',dative:'dat'}[q.cas]]);
const want = q.cas === 'nominative' ? expected.charAt(0).toUpperCase() + expected.slice(1) : expected; }
expect(q.ans).toBe(want); });
});
// ── Nominative is never tested ──────────────────────────────
describe('no nominative anywhere', () => {
it('no level tests the nominative — you already know der/die/das', () => {
expect(LEVELS.some(l => l.name === 'Nominativ')).toBe(false);
for (const lv of LEVELS) {
if (lv.cases) expect(lv.cases, `level ${lv.id} still lists nominative`).not.toContain('nominative');
for (const q of runSession(lv.id, 150)) {
if (q.cas) expect(q.cas, `level ${lv.id} generated a nominative question`).not.toBe('nominative');
expect(q.ans).not.toBe('nominative');
}
} }
}); });
}); });
@@ -289,6 +302,29 @@ describe('streaks', () => {
expect(st.streak).toBe(10); expect(st.streak).toBe(10);
}); });
it('records practice days and prunes old ones', () => {
let st = bumpStreak({ streak: 0, ld: null }, '2026-07-01');
expect(st.days['2026-07-01']).toBe(true);
st = bumpStreak(st, '2026-07-01'); // same day, already recorded
expect(Object.keys(st.days).length).toBe(1);
st = bumpStreak(st, '2026-07-02');
expect(st.days['2026-07-01']).toBe(true);
expect(st.days['2026-07-02']).toBe(true);
st = bumpStreak(st, '2026-07-20'); // far in the future → old days pruned
expect(st.days['2026-07-01']).toBeUndefined();
expect(st.days['2026-07-20']).toBe(true);
});
it('weekDots reports the last 7 days, today last', () => {
const st = { days: { '2026-07-10': true, '2026-07-12': true, '2026-07-14': true } };
const dots = weekDots(st, '2026-07-14');
expect(dots.length).toBe(7);
expect(dots[0].date).toBe('2026-07-08');
expect(dots[6].date).toBe('2026-07-14');
expect(dots[6].isToday).toBe(true);
expect(dots.map(d => d.practiced)).toEqual([false, false, true, false, true, false, true]);
});
it('displays 0 once the streak has lapsed, without needing a new answer', () => { it('displays 0 once the streak has lapsed, without needing a new answer', () => {
const st = { streak: 7, ld: '2026-07-01' }; const st = { streak: 7, ld: '2026-07-01' };
expect(currentStreak(st, '2026-07-01')).toBe(7); expect(currentStreak(st, '2026-07-01')).toBe(7);