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:
+22
-480
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user