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:
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "beug-dev",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev", "--", "--port", "5199", "--strictPort"],
|
||||
"port": 5199
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
+1665
-4
File diff suppressed because it is too large
Load Diff
+4
-2
@@ -6,7 +6,8 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --host"
|
||||
"preview": "vite preview --host",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
@@ -14,6 +15,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"vite": "^5.4.11"
|
||||
"vite": "^5.4.11",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
|
||||
+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>
|
||||
);
|
||||
|
||||
+793
@@ -0,0 +1,793 @@
|
||||
// ═══════════════════════════════════════════
|
||||
// ENGINE — data, question generators, streaks
|
||||
// Pure JS, no React. Everything here is unit-testable.
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
export 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'},
|
||||
};
|
||||
export 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'},
|
||||
};
|
||||
export 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'},
|
||||
};
|
||||
export 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'},
|
||||
};
|
||||
|
||||
export 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', ex:{de:'bis nächsten Montag', en:'until next Monday'}},
|
||||
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', ex:{de:'seit einer Woche', en:'for/since a week'}},
|
||||
von:{c:'dative',en:'from'},
|
||||
zu:{c:'dative',en:'to'}, außer:{c:'dative',en:'except'},
|
||||
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'},
|
||||
};
|
||||
export 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'],
|
||||
};
|
||||
export const COMMON_PREPS = ['mit','von','zu','in','an','auf','für','durch','ohne','um','aus','bei','nach','vor','über','wegen'];
|
||||
export const RARE_PREPS = ['gegen','bis','seit','außer','gegenüber','hinter','neben','unter','zwischen','trotz','während','statt','anstatt','außerhalb','innerhalb'];
|
||||
export const ALL_PREPS = [...COMMON_PREPS, ...RARE_PREPS];
|
||||
|
||||
// "bis" almost never takes a bare definite article in real German
|
||||
// (bis nächsten Montag / bis zum Ende) — so it stays in the case-ID
|
||||
// quizzes but is excluded from fill-the-article sentence levels.
|
||||
const SENTENCE_ACC_PREPS = ['durch','für','gegen','ohne','um'];
|
||||
|
||||
export 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'},
|
||||
};
|
||||
export 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}` };
|
||||
}
|
||||
|
||||
// gen: genitive noun form for masculine/neuter (des Mannes, des Hauses)
|
||||
export const NOUNS = [
|
||||
{w:'Hund',g:'masculine',en:'dog',gen:'Hundes'},
|
||||
{w:'Mann',g:'masculine',en:'man',gen:'Mannes'},
|
||||
{w:'Tisch',g:'masculine',en:'table',gen:'Tisches'},
|
||||
{w:'Park',g:'masculine',en:'park',gen:'Parks'},
|
||||
{w:'Arzt',g:'masculine',en:'doctor',gen:'Arztes'},
|
||||
{w:'Freund',g:'masculine',en:'friend',gen:'Freundes'},
|
||||
{w:'Baum',g:'masculine',en:'tree',gen:'Baumes'},
|
||||
{w:'Garten',g:'masculine',en:'garden',gen:'Gartens'},
|
||||
{w:'Stuhl',g:'masculine',en:'chair',gen:'Stuhls'},
|
||||
{w:'Bruder',g:'masculine',en:'brother',gen:'Bruders'},
|
||||
{w:'Lehrer',g:'masculine',en:'teacher',gen:'Lehrers'},
|
||||
{w:'Vater',g:'masculine',en:'father',gen:'Vaters'},
|
||||
{w:'Zug',g:'masculine',en:'train',gen:'Zuges'},
|
||||
{w:'Regen',g:'masculine',en:'rain',gen:'Regens'},
|
||||
{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:'Arbeit',g:'feminine',en:'work'},
|
||||
{w:'Brücke',g:'feminine',en:'bridge'},
|
||||
{w:'Kind',g:'neuter',en:'child',gen:'Kindes'},
|
||||
{w:'Haus',g:'neuter',en:'house',gen:'Hauses'},
|
||||
{w:'Buch',g:'neuter',en:'book',gen:'Buches'},
|
||||
{w:'Auto',g:'neuter',en:'car',gen:'Autos'},
|
||||
{w:'Mädchen',g:'neuter',en:'girl',gen:'Mädchens'},
|
||||
{w:'Fenster',g:'neuter',en:'window',gen:'Fensters'},
|
||||
{w:'Hotel',g:'neuter',en:'hotel',gen:'Hotels'},
|
||||
{w:'Museum',g:'neuter',en:'museum',gen:'Museums'},
|
||||
{w:'Büro',g:'neuter',en:'office',gen:'Büros'},
|
||||
{w:'Restaurant',g:'neuter',en:'restaurant',gen:'Restaurants'},
|
||||
{w:'Wetter',g:'neuter',en:'weather',gen:'Wetters'},
|
||||
{w:'Dach',g:'neuter',en:'roof',gen:'Daches'},
|
||||
{w:'Konzert',g:'neuter',en:'concert',gen:'Konzerts'},
|
||||
];
|
||||
const NOUN_BY_NAME = Object.fromEntries(NOUNS.map(n => [n.w, n]));
|
||||
export const nounForm = (noun, cas) =>
|
||||
cas === 'genitive' && noun.gen ? noun.gen : noun.w;
|
||||
|
||||
const PERSONS = ['Mann','Arzt','Freund','Bruder','Lehrer','Vater','Frau','Schwester','Mutter','Tochter','Kind','Mädchen'];
|
||||
const PLACES_IN = ['Park','Garten','Schule','Stadt','Haus','Hotel','Museum','Büro','Restaurant'];
|
||||
|
||||
// Nouns that read naturally with a possessive ("my …")
|
||||
const POSSESSABLE = new Set([
|
||||
...PERSONS, 'Hund','Katze','Haus','Garten','Auto','Buch','Tisch','Stuhl',
|
||||
'Büro','Schule','Stadt','Fenster','Tür','Musik',
|
||||
]);
|
||||
|
||||
export 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'},
|
||||
];
|
||||
|
||||
export 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"},
|
||||
];
|
||||
export 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'},
|
||||
};
|
||||
export const combinePoss = (stem, ending) => (stem === 'euer' && ending) ? 'eur' + ending : stem + ending;
|
||||
|
||||
export const CL = {nominative:'Nominativ',accusative:'Akkusativ',dative:'Dativ',genitive:'Genitiv',wechsel:'Wechsel'};
|
||||
export const CASE_C = {nominative:'#2563eb',accusative:'#dc2626',dative:'#ea580c',genitive:'#16a34a',wechsel:'#7c3aed'};
|
||||
export const GENDER_C = {masculine:'#16a34a',feminine:'#db2777',neuter:'#2563eb',plural:'#7c3aed'};
|
||||
|
||||
export 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'}},
|
||||
liegen: {de:{Er:'liegt',Sie:'liegt',Wir:'liegen',Ich:'liege'}, en:{Er:'is lying',Sie:'is lying',Wir:'are lying',Ich:'am lying'}},
|
||||
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'}},
|
||||
kaufen: {de:{Er:'kauft',Sie:'kauft',Wir:'kaufen',Ich:'kaufe'}, en:{Er:'buys',Sie:'buys',Wir:'buy',Ich:'buy'}},
|
||||
sprechen:{de:{Er:'spricht',Sie:'spricht',Wir:'sprechen',Ich:'spreche'}, en:{Er:'talks',Sie:'talks',Wir:'talk',Ich:'talk'}},
|
||||
spielen: {de:{Er:'spielt',Sie:'spielt',Wir:'spielen',Ich:'spiele'}, en:{Er:'plays',Sie:'plays',Wir:'play',Ich:'play'}},
|
||||
schlafen:{de:{Er:'schläft',Sie:'schläft',Wir:'schlafen',Ich:'schlafe'}, en:{Er:'sleeps',Sie:'sleeps',Wir:'sleep',Ich:'sleep'}},
|
||||
bleiben: {de:{Er:'bleibt',Sie:'bleibt',Wir:'bleiben',Ich:'bleibe'}, en:{Er:'stays',Sie:'stays',Wir:'stay',Ich:'stay'}},
|
||||
nehmen: {de:{Er:'nimmt',Sie:'nimmt',Wir:'nehmen',Ich:'nehme'}, en:{Er:'takes',Sie:'takes',Wir:'take',Ich:'take'}},
|
||||
arbeiten:{de:{Er:'arbeitet',Sie:'arbeitet',Wir:'arbeiten',Ich:'arbeite'}, en:{Er:'works',Sie:'works',Wir:'work',Ich:'work'}},
|
||||
springen:{de:{Er:'springt',Sie:'springt',Wir:'springen',Ich:'springe'}, en:{Er:'jumps',Sie:'jumps',Wir:'jump',Ich:'jump'}},
|
||||
hängen: {de:{Er:'hängt',Sie:'hängt',Wir:'hängen',Ich:'hänge'}, en:{Er:'hangs',Sie:'hangs',Wir:'hang',Ich:'hang'}},
|
||||
legen: {de:{Er:'legt',Sie:'legt',Wir:'legen',Ich:'lege'}, en:{Er:'puts',Sie:'puts',Wir:'put',Ich:'put'}},
|
||||
stellen: {de:{Er:'stellt',Sie:'stellt',Wir:'stellen',Ich:'stelle'}, en:{Er:'puts',Sie:'puts',Wir:'put',Ich:'put'}},
|
||||
};
|
||||
export const SUBJ_EN = {Er:'He',Sie:'She',Wir:'We',Ich:'I'};
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
export 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'})},
|
||||
};
|
||||
|
||||
export const SG_PRONOUN_KEYS = ['ich','du','er','sie_sg','es'];
|
||||
export const PL_PRONOUN_KEYS = ['wir','ihr','sie_pl'];
|
||||
export const FORMAL_PRONOUN_KEYS = ['Sie_sg','Sie_pl'];
|
||||
export const ALL_PRONOUN_KEYS = PRONOUNS.map(p=>p.key);
|
||||
const SAFE_SUBJECT_KEYS = ['ich','du','er','wir','ihr'];
|
||||
|
||||
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: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:[...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: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'},
|
||||
];
|
||||
|
||||
// Contractions (preposition + article). pref:true = preferred even in writing.
|
||||
export 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'},
|
||||
];
|
||||
|
||||
export const CONTRACTABLE = {};
|
||||
CONTRACTIONS.filter(c=>c.pref).forEach(c => { (CONTRACTABLE[c.prep] = CONTRACTABLE[c.prep] || {})[c.art] = c.form; });
|
||||
export const CHUNK_PREPS = new Set(Object.keys(CONTRACTABLE)); // an, in, auf, bei, von, zu
|
||||
export const WECHSEL_SET = new Set(PREPS_BY_CASE.wechsel);
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// SENTENCE FRAMES
|
||||
// Per-preposition verb/noun pairings so generated sentences are
|
||||
// semantically plausible ("Er kauft das Geschenk für den Vater",
|
||||
// not "Wir reisen für den Hund").
|
||||
//
|
||||
// frame: {verbs, nouns, mid?, fixedSubj?, enPrep?, enDet?, post?, enVerb?, poss?}
|
||||
// mid — object between verb and preposition ({de,en})
|
||||
// fixedSubj — literal subject instead of Er/Sie/Wir/Ich
|
||||
// ({de, en, vkey?, enVkey?} — conjugation keys, default 'Er')
|
||||
// enPrep — English preposition override (default PREPS[p].en or m/l)
|
||||
// enDet — English determiner (default 'the'; '' drops it)
|
||||
// post — trailing text after the noun ({accusative, dative, genitive, en})
|
||||
// enVerb — English verb override map (Er/Sie/Wir/Ich)
|
||||
// poss — false: skip this frame for possessive questions
|
||||
// Wechsel preps use {motion:[...], location:[...]} instead of a flat list.
|
||||
// ═══════════════════════════════════════════
|
||||
export const PREP_FRAMES = {
|
||||
durch: [{verbs:['gehen','laufen','fahren'], nouns:['Park','Stadt','Straße','Garten','Tür']}],
|
||||
für: [{verbs:['kaufen'], mid:{de:'das Geschenk',en:'the gift'}, nouns:[...PERSONS,'Hund','Katze']}],
|
||||
gegen: [{verbs:['fahren'], nouns:['Baum','Haus','Tür','Fenster','Auto'], enPrep:'into'}],
|
||||
ohne: [{verbs:['kommen','reisen','gehen'], nouns:[...PERSONS,'Hund','Katze','Buch','Auto']}],
|
||||
um: [{verbs:['laufen','gehen'], nouns:['Tisch','Haus','Baum','Park','Garten']}],
|
||||
aus: [{verbs:['kommen','laufen'], nouns:['Haus','Schule','Büro','Museum','Hotel','Stadt','Garten','Restaurant']}],
|
||||
bei: [{verbs:['wohnen','sein','warten'], nouns:PERSONS}],
|
||||
mit: [
|
||||
{verbs:['sprechen'], nouns:PERSONS},
|
||||
{verbs:['spielen'], nouns:['Hund','Katze','Kind']},
|
||||
{verbs:['fahren'], nouns:['Auto','Zug']},
|
||||
],
|
||||
nach: [{verbs:['kommen'], nouns:['Schule','Arbeit','Nacht','Woche'], enPrep:'after', enDet:'', poss:false}],
|
||||
seit: [{verbs:['wohnen'], mid:{de:'hier',en:'here'}, nouns:['Nacht','Woche'], enPrep:'since', enDet:'that',
|
||||
enVerb:{Er:'has lived',Sie:'has lived',Wir:'have lived',Ich:'have lived'}, poss:false}],
|
||||
von: [{verbs:['kommen'], nouns:[...PERSONS,'Arbeit','Schule','Büro']}],
|
||||
zu: [{verbs:['gehen','fahren'], nouns:[...PERSONS,'Schule','Park','Museum','Büro','Restaurant','Hotel']}],
|
||||
außer: [{fixedSubj:{de:'Alle',en:'Everyone',vkey:'Wir',enVkey:'Er'}, verbs:['kommen'], nouns:PERSONS, enPrep:'except'}],
|
||||
gegenüber: [{verbs:['wohnen'], nouns:['Schule','Park','Museum','Hotel','Restaurant']}],
|
||||
trotz: [{verbs:['schlafen','arbeiten'], nouns:['Musik','Regen','Wetter'], poss:false}],
|
||||
während: [
|
||||
{verbs:['schlafen'], nouns:['Nacht','Konzert'], poss:false},
|
||||
{verbs:['arbeiten'], nouns:['Woche','Nacht'], poss:false},
|
||||
],
|
||||
wegen: [{verbs:['bleiben'], nouns:['Regen','Wetter','Kind','Hund','Katze']}],
|
||||
statt: [{verbs:['nehmen'], mid:{de:'den Bus',en:'the bus'}, nouns:['Auto','Zug']}],
|
||||
anstatt: [{verbs:['nehmen'], mid:{de:'den Bus',en:'the bus'}, nouns:['Auto','Zug']}],
|
||||
außerhalb: [{verbs:['wohnen','arbeiten'], nouns:['Stadt']}],
|
||||
innerhalb: [{verbs:['wohnen','arbeiten'], nouns:['Stadt']}],
|
||||
|
||||
an: {
|
||||
motion: [{verbs:['gehen','laufen'], nouns:['Tür','Fenster']}],
|
||||
location:[{verbs:['stehen','warten','sitzen'], nouns:['Tür','Fenster']}],
|
||||
},
|
||||
auf: {
|
||||
motion: [
|
||||
{verbs:['legen'], mid:{de:'das Buch',en:'the book'}, nouns:['Tisch','Stuhl']},
|
||||
{verbs:['gehen'], nouns:['Straße']},
|
||||
{verbs:['springen'], fixedSubj:{de:'Die Katze',en:'The cat'}, nouns:['Tisch','Dach']},
|
||||
],
|
||||
location: [
|
||||
{verbs:['sitzen'], nouns:['Stuhl']},
|
||||
{verbs:['liegen'], fixedSubj:{de:'Das Buch',en:'The book'}, nouns:['Tisch']},
|
||||
{verbs:['stehen','warten'], nouns:['Straße']},
|
||||
],
|
||||
},
|
||||
hinter: {
|
||||
motion: [{verbs:['gehen','laufen'], nouns:['Haus','Baum','Garten','Schule']}],
|
||||
location:[{verbs:['stehen','warten'], nouns:['Haus','Baum','Tür','Schule']}],
|
||||
},
|
||||
in: {
|
||||
motion: [{verbs:['gehen','fahren','kommen','laufen'], nouns:PLACES_IN}],
|
||||
location: [
|
||||
{verbs:['wohnen'], nouns:['Stadt','Haus','Hotel']},
|
||||
{verbs:['sein','warten','sitzen','arbeiten'], nouns:PLACES_IN},
|
||||
],
|
||||
},
|
||||
neben: {
|
||||
motion: [
|
||||
{verbs:['stellen'], mid:{de:'die Lampe',en:'the lamp'}, nouns:['Tisch','Stuhl','Tür']},
|
||||
{verbs:['legen'], mid:{de:'das Buch',en:'the book'}, nouns:['Stuhl','Tisch']},
|
||||
],
|
||||
location: [
|
||||
{verbs:['stehen','sitzen'], nouns:['Tisch','Tür','Fenster']},
|
||||
{verbs:['wohnen'], nouns:['Schule','Park','Museum']},
|
||||
],
|
||||
},
|
||||
über: {
|
||||
motion: [
|
||||
{verbs:['gehen','laufen'], nouns:['Straße','Brücke']},
|
||||
{verbs:['hängen'], mid:{de:'die Lampe',en:'the lamp'}, nouns:['Tisch']},
|
||||
],
|
||||
location:[{verbs:['hängen'], fixedSubj:{de:'Die Lampe',en:'The lamp'}, nouns:['Tisch']}],
|
||||
},
|
||||
unter: {
|
||||
motion: [
|
||||
{verbs:['legen'], mid:{de:'das Buch',en:'the book'}, nouns:['Tisch','Stuhl']},
|
||||
{verbs:['laufen'], fixedSubj:{de:'Die Katze',en:'The cat'}, nouns:['Tisch','Baum']},
|
||||
],
|
||||
location: [
|
||||
{verbs:['sitzen','stehen','schlafen'], nouns:['Baum','Brücke']},
|
||||
{verbs:['schlafen'], fixedSubj:{de:'Die Katze',en:'The cat'}, nouns:['Tisch','Stuhl']},
|
||||
],
|
||||
},
|
||||
vor: {
|
||||
motion: [{verbs:['fahren','gehen'], nouns:['Haus','Tür','Schule','Hotel']}],
|
||||
location:[{verbs:['stehen','warten'], nouns:['Tür','Haus','Schule','Hotel','Museum','Restaurant']}],
|
||||
},
|
||||
zwischen: {
|
||||
motion: [{verbs:['stellen'], mid:{de:'die Lampe',en:'the lamp'}, nouns:['Tisch','Stuhl'],
|
||||
post:{accusative:' und das Fenster', dative:' und dem Fenster', en:' and the window'}}],
|
||||
location: [{verbs:['stehen','sitzen'], nouns:['Tisch','Stuhl'],
|
||||
post:{accusative:' und das Fenster', dative:' und dem Fenster', en:' and the window'}}],
|
||||
},
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// RANDOM HELPERS + SESSION STATE
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
export const pick = a => a[Math.floor(Math.random()*a.length)];
|
||||
export const cap = s => s.charAt(0).toUpperCase() + s.slice(1);
|
||||
|
||||
const pickAvoid = (arr, avoid) => {
|
||||
if (!arr.length) return undefined;
|
||||
if (arr.length === 1) return arr[0];
|
||||
const filtered = arr.filter(x => x !== avoid);
|
||||
return pick(filtered.length ? filtered : arr);
|
||||
};
|
||||
|
||||
const shuffle = a => {
|
||||
const r = [...a];
|
||||
for (let i = r.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[r[i], r[j]] = [r[j], r[i]];
|
||||
}
|
||||
return r;
|
||||
};
|
||||
|
||||
// Shuffle-bag: deal every item from the pool once before any repeats,
|
||||
// and never let a refill produce the same item twice in a row.
|
||||
function drawBag(state, key, pool, avoid) {
|
||||
let bag = state.bags[key];
|
||||
if (!bag || !bag.length) {
|
||||
bag = shuffle(pool);
|
||||
if (bag.length > 1 && bag[bag.length - 1] === avoid) {
|
||||
bag.pop();
|
||||
bag.unshift(avoid);
|
||||
}
|
||||
state.bags[key] = bag;
|
||||
}
|
||||
return bag.pop();
|
||||
}
|
||||
|
||||
export function newState() {
|
||||
return { bags:{}, last:{ prep:null, verb:null, noun:null, subj:null } };
|
||||
}
|
||||
|
||||
// A practice session: one shuffle-bag + anti-repeat memory per round.
|
||||
export function createSession(levelId) {
|
||||
const state = newState();
|
||||
return { next: () => gen(levelId, state), state };
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// FRAME REALIZATION
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
function framesFor(prepKey, tw) {
|
||||
const f = PREP_FRAMES[prepKey];
|
||||
if (!f) return [];
|
||||
if (Array.isArray(f)) return f;
|
||||
return tw ? f.motion : f.location;
|
||||
}
|
||||
|
||||
// Resolve a frame into concrete sentence parts. Returns null if the
|
||||
// preposition has no usable frame for the given constraints.
|
||||
function realizeFrame(prepKey, cas, tw, state, { genderFilter, possOnly } = {}) {
|
||||
let frames = framesFor(prepKey, tw);
|
||||
if (possOnly) {
|
||||
frames = frames
|
||||
.filter(f => f.poss !== false)
|
||||
.map(f => ({...f, nouns: f.nouns.filter(n => POSSESSABLE.has(n))}))
|
||||
.filter(f => f.nouns.length);
|
||||
}
|
||||
if (genderFilter) {
|
||||
frames = frames
|
||||
.map(f => ({...f, nouns: f.nouns.filter(n => genderFilter.includes(NOUN_BY_NAME[n].g))}))
|
||||
.filter(f => f.nouns.length);
|
||||
}
|
||||
if (!frames.length) return null;
|
||||
|
||||
// Prefer frames that can avoid repeating the previous question's verb.
|
||||
const fresh = frames.filter(f => f.verbs.some(v => v !== state.last.verb));
|
||||
const frame = pick(fresh.length ? fresh : frames);
|
||||
const nounName = pickAvoid(frame.nouns, state.last.noun);
|
||||
const noun = NOUN_BY_NAME[nounName];
|
||||
const verbKey = pickAvoid(frame.verbs, state.last.verb);
|
||||
state.last.noun = nounName;
|
||||
state.last.verb = verbKey;
|
||||
|
||||
let subjDe, subjEn, deVkey, enVkey;
|
||||
if (frame.fixedSubj) {
|
||||
subjDe = frame.fixedSubj.de;
|
||||
subjEn = frame.fixedSubj.en;
|
||||
deVkey = frame.fixedSubj.vkey || 'Er';
|
||||
enVkey = frame.fixedSubj.enVkey || deVkey;
|
||||
} else {
|
||||
const subj = pickAvoid(['Er','Sie','Wir','Ich'], state.last.subj);
|
||||
state.last.subj = subj;
|
||||
subjDe = subj; subjEn = SUBJ_EN[subj];
|
||||
deVkey = subj; enVkey = subj;
|
||||
}
|
||||
|
||||
const p = PREPS[prepKey];
|
||||
let enPrep = frame.enPrep || p.en;
|
||||
if (p.c === 'wechsel' && !frame.enPrep) enPrep = tw ? p.m : p.l;
|
||||
|
||||
const verbDe = VERBS[verbKey].de[deVkey];
|
||||
const verbEn = (frame.enVerb || VERBS[verbKey].en)[enVkey];
|
||||
const midDe = frame.mid ? ` ${frame.mid.de}` : '';
|
||||
const midEn = frame.mid ? ` ${frame.mid.en}` : '';
|
||||
const postDe = frame.post ? (frame.post[cas] || '') : '';
|
||||
const postEn = frame.post ? frame.post.en : '';
|
||||
const enDet = frame.enDet !== undefined ? frame.enDet : 'the';
|
||||
|
||||
return { noun, verbKey, subjDe, subjEn, verbDe, verbEn, midDe, midEn, enPrep, postDe, postEn, enDet };
|
||||
}
|
||||
|
||||
const enNoun = (r, det) => `${det}${det ? ' ' : ''}${r.noun.en}`;
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// 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) {
|
||||
const pool = 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]);
|
||||
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()) {
|
||||
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);
|
||||
state.last.prep = prepKey;
|
||||
const pc = PREPS[prepKey].c;
|
||||
let tw = null, cas;
|
||||
if (pc === 'wechsel') { tw = Math.random() > 0.5; cas = tw ? 'accusative' : 'dative'; }
|
||||
else cas = pc;
|
||||
|
||||
// Mixed definite lessons: contraction-capable prep → fused "chunk" question.
|
||||
if (lv.mixed && lv.artType === 'definite' && CHUNK_PREPS.has(prepKey)) {
|
||||
return makeChunkQ(prepKey, cas, tw, state);
|
||||
}
|
||||
|
||||
const r = realizeFrame(prepKey, cas, tw, state);
|
||||
const tbl = lv.artType === 'indefinite' ? INDEF : DEF;
|
||||
const ans = tbl[r.noun.g][cas];
|
||||
const sentence = `${r.subjDe} ${r.verbDe}${r.midDe} ${prepKey} _____ ${nounForm(r.noun, cas)}${r.postDe}.`;
|
||||
const translation = `${r.subjEn} ${r.verbEn}${r.midEn} ${r.enPrep} ${enNoun(r, r.enDet)}${r.postEn}.`;
|
||||
const rule = pc === 'wechsel'
|
||||
? (tw ? `„${prepKey}" + motion (wohin?) → Akkusativ` : `„${prepKey}" + location (wo?) → Dativ`)
|
||||
: `„${prepKey}" → always ${CL[cas]}`;
|
||||
return {type:'article', noun:r.noun, cas, prepKey, ans, sentence, translation, rule, tw, artType:lv.artType, choices:articleChoices(ans, lv.artType)};
|
||||
}
|
||||
|
||||
export function genPrepQ(level, state = newState()) {
|
||||
const lv = LEVELS.find(l=>l.id===level);
|
||||
const prepKey = drawBag(state, 'prep', lv.preps, state.last.prep);
|
||||
state.last.prep = prepKey;
|
||||
const p = PREPS[prepKey];
|
||||
if (p.ex) {
|
||||
return {type:'prep', prepKey, prepEn:p.en, ans:p.c, example:p.ex.de, exampleEn:p.ex.en, exampleFull:true};
|
||||
}
|
||||
const exCase = p.c === 'wechsel' ? 'dative' : p.c;
|
||||
const frames = framesFor(prepKey, false);
|
||||
const nounName = frames.length ? pick(pick(frames).nouns) : pick(NOUNS).w;
|
||||
const noun = NOUN_BY_NAME[nounName];
|
||||
return {type:'prep', prepKey, prepEn:p.en, ans:p.c,
|
||||
example:`${prepKey} ${DEF[noun.g][exCase]} ${nounForm(noun, exCase)}`, exampleEn:noun.en};
|
||||
}
|
||||
|
||||
export function genWechselContextQ(level, state = newState()) {
|
||||
const lv = LEVELS.find(l=>l.id===level);
|
||||
const prepKey = drawBag(state, 'prep', lv.preps, state.last.prep);
|
||||
state.last.prep = prepKey;
|
||||
const isMotion = Math.random() > 0.5;
|
||||
const cas = isMotion ? 'accusative' : 'dative';
|
||||
const r = realizeFrame(prepKey, cas, isMotion, state);
|
||||
const art = DEF[r.noun.g][cas];
|
||||
const sentence = `${r.subjDe} ${r.verbDe}${r.midDe} ${prepKey} ${art} ${nounForm(r.noun, cas)}${r.postDe}.`;
|
||||
const translation = `${r.subjEn} ${r.verbEn}${r.midEn} ${r.enPrep} ${enNoun(r, r.enDet)}${r.postEn}.`;
|
||||
return {type:'wechselContext', prepKey, ans:cas, isMotion, verb:r.verbKey, sentence, translation, nounEn:r.noun.en};
|
||||
}
|
||||
|
||||
const POSS_ACC_TEMPLATES = [
|
||||
{de:'sehe', en:'see', nouns:[...PERSONS,'Hund','Katze','Haus','Garten','Auto']},
|
||||
{de:'kenne', en:'know', nouns:PERSONS},
|
||||
{de:'liebe', en:'love', nouns:[...PERSONS,'Hund','Katze','Stadt','Auto']},
|
||||
{de:'höre', en:'hear', nouns:['Musik','Hund','Katze','Kind']},
|
||||
];
|
||||
|
||||
export function genPossQ(level, state = newState()) {
|
||||
const lv = LEVELS.find(l=>l.id===level);
|
||||
const stem = pickAvoid(lv.possessives, state.last.stem);
|
||||
state.last.stem = stem;
|
||||
const possMeaning = POSSESSIVES.find(p=>p.stem===stem).en;
|
||||
const wantPrep = Math.random() > 0.5;
|
||||
|
||||
if (wantPrep) {
|
||||
const pool = lv.cases.includes('genitive')
|
||||
? [...SENTENCE_ACC_PREPS,...PREPS_BY_CASE.dative,...PREPS_BY_CASE.wechsel,...PREPS_BY_CASE.genitive]
|
||||
: [...SENTENCE_ACC_PREPS,...PREPS_BY_CASE.dative,...PREPS_BY_CASE.wechsel];
|
||||
// Draw until we find a prep with a possessive-friendly frame.
|
||||
for (let i = 0; i < pool.length; i++) {
|
||||
const prepKey = drawBag(state, 'prep', pool, state.last.prep);
|
||||
const pc = PREPS[prepKey].c;
|
||||
let tw = null, cas;
|
||||
if (pc === 'wechsel') { tw = Math.random() > 0.5; cas = tw ? 'accusative' : 'dative'; } else cas = pc;
|
||||
if (!lv.cases.includes(cas)) continue;
|
||||
const r = realizeFrame(prepKey, cas, tw, state, { possOnly:true });
|
||||
if (!r) continue;
|
||||
state.last.prep = prepKey;
|
||||
const ans = combinePoss(stem, POSS_ENDINGS[r.noun.g][cas]);
|
||||
const sentence = `${r.subjDe} ${r.verbDe}${r.midDe} ${prepKey} _____ ${nounForm(r.noun, cas)}${r.postDe}.`;
|
||||
const translation = `${r.subjEn} ${r.verbEn}${r.midEn} ${r.enPrep} ${possMeaning} ${r.noun.en}${r.postEn}.`;
|
||||
const rule = `${stem}- + ${CL[cas]} + ${r.noun.g} → ${ans}`;
|
||||
return {type:'poss', noun:r.noun, stem, possMeaning, cas, ans, sentence, translation, rule, prepKey, tw};
|
||||
}
|
||||
}
|
||||
|
||||
// No preposition: simple subject/object sentence.
|
||||
const cas = pick(lv.cases.filter(c => c !== 'genitive')) || lv.cases[0];
|
||||
let noun, sentence, translation;
|
||||
if (cas === 'nominative') {
|
||||
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);
|
||||
noun = NOUN_BY_NAME[pickAvoid(t.nouns, state.last.noun)];
|
||||
sentence = `Ich ${t.de} _____ ${noun.w}.`;
|
||||
translation = `I ${t.en} ${possMeaning} ${noun.en}.`;
|
||||
} else {
|
||||
noun = NOUN_BY_NAME[pickAvoid(PERSONS, state.last.noun)];
|
||||
sentence = `Ich helfe _____ ${noun.w}.`;
|
||||
translation = `I help ${possMeaning} ${noun.en}.`;
|
||||
}
|
||||
state.last.noun = noun.w;
|
||||
const ans = combinePoss(stem, POSS_ENDINGS[noun.g][cas]);
|
||||
const rule = `${stem}- + ${CL[cas]} + ${noun.g} → ${ans}`;
|
||||
return {type:'poss', noun, stem, possMeaning, cas, ans, sentence, translation, rule, prepKey:null, tw:null};
|
||||
}
|
||||
|
||||
export function genPronounQ(level, state = newState()) {
|
||||
const lv = LEVELS.find(l=>l.id===level);
|
||||
const pronKey = drawBag(state, 'pron', lv.pronouns, state.last.pron);
|
||||
state.last.pron = pronKey;
|
||||
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};
|
||||
}
|
||||
|
||||
export function genContractionQ(state = newState()) {
|
||||
const form = drawBag(state, 'contr', CONTRACTIONS.map(c=>c.form), state.last.contr);
|
||||
state.last.contr = form;
|
||||
const c = CONTRACTIONS.find(x => x.form === form);
|
||||
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.
|
||||
export 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) };
|
||||
}
|
||||
|
||||
export function makeChunkQ(prepKey, cas, tw, state = newState(), genderFilter) {
|
||||
const r = realizeFrame(prepKey, cas, tw, state, { genderFilter });
|
||||
const article = DEF[r.noun.g][cas];
|
||||
const { correct, contracts, choices } = buildChunkChoices(prepKey, article);
|
||||
const sentence = `${r.subjDe} ${r.verbDe}${r.midDe} _____ ${nounForm(r.noun, cas)}${r.postDe}.`;
|
||||
const translation = `${r.subjEn} ${r.verbEn}${r.midEn} ${r.enPrep} ${enNoun(r, r.enDet)}${r.postEn}.`;
|
||||
const pref = contracts ? (CONTRACTIONS.find(x => x.prep === prepKey && x.art === article)?.pref ?? true) : null;
|
||||
return { type:'chunk', prepKey, noun:r.noun, cas, ans:correct, choices, sentence, translation, tw, contracts, pref };
|
||||
}
|
||||
|
||||
// Dedicated "Kontraktionen" level: answer ALWAYS contracts (preferred forms only).
|
||||
export function genContractionContextQ(state = newState()) {
|
||||
const prefForms = CONTRACTIONS.filter(x => x.pref && CHUNK_PREPS.has(x.prep));
|
||||
const form = drawBag(state, 'contr', prefForms.map(c=>c.form), state.last.contr);
|
||||
state.last.contr = form;
|
||||
const c = prefForms.find(x => x.form === form);
|
||||
const wechsel = WECHSEL_SET.has(c.prep);
|
||||
let cas, genders, tw = null;
|
||||
if (c.art === 'das') { cas='accusative'; genders=['neuter']; tw = wechsel ? true : null; }
|
||||
else if (c.art === 'dem') { cas='dative'; genders=['masculine','neuter']; tw = wechsel ? false : null; }
|
||||
else { cas='dative'; genders=['feminine']; tw = wechsel ? false : null; }
|
||||
return makeChunkQ(c.prep, cas, tw, state, genders);
|
||||
}
|
||||
|
||||
export function gen(level, state = newState()) {
|
||||
const lv = LEVELS.find(l=>l.id===level);
|
||||
if (lv.type==='chunk') return genContractionContextQ(state);
|
||||
if (lv.type==='contraction') return genContractionQ(state);
|
||||
if (lv.type==='prep') return genPrepQ(level, state);
|
||||
if (lv.type==='wechselContext') return genWechselContextQ(level, state);
|
||||
if (lv.type==='poss') return genPossQ(level, state);
|
||||
if (lv.type==='pronoun') return genPronounQ(level, state);
|
||||
return genArticleQ(level, state);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// STREAKS
|
||||
// Local dates (not UTC) so a late-evening session counts as the
|
||||
// day the user actually experienced.
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
export function localDateStr(d = new Date()) {
|
||||
const p = n => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}`;
|
||||
}
|
||||
const dayDiff = (a, b) => Math.round((Date.parse(b) - Date.parse(a)) / 864e5);
|
||||
|
||||
// Called on every answered question. Idempotent within a day.
|
||||
export function bumpStreak(st, today = localDateStr()) {
|
||||
if (st.ld === today) return st;
|
||||
const streak = st.ld && dayDiff(st.ld, today) === 1 ? (st.streak || 0) + 1 : 1;
|
||||
return { ...st, streak, ld: today };
|
||||
}
|
||||
|
||||
// What to display: a streak is only alive if the last practice was
|
||||
// today or yesterday — otherwise it shows 0 (it has lapsed).
|
||||
export function currentStreak(st, today = localDateStr()) {
|
||||
if (!st?.ld) return 0;
|
||||
const d = dayDiff(st.ld, today);
|
||||
return d >= 0 && d <= 1 ? (st.streak || 0) : 0;
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
DEF, INDEF, PREPS, PREPS_BY_CASE, COMMON_PREPS, RARE_PREPS, ALL_PREPS,
|
||||
NOUNS, nounForm, LEVELS, CONTRACTIONS, CONTRACTABLE, CHUNK_PREPS,
|
||||
POSS_ENDINGS, combinePoss, PRONOUNS, VERBS, PREP_FRAMES,
|
||||
createSession, gen, genContractionContextQ, newState, buildChunkChoices,
|
||||
bumpStreak, currentStreak,
|
||||
} from './engine.js';
|
||||
|
||||
const runSession = (levelId, n) => {
|
||||
const s = createSession(levelId);
|
||||
return Array.from({ length: n }, () => s.next());
|
||||
};
|
||||
|
||||
// ── Grammar data ────────────────────────────────────────────
|
||||
|
||||
describe('declension tables', () => {
|
||||
it('definite articles are correct', () => {
|
||||
expect(DEF.masculine.accusative).toBe('den');
|
||||
expect(DEF.masculine.dative).toBe('dem');
|
||||
expect(DEF.feminine.dative).toBe('der');
|
||||
expect(DEF.neuter.genitive).toBe('des');
|
||||
expect(DEF.plural.dative).toBe('den');
|
||||
});
|
||||
|
||||
it('every masculine/neuter noun has a genitive form ending in -s', () => {
|
||||
for (const n of NOUNS) {
|
||||
if (n.g === 'masculine' || n.g === 'neuter') {
|
||||
expect(n.gen, `${n.w} is missing its genitive form`).toBeTruthy();
|
||||
expect(n.gen.endsWith('s'), `${n.gen} should end in -s`).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('nounForm applies the genitive only for genitive case', () => {
|
||||
const haus = NOUNS.find(n => n.w === 'Haus');
|
||||
expect(nounForm(haus, 'genitive')).toBe('Hauses');
|
||||
expect(nounForm(haus, 'dative')).toBe('Haus');
|
||||
const frau = NOUNS.find(n => n.w === 'Frau');
|
||||
expect(nounForm(frau, 'genitive')).toBe('Frau');
|
||||
});
|
||||
|
||||
it('every frame references real verbs and nouns', () => {
|
||||
const nounNames = new Set(NOUNS.map(n => n.w));
|
||||
const checkFrames = frames => {
|
||||
for (const f of frames) {
|
||||
f.verbs.forEach(v => expect(VERBS[v], `unknown verb ${v}`).toBeTruthy());
|
||||
f.nouns.forEach(n => expect(nounNames.has(n), `unknown noun ${n}`).toBe(true));
|
||||
}
|
||||
};
|
||||
for (const [prep, f] of Object.entries(PREP_FRAMES)) {
|
||||
expect(PREPS[prep], `frame for unknown prep ${prep}`).toBeTruthy();
|
||||
if (Array.isArray(f)) checkFrames(f);
|
||||
else { checkFrames(f.motion); checkFrames(f.location); }
|
||||
}
|
||||
});
|
||||
|
||||
it('every sentence-level preposition has a frame', () => {
|
||||
for (const lv of LEVELS) {
|
||||
if (lv.type === 'article' && lv.preps) {
|
||||
for (const p of lv.preps) expect(PREP_FRAMES[p], `no frame for ${p} (level ${lv.id})`).toBeTruthy();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Article questions ───────────────────────────────────────
|
||||
|
||||
describe('article questions', () => {
|
||||
const articleLevels = LEVELS.filter(l => l.type === 'article').map(l => l.id);
|
||||
|
||||
it.each(articleLevels)('level %s: answer is always among 4 unique choices', (id) => {
|
||||
for (const q of runSession(id, 200)) {
|
||||
expect(q.choices.length).toBe(4);
|
||||
expect(new Set(q.choices).size).toBe(4);
|
||||
expect(q.choices).toContain(q.ans);
|
||||
expect(q.sentence).toContain('_____');
|
||||
}
|
||||
});
|
||||
|
||||
it('genitive sentences use the genitive noun form (des Hauses, not des Haus)', () => {
|
||||
for (const q of runSession(5, 200)) {
|
||||
if (q.type !== 'article') continue;
|
||||
expect(q.cas).toBe('genitive');
|
||||
expect(q.sentence).toContain(nounForm(q.noun, 'genitive'));
|
||||
if (q.noun.gen) expect(q.sentence).not.toContain(` ${q.noun.w}.`);
|
||||
}
|
||||
});
|
||||
|
||||
it('the answer matches the declension table', () => {
|
||||
for (const q of runSession(6, 300)) {
|
||||
if (q.type === 'article') expect(q.ans).toBe(DEF[q.noun.g][q.cas]);
|
||||
}
|
||||
});
|
||||
|
||||
it('never repeats a preposition twice in a row', () => {
|
||||
for (const id of [2, 3, 4, 5]) {
|
||||
const qs = runSession(id, 200);
|
||||
for (let i = 1; i < qs.length; i++) {
|
||||
if (qs[i].prepKey && qs[i-1].prepKey) {
|
||||
expect(qs[i].prepKey, `level ${id} repeated ${qs[i].prepKey} at ${i}`).not.toBe(qs[i-1].prepKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('covers every preposition of the level within one bag cycle', () => {
|
||||
const lv = LEVELS.find(l => l.id === 3);
|
||||
const seen = new Set(runSession(3, lv.preps.length).map(q => q.prepKey));
|
||||
expect([...seen].sort()).toEqual([...lv.preps].sort());
|
||||
});
|
||||
});
|
||||
|
||||
// ── Preposition case quiz ───────────────────────────────────
|
||||
|
||||
describe('prep case quiz (levels 8/9/10)', () => {
|
||||
it('every common prep appears within 16 draws — zu, nach, bei included', () => {
|
||||
const seen = new Set(runSession(8, COMMON_PREPS.length).map(q => q.prepKey));
|
||||
expect([...seen].sort()).toEqual([...COMMON_PREPS].sort());
|
||||
});
|
||||
|
||||
it('every prep appears within one cycle on levels 9 and 10', () => {
|
||||
expect(new Set(runSession(9, RARE_PREPS.length).map(q => q.prepKey)).size).toBe(RARE_PREPS.length);
|
||||
expect(new Set(runSession(10, ALL_PREPS.length).map(q => q.prepKey)).size).toBe(ALL_PREPS.length);
|
||||
});
|
||||
|
||||
it('never shows the same preposition twice in a row', () => {
|
||||
const qs = runSession(10, 300);
|
||||
for (let i = 1; i < qs.length; i++) expect(qs[i].prepKey).not.toBe(qs[i-1].prepKey);
|
||||
});
|
||||
|
||||
it('answers match PREPS and examples are well-formed', () => {
|
||||
for (const q of runSession(10, 300)) {
|
||||
expect(q.ans).toBe(PREPS[q.prepKey].c);
|
||||
expect(q.example.startsWith(q.prepKey) || q.exampleFull).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Wechsel im Kontext ──────────────────────────────────────
|
||||
|
||||
describe('wechsel in context (level 18)', () => {
|
||||
it('neben appears (was underrepresented)', () => {
|
||||
const seen = new Set(runSession(18, 9).map(q => q.prepKey));
|
||||
expect(seen.has('neben')).toBe(true);
|
||||
expect(seen.size).toBe(9);
|
||||
});
|
||||
|
||||
it('never repeats prep back-to-back and rarely repeats the verb', () => {
|
||||
const qs = runSession(18, 300);
|
||||
let verbRepeats = 0;
|
||||
for (let i = 1; i < qs.length; i++) {
|
||||
expect(qs[i].prepKey).not.toBe(qs[i-1].prepKey);
|
||||
if (qs[i].verb === qs[i-1].verb) verbRepeats++;
|
||||
}
|
||||
expect(verbRepeats).toBeLessThanOrEqual(6); // only possible via single-verb frames
|
||||
});
|
||||
|
||||
it('case follows motion/location and the sentence article matches', () => {
|
||||
for (const q of runSession(18, 300)) {
|
||||
expect(q.ans).toBe(q.isMotion ? 'accusative' : 'dative');
|
||||
expect(q.sentence).toContain(` ${q.prepKey} `);
|
||||
expect(q.sentence.endsWith('.')).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Chunk / contraction questions ───────────────────────────
|
||||
|
||||
describe('contractions', () => {
|
||||
it('buildChunkChoices always contains the correct form, 4 unique options', () => {
|
||||
for (const c of CONTRACTIONS.filter(x => x.pref)) {
|
||||
const { correct, choices } = buildChunkChoices(c.prep, c.art);
|
||||
expect(correct).toBe(c.form);
|
||||
expect(choices).toContain(c.form);
|
||||
expect(new Set(choices).size).toBe(4);
|
||||
}
|
||||
});
|
||||
|
||||
it('non-contracting articles keep the two-word form', () => {
|
||||
const { correct, contracts } = buildChunkChoices('an', 'die');
|
||||
expect(contracts).toBe(false);
|
||||
expect(correct).toBe('an die');
|
||||
});
|
||||
|
||||
it('dedicated Kontraktionen level always yields a preferred contraction', () => {
|
||||
const state = newState();
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const q = genContractionContextQ(state);
|
||||
expect(q.contracts).toBe(true);
|
||||
expect(q.pref).toBe(true);
|
||||
const c = CONTRACTIONS.find(x => x.form === q.ans);
|
||||
expect(c, `answer ${q.ans} is a known contraction`).toBeTruthy();
|
||||
expect(DEF[q.noun.g][q.cas]).toBe(c.art);
|
||||
expect(q.choices).toContain(q.ans);
|
||||
}
|
||||
});
|
||||
|
||||
it('mixed definite level routes contractable preps to chunk questions', () => {
|
||||
const qs = runSession(6, 300);
|
||||
for (const q of qs) {
|
||||
if (q.prepKey && CHUNK_PREPS.has(q.prepKey)) expect(q.type).toBe('chunk');
|
||||
}
|
||||
expect(qs.some(q => q.type === 'chunk')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Possessives & pronouns ──────────────────────────────────
|
||||
|
||||
describe('possessive questions', () => {
|
||||
it.each([11, 12, 13])('level %s: answer matches stem + ending, genitive nouns inflected', (id) => {
|
||||
const lv = LEVELS.find(l => l.id === id);
|
||||
for (const q of runSession(id, 300)) {
|
||||
expect(lv.possessives).toContain(q.stem);
|
||||
expect(q.ans).toBe(combinePoss(q.stem, POSS_ENDINGS[q.noun.g][q.cas]));
|
||||
expect(lv.cases).toContain(q.cas);
|
||||
expect(q.sentence).toContain('_____');
|
||||
if (q.cas === 'genitive') expect(q.sentence).toContain(nounForm(q.noun, 'genitive'));
|
||||
}
|
||||
});
|
||||
|
||||
it('level 13 reaches the genitive', () => {
|
||||
expect(runSession(13, 300).some(q => q.cas === 'genitive')).toBe(true);
|
||||
});
|
||||
|
||||
it('dative helfen template only targets people', () => {
|
||||
for (const q of runSession(11, 400)) {
|
||||
if (q.sentence.startsWith('Ich helfe')) {
|
||||
expect(['masculine','feminine','neuter'].includes(q.noun.g)).toBe(true);
|
||||
expect(q.noun.en).not.toBe('table');
|
||||
expect(q.noun.en).not.toBe('week');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('pronoun questions', () => {
|
||||
it.each([14, 15, 16, 17])('level %s: answer matches the pronoun table', (id) => {
|
||||
for (const q of runSession(id, 200)) {
|
||||
const p = PRONOUNS.find(x => x.key === q.pronKey);
|
||||
const expected = p[{nominative:'nom',accusative:'acc',dative:'dat'}[q.cas]];
|
||||
const want = q.cas === 'nominative' ? expected.charAt(0).toUpperCase() + expected.slice(1) : expected;
|
||||
expect(q.ans).toBe(want);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Every level generates without crashing ──────────────────
|
||||
|
||||
describe('all levels', () => {
|
||||
it.each(LEVELS.map(l => [l.id]))('level %s generates 100 questions cleanly', (id) => {
|
||||
for (const q of runSession(id, 100)) {
|
||||
expect(q).toBeTruthy();
|
||||
expect(q.ans).toBeTruthy();
|
||||
expect(q.type).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Streaks (multi-day simulation) ──────────────────────────
|
||||
|
||||
describe('streaks', () => {
|
||||
it('grows by one per consecutive day, no matter how many rounds per day', () => {
|
||||
let st = { streak: 0, ld: null };
|
||||
st = bumpStreak(st, '2026-07-01');
|
||||
expect(st.streak).toBe(1);
|
||||
st = bumpStreak(st, '2026-07-01'); // second round same day
|
||||
st = bumpStreak(st, '2026-07-01');
|
||||
expect(st.streak).toBe(1);
|
||||
st = bumpStreak(st, '2026-07-02');
|
||||
expect(st.streak).toBe(2);
|
||||
st = bumpStreak(st, '2026-07-03');
|
||||
st = bumpStreak(st, '2026-07-03');
|
||||
expect(st.streak).toBe(3);
|
||||
});
|
||||
|
||||
it('resets after a missed day', () => {
|
||||
let st = { streak: 5, ld: '2026-07-01' };
|
||||
st = bumpStreak(st, '2026-07-04');
|
||||
expect(st.streak).toBe(1);
|
||||
});
|
||||
|
||||
it('survives month and year boundaries', () => {
|
||||
let st = bumpStreak({ streak: 0, ld: null }, '2026-01-31');
|
||||
st = bumpStreak(st, '2026-02-01');
|
||||
expect(st.streak).toBe(2);
|
||||
st = { streak: 9, ld: '2026-12-31' };
|
||||
st = bumpStreak(st, '2027-01-01');
|
||||
expect(st.streak).toBe(10);
|
||||
});
|
||||
|
||||
it('displays 0 once the streak has lapsed, without needing a new answer', () => {
|
||||
const st = { streak: 7, ld: '2026-07-01' };
|
||||
expect(currentStreak(st, '2026-07-01')).toBe(7);
|
||||
expect(currentStreak(st, '2026-07-02')).toBe(7); // still alive: practiced yesterday
|
||||
expect(currentStreak(st, '2026-07-03')).toBe(0); // lapsed
|
||||
expect(currentStreak({}, '2026-07-03')).toBe(0);
|
||||
expect(currentStreak(null, '2026-07-03')).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user