Add testable question engine, streak fixes, mobile case grid

- New question-generation engine (shuffle-bag preposition selection,
  per-preposition sentence frames, genitive noun forms) in src/engine.js
- Streak fixes: local-date handling, lapse display
- Footer copy: "Get in touch"
- 2x2 mobile case grid
- Vitest suite (60 tests) covering the engine

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
fightme-cmd
2026-07-10 14:12:42 +02:00
parent d30350e7e2
commit d30e213104
6 changed files with 2795 additions and 486 deletions
+22 -480
View File
@@ -1,4 +1,9 @@
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,
} from './engine.js';
// ═══════════════════════════════════════════
// STORAGE (window.storage in Claude, localStorage when deployed)
@@ -48,182 +53,6 @@ const Access = {
}
};
// ═══════════════════════════════════════════
// DATA
// ═══════════════════════════════════════════
const DEF = {
masculine:{nominative:'der',accusative:'den',dative:'dem',genitive:'des'},
feminine: {nominative:'die',accusative:'die',dative:'der',genitive:'der'},
neuter: {nominative:'das',accusative:'das',dative:'dem',genitive:'des'},
plural: {nominative:'die',accusative:'die',dative:'den',genitive:'der'},
};
const INDEF = {
masculine:{nominative:'ein', accusative:'einen',dative:'einem',genitive:'eines'},
feminine: {nominative:'eine',accusative:'eine', dative:'einer',genitive:'einer'},
neuter: {nominative:'ein', accusative:'ein', dative:'einem',genitive:'eines'},
plural: {nominative:'keine',accusative:'keine',dative:'keinen',genitive:'keiner'},
};
const STRONG = {
masculine:{nominative:'r',accusative:'n',dative:'m',genitive:'n'},
feminine: {nominative:'e',accusative:'e',dative:'r',genitive:'r'},
neuter: {nominative:'s',accusative:'s',dative:'m',genitive:'n'},
plural: {nominative:'e',accusative:'e',dative:'n',genitive:'r'},
};
const WEAK = {
masculine:{nominative:'e',accusative:'n',dative:'n',genitive:'n'},
feminine: {nominative:'e',accusative:'e',dative:'n',genitive:'n'},
neuter: {nominative:'e',accusative:'e',dative:'n',genitive:'n'},
plural: {nominative:'n',accusative:'n',dative:'n',genitive:'n'},
};
const PREPS = {
durch:{c:'accusative',en:'through'}, für:{c:'accusative',en:'for'},
gegen:{c:'accusative',en:'against'}, ohne:{c:'accusative',en:'without'},
um:{c:'accusative',en:'around'}, bis:{c:'accusative',en:'until'},
aus:{c:'dative',en:'out of'}, bei:{c:'dative',en:'at'},
mit:{c:'dative',en:'with'}, nach:{c:'dative',en:'to/after'},
seit:{c:'dative',en:'since'}, von:{c:'dative',en:'from'},
zu:{c:'dative',en:'to'}, außer:{c:'dative',en:'besides'},
gegenüber:{c:'dative',en:'opposite'},
trotz:{c:'genitive',en:'despite'}, während:{c:'genitive',en:'during'},
wegen:{c:'genitive',en:'because of'}, statt:{c:'genitive',en:'instead of'},
anstatt:{c:'genitive',en:'instead of'}, außerhalb:{c:'genitive',en:'outside'},
innerhalb:{c:'genitive',en:'inside'},
an:{c:'wechsel',en:'at/on',m:'to',l:'at'},
auf:{c:'wechsel',en:'on',m:'onto',l:'on'},
hinter:{c:'wechsel',en:'behind',m:'behind',l:'behind'},
in:{c:'wechsel',en:'in/into',m:'into',l:'in'},
neben:{c:'wechsel',en:'next to',m:'next to',l:'next to'},
über:{c:'wechsel',en:'over/above',m:'over',l:'above'},
unter:{c:'wechsel',en:'under',m:'under',l:'under'},
vor:{c:'wechsel',en:'in front of',m:'in front of',l:'in front of'},
zwischen:{c:'wechsel',en:'between',m:'between',l:'between'},
};
const PREPS_BY_CASE = {
accusative:['durch','für','gegen','ohne','um','bis'],
dative:['aus','bei','mit','nach','seit','von','zu','außer','gegenüber'],
genitive:['trotz','während','wegen','statt','anstatt','außerhalb','innerhalb'],
wechsel:['an','auf','hinter','neben','unter','über','in','vor','zwischen'],
};
const COMMON_PREPS = ['mit','von','zu','in','an','auf','für','durch','ohne','um','aus','bei','nach','vor','über','wegen'];
const RARE_PREPS = ['gegen','bis','seit','außer','gegenüber','hinter','neben','unter','zwischen','trotz','während','statt','anstatt','außerhalb','innerhalb'];
const ALL_PREPS = [...COMMON_PREPS, ...RARE_PREPS];
const WECHSEL_EX = {
an: {noun:'Tür', g:'feminine', en:'door', mot:'to', loc:'at'},
auf: {noun:'Tisch', g:'masculine', en:'table', mot:'onto', loc:'on'},
hinter: {noun:'Haus', g:'neuter', en:'house', mot:'behind', loc:'behind'},
in: {noun:'Park', g:'masculine', en:'park', mot:'into', loc:'in'},
neben: {noun:'Tisch', g:'masculine', en:'table', mot:'next to', loc:'next to'},
über: {noun:'Brücke', g:'feminine', en:'bridge', mot:'over', loc:'above'},
unter: {noun:'Brücke', g:'feminine', en:'bridge', mot:'under', loc:'under'},
vor: {noun:'Haus', g:'neuter', en:'house', mot:'in front of', loc:'in front of'},
zwischen: {noun:'Bäume', g:'plural', en:'trees', mot:'between', loc:'between', plDat:'n'},
};
function wechselExample(prepKey, isMotion) {
const ex = WECHSEL_EX[prepKey];
const cas = isMotion ? 'accusative' : 'dative';
const art = DEF[ex.g][cas];
let n = ex.noun;
if (ex.g === 'plural' && !isMotion && ex.plDat) n += ex.plDat;
return { de: `${prepKey} ${art} ${n}`, en: `${isMotion ? ex.mot : ex.loc} the ${ex.en}` };
}
const NOUNS = [
{w:'Hund',g:'masculine',en:'dog'},{w:'Mann',g:'masculine',en:'man'},
{w:'Tisch',g:'masculine',en:'table'},{w:'Park',g:'masculine',en:'park'},
{w:'Arzt',g:'masculine',en:'doctor'},{w:'Freund',g:'masculine',en:'friend'},
{w:'Baum',g:'masculine',en:'tree'},{w:'Garten',g:'masculine',en:'garden'},
{w:'Stuhl',g:'masculine',en:'chair'},{w:'Bruder',g:'masculine',en:'brother'},
{w:'Lehrer',g:'masculine',en:'teacher'},{w:'Vater',g:'masculine',en:'father'},
{w:'Frau',g:'feminine',en:'woman'},{w:'Katze',g:'feminine',en:'cat'},
{w:'Schule',g:'feminine',en:'school'},{w:'Stadt',g:'feminine',en:'city'},
{w:'Straße',g:'feminine',en:'street'},{w:'Tür',g:'feminine',en:'door'},
{w:'Schwester',g:'feminine',en:'sister'},{w:'Mutter',g:'feminine',en:'mother'},
{w:'Tochter',g:'feminine',en:'daughter'},{w:'Musik',g:'feminine',en:'music'},
{w:'Nacht',g:'feminine',en:'night'},{w:'Woche',g:'feminine',en:'week'},
{w:'Kind',g:'neuter',en:'child'},{w:'Haus',g:'neuter',en:'house'},
{w:'Buch',g:'neuter',en:'book'},{w:'Auto',g:'neuter',en:'car'},
{w:'Mädchen',g:'neuter',en:'girl'},{w:'Fenster',g:'neuter',en:'window'},
{w:'Hotel',g:'neuter',en:'hotel'},{w:'Museum',g:'neuter',en:'museum'},
{w:'Büro',g:'neuter',en:'office'},{w:'Restaurant',g:'neuter',en:'restaurant'},
];
const PRONOUNS = [
{key:'ich', nom:'ich', acc:'mich', dat:'mir', en:'I', enObj:'me', tag:'1st person', num:'sg'},
{key:'du', nom:'du', acc:'dich', dat:'dir', en:'you', enObj:'you', tag:'2nd informal', num:'sg'},
{key:'Sie_sg', nom:'Sie', acc:'Sie', dat:'Ihnen', en:'you', enObj:'you', tag:'2nd formal', num:'sg'},
{key:'er', nom:'er', acc:'ihn', dat:'ihm', en:'he', enObj:'him', tag:'3rd masculine', num:'sg'},
{key:'sie_sg', nom:'sie', acc:'sie', dat:'ihr', en:'she', enObj:'her', tag:'3rd feminine', num:'sg'},
{key:'es', nom:'es', acc:'es', dat:'ihm', en:'it', enObj:'it', tag:'3rd neuter', num:'sg'},
{key:'wir', nom:'wir', acc:'uns', dat:'uns', en:'we', enObj:'us', tag:'1st plural', num:'pl'},
{key:'ihr', nom:'ihr', acc:'euch', dat:'euch', en:"y'all", enObj:"y'all", tag:'2nd inf plural', num:'pl'},
{key:'Sie_pl', nom:'Sie', acc:'Sie', dat:'Ihnen', en:'you (formal)', enObj:'you (formal)', tag:'2nd formal pl', num:'pl'},
{key:'sie_pl', nom:'sie', acc:'sie', dat:'ihnen', en:'they', enObj:'them', tag:'3rd plural', num:'pl'},
];
const POSSESSIVES = [
{stem:'mein', en:'my'},
{stem:'dein', en:'your (informal)'},
{stem:'sein', en:'his / its'},
{stem:'ihr', en:'her / their'},
{stem:'unser', en:'our'},
{stem:'euer', en:"y'all's"},
];
const POSS_ENDINGS = {
masculine:{nominative:'', accusative:'en', dative:'em', genitive:'es'},
feminine: {nominative:'e', accusative:'e', dative:'er', genitive:'er'},
neuter: {nominative:'', accusative:'', dative:'em', genitive:'es'},
plural: {nominative:'e', accusative:'e', dative:'en', genitive:'er'},
};
const combinePoss = (stem, ending) => (stem === 'euer' && ending) ? 'eur' + ending : stem + ending;
const CL = {nominative:'Nominativ',accusative:'Akkusativ',dative:'Dativ',genitive:'Genitiv',wechsel:'Wechsel'};
const CASE_C = {nominative:'#2563eb',accusative:'#dc2626',dative:'#ea580c',genitive:'#16a34a',wechsel:'#7c3aed'};
const GENDER_C = {masculine:'#16a34a',feminine:'#db2777',neuter:'#2563eb',plural:'#7c3aed'};
const VERBS = {
sein: {de:{Er:'ist',Sie:'ist',Wir:'sind',Ich:'bin'}, en:{Er:'is',Sie:'is',Wir:'are',Ich:'am'}},
stehen: {de:{Er:'steht',Sie:'steht',Wir:'stehen',Ich:'stehe'}, en:{Er:'is standing',Sie:'is standing',Wir:'are standing',Ich:'am standing'}},
sitzen: {de:{Er:'sitzt',Sie:'sitzt',Wir:'sitzen',Ich:'sitze'}, en:{Er:'is sitting',Sie:'is sitting',Wir:'are sitting',Ich:'am sitting'}},
warten: {de:{Er:'wartet',Sie:'wartet',Wir:'warten',Ich:'warte'}, en:{Er:'waits',Sie:'waits',Wir:'wait',Ich:'wait'}},
wohnen: {de:{Er:'wohnt',Sie:'wohnt',Wir:'wohnen',Ich:'wohne'}, en:{Er:'lives',Sie:'lives',Wir:'live',Ich:'live'}},
gehen: {de:{Er:'geht',Sie:'geht',Wir:'gehen',Ich:'gehe'}, en:{Er:'walks',Sie:'walks',Wir:'walk',Ich:'walk'}},
laufen: {de:{Er:'läuft',Sie:'läuft',Wir:'laufen',Ich:'laufe'}, en:{Er:'runs',Sie:'runs',Wir:'run',Ich:'run'}},
fahren: {de:{Er:'fährt',Sie:'fährt',Wir:'fahren',Ich:'fahre'}, en:{Er:'drives',Sie:'drives',Wir:'drive',Ich:'drive'}},
kommen: {de:{Er:'kommt',Sie:'kommt',Wir:'kommen',Ich:'komme'}, en:{Er:'comes',Sie:'comes',Wir:'come',Ich:'come'}},
reisen: {de:{Er:'reist',Sie:'reist',Wir:'reisen',Ich:'reise'}, en:{Er:'travels',Sie:'travels',Wir:'travel',Ich:'travel'}},
};
const SUBJ_EN = {Er:'He',Sie:'She',Wir:'We',Ich:'I'};
const MOTION_VS = ['gehen','laufen','fahren','kommen','reisen'];
const LOCATION_VS = ['sein','stehen','sitzen','warten','wohnen'];
function mkv(stems) {
return {
ich: stems.ich, du: stems.du,
er: stems.er, sie_sg: stems.er, es: stems.er,
wir: stems.wir, sie_pl: stems.wir, Sie_sg: stems.wir, Sie_pl: stems.wir,
ihr: stems.ihr,
};
}
const VC = {
kommen: {de:mkv({ich:'komme',du:'kommst',er:'kommt',wir:'kommen',ihr:'kommt'}), en:mkv({ich:'come',du:'come',er:'comes',wir:'come',ihr:'come'})},
sein: {de:mkv({ich:'bin',du:'bist',er:'ist',wir:'sind',ihr:'seid'}), en:mkv({ich:'am',du:'are',er:'is',wir:'are',ihr:'are'})},
haben: {de:mkv({ich:'habe',du:'hast',er:'hat',wir:'haben',ihr:'habt'}), en:mkv({ich:'have',du:'have',er:'has',wir:'have',ihr:'have'})},
sehen: {de:mkv({ich:'sehe',du:'siehst',er:'sieht',wir:'sehen',ihr:'seht'}), en:mkv({ich:'see',du:'see',er:'sees',wir:'see',ihr:'see'})},
kennen: {de:mkv({ich:'kenne',du:'kennst',er:'kennt',wir:'kennen',ihr:'kennt'}), en:mkv({ich:'know',du:'know',er:'knows',wir:'know',ihr:'know'})},
lieben: {de:mkv({ich:'liebe',du:'liebst',er:'liebt',wir:'lieben',ihr:'liebt'}), en:mkv({ich:'love',du:'love',er:'loves',wir:'love',ihr:'love'})},
helfen: {de:mkv({ich:'helfe',du:'hilfst',er:'hilft',wir:'helfen',ihr:'helft'}), en:mkv({ich:'help',du:'help',er:'helps',wir:'help',ihr:'help'})},
geben: {de:mkv({ich:'gebe',du:'gibst',er:'gibt',wir:'geben',ihr:'gebt'}), en:mkv({ich:'give',du:'give',er:'gives',wir:'give',ihr:'give'})},
};
const SG_PRONOUN_KEYS = ['ich','du','er','sie_sg','es'];
const PL_PRONOUN_KEYS = ['wir','ihr','sie_pl'];
const FORMAL_PRONOUN_KEYS = ['Sie_sg','Sie_pl'];
const ALL_PRONOUN_KEYS = PRONOUNS.map(p=>p.key);
const SAFE_SUBJECT_KEYS = ['ich','du','er','wir','ihr'];
const TABS = [
{id:'prep', label:'Prepositions'},
{id:'article', label:'Articles'},
@@ -264,32 +93,6 @@ const STRIPE_PLANS = PAYMENTS_ENABLED ? [
},
] : [];
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, bis', cases:['accusative'], preps:PREPS_BY_CASE.accusative, 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.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:6, tab:'article', name:'Alles gemischt', desc:'All prepositions, definite articles', cases:['nominative','accusative','dative','genitive'], preps:[...PREPS_BY_CASE.accusative,...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:[...PREPS_BY_CASE.accusative,...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: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'},
{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 — all cases', possessives:['mein','dein','sein'], cases:['nominative','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: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: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:15, tab:'pronoun', name:'Plural Pronomen', desc:'wir, ihr, sie — all cases', pronouns:PL_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:['nominative','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'},
];
// Audio served from /public/audio
const AUDIO = {
dativSung: '/audio/dativ-blue-danube-sung.m4a',
@@ -354,269 +157,6 @@ const LEARN_CARDS = [
},
];
// Contractions (preposition + article). pref:true = preferred even in writing.
const CONTRACTIONS = [
{prep:'an', art:'das', form:'ans', pref:true, c:'wechsel'},
{prep:'an', art:'dem', form:'am', pref:true, c:'wechsel'},
{prep:'in', art:'das', form:'ins', pref:true, c:'wechsel'},
{prep:'in', art:'dem', form:'im', pref:true, c:'wechsel'},
{prep:'auf',art:'das', form:'aufs',pref:true, c:'wechsel'},
{prep:'bei',art:'dem', form:'beim',pref:true, c:'dative'},
{prep:'von',art:'dem', form:'vom', pref:true, c:'dative'},
{prep:'zu', art:'dem', form:'zum', pref:true, c:'dative'},
{prep:'zu', art:'der', form:'zur', pref:true, c:'dative'},
{prep:'durch',art:'das',form:'durchs',pref:false,c:'accusative'},
{prep:'für',art:'das', form:'fürs',pref:false, c:'accusative'},
{prep:'um', art:'das', form:'ums', pref:false, c:'accusative'},
{prep:'über',art:'das',form:'übers',pref:false,c:'wechsel'},
{prep:'unter',art:'das',form:'unters',pref:false,c:'wechsel'},
{prep:'vor',art:'das', form:'vors',pref:false, c:'wechsel'},
{prep:'hinter',art:'das',form:'hinters',pref:false,c:'wechsel'},
];
// Only PREFERRED contractions drive the in-context tests, so the contracted
// form is unambiguously the best answer (the two-word form is the distractor).
const CONTRACTABLE = {};
CONTRACTIONS.filter(c=>c.pref).forEach(c => { (CONTRACTABLE[c.prep] = CONTRACTABLE[c.prep] || {})[c.art] = c.form; });
// Prepositions that get the fused "chunk" question in mixed/dedicated lessons.
const CHUNK_PREPS = new Set(Object.keys(CONTRACTABLE)); // an, in, auf, bei, von, zu
const WECHSEL_SET = new Set(PREPS_BY_CASE.wechsel);
const pick = a => a[Math.floor(Math.random()*a.length)];
const cap = s => s.charAt(0).toUpperCase() + s.slice(1);
// ═══════════════════════════════════════════
// QUESTION GENERATORS
// ═══════════════════════════════════════════
function genArticleQ(level) {
const lv = LEVELS.find(l=>l.id===level);
const noun = pick(NOUNS);
const tbl = lv.artType === 'indefinite' ? INDEF : DEF;
let cas, prepKey, tw = null, verb;
if (lv.id === 1) { cas='nominative'; prepKey=null; }
else if (lv.twoWay) { prepKey=pick(lv.preps); tw=Math.random()>0.5; cas=tw?'accusative':'dative'; }
else if (lv.mixed) {
if (Math.random()<0.12) { cas='nominative'; prepKey=null; }
else { prepKey=pick(lv.preps); const pc=PREPS[prepKey].c;
if (pc==='wechsel') { tw=Math.random()>0.5; cas=tw?'accusative':'dative'; } else cas=pc; }
} else { prepKey=pick(lv.preps); cas=lv.cases[0]; }
// Mixed definite lessons: contraction-capable prep → fused "chunk" question.
if (lv.mixed && lv.artType === 'definite' && prepKey && CHUNK_PREPS.has(prepKey)) {
return makeChunkQ(prepKey, noun, cas, tw);
}
const ans = tbl[noun.g][cas];
const _pool = lv.artType === 'indefinite' ? ['ein','eine','einen','einem','eines','einer','keine','keinen','keiner'] : ['der','die','das','den','dem','des'];
const _rem = _pool.filter(a => a !== ans);
const _picked = [];
while (_picked.length < 3 && _rem.length) _picked.push(_rem.splice(Math.floor(Math.random()*_rem.length), 1)[0]);
const choices = [ans, ..._picked].sort(() => Math.random() - 0.5);
let sentence, translation, rule;
if (!prepKey) {
sentence = `_____ ${noun.w} ist hier.`;
translation = `The ${noun.en} is here.`;
rule = 'Subject of the sentence → Nominativ';
} else {
const subj = pick(['Er','Sie','Wir']);
if (tw===true) verb=pick(MOTION_VS);
else if (tw===false) verb=pick(LOCATION_VS);
else if (cas==='genitive') verb=pick(['warten','wohnen']);
else verb=pick(MOTION_VS);
sentence = `${subj} ${VERBS[verb].de[subj]} ${prepKey} _____ ${noun.w}.`;
let pEn = PREPS[prepKey].en;
if (PREPS[prepKey].c==='wechsel') pEn = tw ? PREPS[prepKey].m : PREPS[prepKey].l;
translation = `${SUBJ_EN[subj]} ${VERBS[verb].en[subj]} ${pEn} the ${noun.en}.`;
rule = PREPS[prepKey].c==='wechsel'
? (tw ? `${prepKey}" + motion (wohin?) → Akkusativ` : `${prepKey}" + location (wo?) → Dativ`)
: `${prepKey}" → always ${CL[cas]}`;
}
return {type:'article', noun, cas, prepKey, ans, sentence, translation, rule, tw, artType:lv.artType, choices};
}
function genPrepQ(level) {
const lv = LEVELS.find(l=>l.id===level);
const prepKey = pick(lv.preps);
const p = PREPS[prepKey];
const exampleNoun = pick(NOUNS);
const exCase = p.c==='wechsel' ? 'dative' : p.c;
return {type:'prep', prepKey, prepEn:p.en, ans:p.c,
example:`${prepKey} ${DEF[exampleNoun.g][exCase]} ${exampleNoun.w}`, exampleEn:exampleNoun.en};
}
function genWechselContextQ(level) {
const lv = LEVELS.find(l=>l.id===level);
const prepKey = pick(lv.preps);
const isMotion = Math.random() > 0.5;
const cas = isMotion ? 'accusative' : 'dative';
const subj = pick(['Er','Sie','Wir','Ich']);
const verb = isMotion ? pick(MOTION_VS) : pick(LOCATION_VS);
const ex = WECHSEL_EX[prepKey];
const art = DEF[ex.g][cas];
let nounStr = ex.noun;
if (ex.g === 'plural' && !isMotion && ex.plDat) nounStr += ex.plDat;
const sentence = `${subj} ${VERBS[verb].de[subj]} ${prepKey} ${art} ${nounStr}.`;
const pEn = isMotion ? ex.mot : ex.loc;
const translation = `${SUBJ_EN[subj]} ${VERBS[verb].en[subj]} ${pEn} the ${ex.en}.`;
return {type:'wechselContext', prepKey, ans:cas, isMotion, verb, sentence, translation, nounEn: ex.en};
}
function genPossQ(level) {
const lv = LEVELS.find(l=>l.id===level);
const noun = pick(NOUNS);
const stem = pick(lv.possessives);
const possMeaning = POSSESSIVES.find(p=>p.stem===stem).en;
let cas, prepKey=null, tw=null, verb;
const wantPrep = Math.random() > 0.5;
if (wantPrep) {
const allPreps = lv.cases.includes('genitive')
? [...PREPS_BY_CASE.accusative,...PREPS_BY_CASE.dative,...PREPS_BY_CASE.wechsel,...PREPS_BY_CASE.genitive]
: [...PREPS_BY_CASE.accusative,...PREPS_BY_CASE.dative,...PREPS_BY_CASE.wechsel];
prepKey = pick(allPreps);
const pc = PREPS[prepKey].c;
if (pc==='wechsel') { tw=Math.random()>0.5; cas=tw?'accusative':'dative'; } else cas=pc;
if (!lv.cases.includes(cas)) { cas=pick(lv.cases); prepKey=null; tw=null; }
} else {
cas = pick(lv.cases.filter(c => c !== 'genitive')) || lv.cases[0];
}
const ans = combinePoss(stem, POSS_ENDINGS[noun.g][cas]);
let sentence, translation, rule;
if (!prepKey) {
if (cas==='nominative') {
sentence = `_____ ${noun.w} ist hier.`;
translation = `${cap(possMeaning)} ${noun.en} is here.`;
} else if (cas==='accusative') {
const v = pick([['liebe','love'],['sehe','see'],['kenne','know'],['höre','hear']]);
sentence = `Ich ${v[0]} _____ ${noun.w}.`;
translation = `I ${v[1]} ${possMeaning} ${noun.en}.`;
} else if (cas==='dative') {
sentence = `Ich helfe _____ ${noun.w}.`;
translation = `I help ${possMeaning} ${noun.en}.`;
}
} else {
const subj='Ich';
if (tw===true) verb='gehen';
else if (tw===false) verb='sein';
else if (PREPS[prepKey].c==='genitive') verb='warten';
else verb='gehen';
sentence = `${subj} ${VERBS[verb].de[subj]} ${prepKey} _____ ${noun.w}.`;
let pEn = PREPS[prepKey].en;
if (PREPS[prepKey].c==='wechsel') pEn = tw ? PREPS[prepKey].m : PREPS[prepKey].l;
translation = `${SUBJ_EN[subj]} ${VERBS[verb].en[subj]} ${pEn} ${possMeaning} ${noun.en}.`;
}
rule = `${stem}- + ${CL[cas]} + ${noun.g}${ans}`;
return {type:'poss', noun, stem, possMeaning, cas, ans, sentence, translation, rule, prepKey, tw};
}
function genPronounQ(level) {
const lv = LEVELS.find(l=>l.id===level);
const pronKey = pick(lv.pronouns);
const pron = PRONOUNS.find(p=>p.key===pronKey);
const cas = pick(lv.cases);
const rawAns = pron[{nominative:'nom',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 subjKey = pick(subjCandidates);
const subj = PRONOUNS.find(p=>p.key===subjKey);
let tpl;
if (cas === 'accusative') {
tpl = pick([{v:'sehen'}, {v:'kennen'}, {v:'lieben'}]);
} else {
tpl = pick([{v:'helfen'}, {v:'geben', dePost:' das Buch', enPost:' the book'}]);
}
const sDe = VC[tpl.v].de[subjKey];
const sEn = VC[tpl.v].en[subjKey];
const dePost = tpl.dePost || '';
const enPost = tpl.enPost || '';
sentence = `${cap(subj.nom)} ${sDe} _____${dePost}.`;
translation = `${cap(subj.en)} ${sEn} ${pron.enObj}${enPost}.`;
ans = rawAns;
}
return {type:'pronoun', pronKey, pron, cas, ans, sentence, translation};
}
function genContractionQ() {
const c = pick(CONTRACTIONS);
return {type:'contraction', prep:c.prep, art:c.art, ans:c.form, pref:c.pref, cas:c.c};
}
// Build the 4 choices for a fused prep+article question: always a BLEND of
// contracted and two-word forms, so the shape never reveals whether it contracts.
function buildChunkChoices(prepKey, article) {
const map = CONTRACTABLE[prepKey] || {};
const contracts = !!map[article];
const correct = contracts ? map[article] : `${prepKey} ${article}`;
const cand = new Set();
Object.values(map).forEach(f => cand.add(f)); // contraction shapes
Object.keys(map).forEach(a => cand.add(`${prepKey} ${a}`)); // their two-word forms
cand.add(`${prepKey} ${article}`); // two-word of the real article
cand.delete(correct);
const pool = [...cand].sort(() => Math.random() - 0.5);
const choices = [correct, ...pool.slice(0, 3)];
const filler = ['das','dem','der','die','den'].map(a => `${prepKey} ${a}`);
for (let i = 0; i < filler.length && choices.length < 4; i++) {
if (!choices.includes(filler[i])) choices.push(filler[i]);
}
return { correct, contracts, choices: choices.slice(0, 4).sort(() => Math.random() - 0.5) };
}
function pickChunkVerb(prepKey, tw) {
if (PREPS[prepKey].c === 'wechsel') return tw ? pick(MOTION_VS) : pick(LOCATION_VS);
if (prepKey === 'bei') return pick(['sein','wohnen','warten']);
if (prepKey === 'von') return 'kommen';
if (prepKey === 'zu') return pick(['gehen','fahren','kommen']);
return pick(['gehen','fahren']);
}
function makeChunkQ(prepKey, noun, cas, tw) {
const article = DEF[noun.g][cas];
const { correct, contracts, choices } = buildChunkChoices(prepKey, article);
const subj = pick(['Er','Sie','Wir','Ich']);
const verb = pickChunkVerb(prepKey, tw);
const sentence = `${subj} ${VERBS[verb].de[subj]} _____ ${noun.w}.`;
let pEn = PREPS[prepKey].en;
if (PREPS[prepKey].c === 'wechsel') pEn = tw ? PREPS[prepKey].m : PREPS[prepKey].l;
const translation = `${SUBJ_EN[subj]} ${VERBS[verb].en[subj]} ${pEn} the ${noun.en}.`;
const pref = contracts ? (CONTRACTIONS.find(x => x.prep === prepKey && x.art === article)?.pref ?? true) : null;
return { type:'chunk', prepKey, noun, cas, ans:correct, choices, sentence, translation, tw, contracts, pref };
}
// Dedicated "Kontraktionen" level: answer ALWAYS contracts (preferred forms only).
function genContractionContextQ() {
const c = pick(CONTRACTIONS.filter(x => x.pref && CHUNK_PREPS.has(x.prep)));
const wechsel = WECHSEL_SET.has(c.prep);
let cas, gender, tw = null;
if (c.art === 'das') { cas='accusative'; gender='neuter'; tw = wechsel ? true : null; }
else if (c.art === 'dem') { cas='dative'; gender=pick(['masculine','neuter']); tw = wechsel ? false : null; }
else { cas='dative'; gender='feminine'; tw = wechsel ? false : null; }
const noun = pick(NOUNS.filter(n => n.g === gender));
return makeChunkQ(c.prep, noun, cas, tw);
}
function gen(level) {
const lv = LEVELS.find(l=>l.id===level);
if (lv.type==='chunk') return genContractionContextQ();
if (lv.type==='contraction') return genContractionQ();
if (lv.type==='prep') return genPrepQ(level);
if (lv.type==='wechselContext') return genWechselContextQ(level);
if (lv.type==='poss') return genPossQ(level);
if (lv.type==='pronoun') return genPronounQ(level);
return genArticleQ(level);
}
// ═══════════════════════════════════════════
// MAIN APP
@@ -643,6 +183,7 @@ export default function App() {
const [loaded, setLoaded] = useState(false);
const [shake, setShake] = useState(false);
const iref = useRef(null);
const sessionRef = useRef(null);
useEffect(()=>{(async()=>{
const data = await Storage.get();
@@ -682,7 +223,8 @@ export default function App() {
const startLv = id => {
if (!unlocked(id)) { setPayMsg(''); setScr('paywall'); return; }
setLv(id); setRes([]); setInp(''); setRev(false); setQ(gen(id)); setScr('practice');
sessionRef.current = createSession(id);
setLv(id); setRes([]); setInp(''); setRev(false); setQ(sessionRef.current.next()); setScr('practice');
};
const openCheckout = plan => {
@@ -705,23 +247,20 @@ export default function App() {
try { iref.current?.blur(); } catch(e){}
setOk(correct); setRev(true);
const nr = [...res, correct]; setRes(nr);
const today = new Date().toISOString().slice(0,10);
const ns = {...st};
let ns = {...st};
ns.ta = (ns.ta||0) + 1;
if (correct) ns.tc = (ns.tc||0) + 1;
if (!ns.sc[lv]) ns.sc[lv] = {c:0,t:0};
ns.sc[lv] = {c: ns.sc[lv].c + (correct?1:0), t: ns.sc[lv].t + 1};
if (ns.ld !== today) {
const ld = ns.ld ? new Date(ns.ld) : null;
ns.streak = ld && Math.round((new Date(today)-ld)/864e5)===1 ? (ns.streak||0)+1 : 1;
ns.ld = today;
}
ns = bumpStreak(ns);
save(ns);
}, [inp, q, res, st, lv, save]);
const doNext = useCallback(() => {
if (res.length >= ROUND) { setScr('summary'); return; }
setInp(''); setRev(false); setOk(false); setQ(gen(lv));
setInp(''); setRev(false); setOk(false);
if (!sessionRef.current) sessionRef.current = createSession(lv);
setQ(sessionRef.current.next());
}, [res, lv]);
submitRef.current = doSubmit;
@@ -772,7 +311,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}>🔥 {st.streak||0}</span>
<span style={S.stat}>🔥 {currentStreak(st)}</span>
</div>
</div>
@@ -997,7 +536,7 @@ export default function App() {
<div style={{fontSize:48, fontWeight:700, color:'#1a1a1a', margin:'8px 0 4px'}}>{q.prepKey}</div>
<div style={{fontSize:15, color:'#999', marginBottom:28}}>{q.prepEn}</div>
{!rev ? (
<div style={{display:'flex',gap:8,flexWrap:'wrap',justifyContent:'center',maxWidth:420,margin:'0 auto'}}>
<div style={{display:'grid',gridTemplateColumns:'1fr 1fr',gap:10,maxWidth:340,margin:'0 auto'}}>
{[
['accusative','Akkusativ','1'],['dative','Dativ','2'],
['genitive','Genitiv','3'],['wechsel','Wechsel','4'],
@@ -1158,12 +697,15 @@ function HomeScreen({ onStart }) {
function FeatureRequestFooter() {
return (
<footer style={S.footerContact}>
<div>Have a feature request? Get in touch. Always looking to improve the tool.</div>
<div>
<div>Have a feature request?</div>
<div>Always looking to improve the tool.</div>
</div>
<a
style={S.footerMail}
href={`mailto:genesis_iodine_03@icloud.com?subject=${encodeURIComponent('Beug feature request')}&body=${encodeURIComponent('Hey, I have a feature request for Beug:\n\n')}`}
>
Write an email
Get in touch
</a>
</footer>
);
@@ -1310,7 +852,7 @@ function PrepFeedback({ q, inp, ok }) {
</div>
{q.ans !== 'wechsel' && (
<div style={{fontSize:13, color:'#999', fontStyle:'italic', marginTop:4}}>
Example: {q.example} <span style={{color:'#bbb'}}>({q.prepEn} the {q.exampleEn})</span>
Example: {q.example} <span style={{color:'#bbb'}}>({q.exampleFull ? q.exampleEn : `${q.prepEn} the ${q.exampleEn}`})</span>
</div>
)}
{q.ans === 'wechsel' && (
@@ -1333,7 +875,7 @@ function WechselContextFeedback({ q, inp, ok }) {
„{q.verb}" {q.isMotion ? 'implies motion (wohin?)' : 'implies location (wo?)'} <span style={{color:CASE_C[q.ans], fontWeight:600}}>{CL[q.ans]}</span>
</div>
<div style={{fontSize:12, color:'#999', marginTop:4, lineHeight:1.6}}>
Wechselpräpositionen take <span style={{color:CASE_C.accusative}}>Akkusativ</span> with motion verbs (gehen, fahren, laufen, kommen) and <span style={{color:CASE_C.dative}}>Dativ</span> with location verbs (sein, stehen, sitzen, warten).
Wechselpräpositionen take <span style={{color:CASE_C.accusative}}>Akkusativ</span> when something moves toward a destination wohin? (gehen, fahren, legen, stellen) and <span style={{color:CASE_C.dative}}>Dativ</span> when it stays put wo? (sein, stehen, sitzen, liegen).
</div>
</div>
);