// ═══════════════════════════════════════════ // 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']; // "zu" always fuses with a definite article (zum/zur), so a fill-the-article // sentence can never show it honestly — it lives in the Kontraktionen levels // and the mixed rounds, which teach the fused form properly. const SENTENCE_DAT_PREPS = PREPS_BY_CASE.dative.filter(p => p !== 'zu'); // "trotz eines Wetters" is not German — trotz only governs uncountables here, // so the ein-Wörter lesson leaves it out rather than listing a prep it can // never ask about. const INDEF_GEN_PREPS = PREPS_BY_CASE.genitive.filter(p => p !== 'trotz'); 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) // noIndef: never combine with ein-words — mass nouns („eines Wetters" is not // German) and nouns whose frames only read naturally with a definite article // (kinship terms, temporal Nacht/Woche: „während einer Woche" is off). export const NOUNS = [ {w:'Hund',g:'masculine',en:'dog',gen:'Hundes'}, {w:'Mann',g:'masculine',en:'man',gen:'Mannes',enPoss:'husband'}, {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',noIndef:true}, {w:'Lehrer',g:'masculine',en:'teacher',gen:'Lehrers'}, {w:'Vater',g:'masculine',en:'father',gen:'Vaters',noIndef:true}, {w:'Zug',g:'masculine',en:'train',gen:'Zuges'}, {w:'Regen',g:'masculine',en:'rain',gen:'Regens',noIndef:true}, {w:'Frau',g:'feminine',en:'woman',enPoss:'wife'}, {w:'Ärztin',g:'feminine',en:'doctor'}, {w:'Lehrerin',g:'feminine',en:'teacher'}, {w:'Freundin',g:'feminine',en:'friend'}, {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',noIndef:true}, {w:'Mutter',g:'feminine',en:'mother',noIndef:true}, {w:'Tochter',g:'feminine',en:'daughter',noIndef:true}, {w:'Musik',g:'feminine',en:'music',noIndef:true}, {w:'Nacht',g:'feminine',en:'night',noIndef:true}, {w:'Woche',g:'feminine',en:'week',noIndef:true}, {w:'Arbeit',g:'feminine',en:'work',noIndef:true}, {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',noIndef:true}, {w:'Dach',g:'neuter',en:'roof',gen:'Daches'}, {w:'Konzert',g:'neuter',en:'concert',gen:'Konzerts'}, {w:'See',g:'masculine',en:'lake',gen:'Sees'}, {w:'Fluss',g:'masculine',en:'river',gen:'Flusses'}, {w:'Berg',g:'masculine',en:'mountain',gen:'Berges'}, {w:'Wald',g:'masculine',en:'forest',gen:'Waldes'}, {w:'Bahnhof',g:'masculine',en:'station',gen:'Bahnhofs'}, {w:'Flughafen',g:'masculine',en:'airport',gen:'Flughafens'}, {w:'Schrank',g:'masculine',en:'cupboard',gen:'Schranks'}, {w:'Boden',g:'masculine',en:'floor',gen:'Bodens'}, {w:'Supermarkt',g:'masculine',en:'supermarket',gen:'Supermarkts'}, {w:'Keller',g:'masculine',en:'cellar',gen:'Kellers'}, {w:'Wand',g:'feminine',en:'wall'}, {w:'Küche',g:'feminine',en:'kitchen'}, {w:'Wohnung',g:'feminine',en:'flat'}, {w:'Tafel',g:'feminine',en:'blackboard'}, {w:'Wiese',g:'feminine',en:'meadow'}, {w:'Tasche',g:'feminine',en:'bag'}, {w:'Kirche',g:'feminine',en:'church'}, {w:'Bibliothek',g:'feminine',en:'library'}, {w:'Bank',g:'feminine',en:'bench'}, {w:'Bett',g:'neuter',en:'bed',gen:'Bettes'}, {w:'Zimmer',g:'neuter',en:'room',gen:'Zimmers'}, {w:'Regal',g:'neuter',en:'shelf',gen:'Regals'}, {w:'Sofa',g:'neuter',en:'sofa',gen:'Sofas'}, {w:'Meer',g:'neuter',en:'sea',gen:'Meeres'}, {w:'Dorf',g:'neuter',en:'village',gen:'Dorfes'}, {w:'Bild',g:'neuter',en:'picture',gen:'Bildes'}, {w:'Wasser',g:'neuter',en:'water',gen:'Wassers',noIndef:true}, {w:'Brot',g:'neuter',en:'bread',gen:'Brotes',noIndef:true}, {w:'Bier',g:'neuter',en:'beer',gen:'Bieres',noIndef:true}, {w:'Geld',g:'neuter',en:'money',gen:'Geldes',noIndef:true}, {w:'Kaffee',g:'masculine',en:'coffee',gen:'Kaffees',noIndef:true}, {w:'Tee',g:'masculine',en:'tea',gen:'Tees',noIndef:true}, {w:'Wein',g:'masculine',en:'wine',gen:'Weines',noIndef:true}, {w:'Milch',g:'feminine',en:'milk',noIndef:true}, {w:'Zeit',g:'feminine',en:'time',noIndef:true}, ]; 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','Ärztin','Lehrerin','Freundin']; // zu/von/bei + a person means "to/from/at their PLACE". With a bare definite // article that only sounds right for roles you visit (beim Arzt, zur Ärztin); // for family German wants a possessive (bei meiner Schwester), so those frames // are marked possOnly. English always needs the -'s. const VISITABLE = ['Arzt','Lehrer','Freund','Ärztin','Lehrerin','Freundin']; 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', 'Wohnung','Zimmer','Küche','Tasche','Bett','Sofa','Schrank','Regal','Wand','Bild', ]); POSSESSABLE.delete('Mädchen'); // "mein Mädchen" is not idiomatic // German possessives reinterpret these: mein Mann = my husband, meine Frau = my wife. export const possNounEn = noun => noun.enPoss || noun.en; 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; // ═══════════════════════════════════════════ // ADJECTIVE ENDINGS // Three tables, chosen by what stands in front of the adjective. The idea the // whole system rests on (the "one signal" principle American courses teach): // the case/gender signal has to appear ONCE in the noun phrase. If the // determiner already carries it, the adjective goes limp (-e/-en). If there is // no determiner, the adjective has to carry it itself. // ═══════════════════════════════════════════ // der-word endings: der/die/das, dieser, jeder, welcher, jener, alle. // Note genitive masc/neut is -es here but -en on an adjective — the one place // the two tables part company. export const DER_ENDINGS = { masculine:{nominative:'er',accusative:'en',dative:'em',genitive:'es'}, feminine: {nominative:'e', accusative:'e', dative:'er',genitive:'er'}, neuter: {nominative:'es',accusative:'es',dative:'em',genitive:'es'}, plural: {nominative:'e', accusative:'e', dative:'en',genitive:'er'}, }; const ADJ_GENDERS = ['masculine','feminine','neuter','plural']; const ADJ_CASES = ['nominative','accusative','dative','genitive']; // STRONG/WEAK already exist above as single signal letters; as adjective // endings they are those letters with a leading -e where needed. const ending = letter => (letter === 'e' ? 'e' : 'e' + letter); export const ADJ_STRONG = Object.fromEntries(ADJ_GENDERS.map(g => [g, Object.fromEntries(ADJ_CASES.map(c => [c, ending(STRONG[g][c])]))])); export const ADJ_WEAK = Object.fromEntries(ADJ_GENDERS.map(g => [g, Object.fromEntries(ADJ_CASES.map(c => [c, ending(WEAK[g][c])]))])); // MIXED is not a third thing to memorise: it IS weak, except in the three // slots where the ein-word itself has no ending (ein_ guter, ein_ gutes, // ein_ gutes). There the adjective picks up the strong ending instead. // Derived from POSS_ENDINGS so the rule is enforced, never duplicated. export const ADJ_MIXED = Object.fromEntries(ADJ_GENDERS.map(g => [g, Object.fromEntries(ADJ_CASES.map(c => [c, POSS_ENDINGS[g][c] === '' ? ADJ_STRONG[g][c] : ADJ_WEAK[g][c]]))])); export const ADJ_ENDINGS = { strong: ADJ_STRONG, weak: ADJ_WEAK, mixed: ADJ_MIXED }; export const ADJ_CLASS_LABEL = { weak:'Schwach', mixed:'Gemischt', strong:'Stark' }; export const ADJ_CLASS_HINT = { weak: 'after a der-word — it already shows the case', mixed: 'after an ein-word — sometimes it shows nothing', strong:'no determiner — the adjective must show the case', }; // Every distinct ending, so wrong answers are always plausible endings. export const ADJ_ALL_ENDINGS = ['e','en','er','es','em']; // ═══════════════════════════════════════════ // ADJECTIVES // Adjectives declare which kinds of noun they may describe, the same // discipline the preposition frames use — "ein grünes Kind" has to be // impossible to build, not merely unlikely to come up. // ═══════════════════════════════════════════ export const ADJ_NOUN_GROUPS = { people: ['Mann','Frau','Arzt','Ärztin','Freund','Freundin','Lehrer','Lehrerin', 'Bruder','Schwester','Vater','Mutter','Tochter','Kind','Mädchen'], animals: ['Hund','Katze'], things: ['Buch','Auto','Tisch','Stuhl','Fenster','Tür','Bett','Sofa','Schrank', 'Regal','Tasche','Bild','Zug'], // Buildings you can be inside; settlements you can live in; landscape you // cannot ("Ich wohne in dem alten See" was reachable before this split). buildings: ['Haus','Hotel','Museum','Büro','Restaurant','Kirche','Bibliothek', 'Supermarkt','Schule','Bahnhof','Zimmer','Küche','Keller','Wohnung'], settlements:['Stadt','Dorf'], landscape: ['Park','Garten','Wald','See','Fluss','Berg','Meer','Wiese','Brücke','Straße'], mass: ['Wasser','Milch','Brot','Kaffee','Tee','Bier','Wein','Geld','Zeit','Musik','Arbeit'], weather: ['Wetter','Regen'], }; // groups = broad compatibility; nouns = an explicit list where the adjective is // fussy and a whole group would let nonsense through. // enPeople: German groß/klein mean tall/short of a person. // neg: excluded from frames that assert liking ("I like the bad kitchen"). export const ADJECTIVES = [ {de:'groß', en:'big', enPeople:'tall', groups:['people','animals','things','buildings','settlements','landscape']}, {de:'klein', en:'small', enPeople:'short', groups:['people','animals','things','buildings','settlements','landscape']}, {de:'alt', en:'old', groups:['people','animals','things','buildings','settlements']}, {de:'neu', en:'new', groups:['things','buildings']}, {de:'jung', en:'young', groups:['people','animals']}, {de:'schön', en:'beautiful', groups:['things','buildings','settlements','landscape'], nouns:['Musik','Wetter']}, {de:'gut', en:'good', groups:['things','buildings','people','mass','weather']}, {de:'schlecht', en:'bad', neg:true, groups:['things','buildings','mass','weather']}, {de:'freundlich', en:'friendly', groups:['people','animals']}, {de:'müde', en:'tired', neg:true, groups:['people','animals']}, {de:'krank', en:'sick', neg:true, groups:['people','animals']}, {de:'interessant', en:'interesting', nouns:['Buch','Bild','Museum','Arbeit','Musik','Konzert']}, {de:'wichtig', en:'important', nouns:['Buch','Arbeit','Zeit','Geld','Konzert']}, {de:'billig', en:'cheap', nouns:['Auto','Buch','Tasche','Zug','Hotel','Restaurant','Kaffee','Bier','Wein','Bild']}, {de:'kalt', en:'cold', nouns:['Wasser','Milch','Kaffee','Tee','Bier','Wetter','Zimmer','Keller','Haus','Nacht']}, {de:'warm', en:'warm', nouns:['Wasser','Milch','Kaffee','Tee','Brot','Wetter','Zimmer','Haus']}, {de:'laut', en:'loud', neg:true, nouns:['Musik','Stadt','Straße','Restaurant','Hund','Konzert','Kind']}, {de:'ruhig', en:'quiet', nouns:['Zimmer','Dorf','Garten','Wald','Straße','Hund','Katze','Kind']}, {de:'lang', en:'long', nouns:['Straße','Brücke','Fluss','Buch','Nacht','Woche','Arbeit']}, {de:'rot', en:'red', nouns:['Auto','Buch','Tasche','Tür','Sofa','Bild','Wein','Katze']}, {de:'grün', en:'green', nouns:['Auto','Buch','Tasche','Tür','Sofa','Wiese','Wald','Tee']}, {de:'schwarz', en:'black', nouns:['Auto','Buch','Tasche','Tür','Sofa','Hund','Katze','Kaffee','Tee','Bild']}, {de:'weiß', en:'white', nouns:['Auto','Haus','Tür','Sofa','Tasche','Hund','Katze','Milch','Brot','Wein']}, {de:'blau', en:'blue', nouns:['Auto','Buch','Tasche','Tür','Sofa','See','Meer']}, ]; export const adjNouns = adj => [...new Set([ ...(adj.groups || []).flatMap(g => ADJ_NOUN_GROUPS[g]), ...(adj.nouns || []), ])]; const PEOPLE_SET = new Set(ADJ_NOUN_GROUPS.people); export const adjEn = (adj, noun) => (adj.enPeople && PEOPLE_SET.has(noun.w)) ? adj.enPeople : adj.en; export const DER_WORD_DETS = [ {stem:'d', en:'the', definite:true}, {stem:'dies', en:'this'}, {stem:'jed', en:'every'}, ]; export const EIN_WORD_DETS = [ {stem:'ein', en:'a'}, {stem:'kein', en:'no'}, {stem:'mein', en:'my', poss:true}, {stem:'dein', en:'your', poss:true}, {stem:'sein', en:'his', poss:true}, {stem:'ihr', en:'her', poss:true}, {stem:'unser', en:'our', poss:true}, ]; export function determinerFor(cls, det, g, cas) { if (cls === 'strong') return ''; if (cls === 'weak') return det.definite ? DEF[g][cas] : det.stem + DER_ENDINGS[g][cas]; return combinePoss(det.stem, POSS_ENDINGS[g][cas]); } // Sentence carriers. `prep` is kept OUT of dePre so it can fuse with the // article (in + dem -> im). deCap/enCap mark which sentence the noun phrase opens. // noNeg drops adjectives that would contradict the frame ("I like the bad hotel"). export const ADJ_FRAMES = [ {cas:'nominative', dePre:'', dePost:' gefällt mir.', enPre:'I like ', enPost:'.', deCap:true, noNeg:true, groups:['people','animals','things','buildings','settlements','landscape'], nouns:['Musik','Wetter','Wein','Kaffee','Brot']}, {cas:'nominative', dePre:'', dePost:' ist da.', enPre:'', enPost:' is there.', deCap:true, enCap:true, groups:['people','animals','things']}, {cas:'accusative', dePre:'Ich sehe ', dePost:'.', enPre:'I see ', enPost:'.', groups:['people','animals','things','buildings','settlements','landscape']}, {cas:'accusative', dePre:'Ich kaufe ', dePost:'.', enPre:'I buy ', enPost:'.', groups:['things'], nouns:['Brot','Wein','Bier','Kaffee','Tee','Milch']}, {cas:'accusative', dePre:'Ich mag ', dePost:'.', enPre:'I like ', enPost:'.', noNeg:true, groups:['people','animals','things','buildings','settlements','landscape'], nouns:['Musik','Kaffee','Tee','Wein','Bier','Brot']}, {cas:'dative', dePre:'Ich helfe ', dePost:'.', enPre:'I help ', enPost:'.', groups:['people','animals']}, {cas:'dative', dePre:'Ich spiele ', prep:'mit', dePost:'.', enPre:'I play with ', enPost:'.', groups:['animals']}, {cas:'dative', dePre:'Ich wohne ', prep:'in', dePost:'.', enPre:'I live in ', enPost:'.', groups:['buildings','settlements']}, {cas:'dative', dePre:'Ich koche ', prep:'mit', dePost:'.', enPre:'I cook with ', enPost:'.', nouns:['Wasser','Milch','Brot','Wein']}, {cas:'dative', dePre:'Ich warte ', prep:'vor', dePost:'.', enPre:'I wait in front of ', enPost:'.', groups:['buildings']}, {cas:'genitive', dePre:'Ich arbeite ', prep:'trotz', dePost:'.', enPre:'I work despite ', enPost:'.', nouns:['Wetter','Regen','Musik','Arbeit'], classes:['weak','strong']}, {cas:'genitive', dePre:'Ich bleibe ', prep:'wegen', dePost:'.', enPre:'I stay because of ', enPost:'.', nouns:['Wetter','Regen','Musik'], classes:['weak','strong']}, ]; ADJ_FRAMES.forEach((f, i) => { f.id = i; }); const MASS_LIKE = new Set([...ADJ_NOUN_GROUPS.mass, ...ADJ_NOUN_GROUPS.weather]); const framePool = f => [...new Set([ ...(f.groups || []).flatMap(g => ADJ_NOUN_GROUPS[g]), ...(f.nouns || []), ])]; 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'}}, rennen: {de:{Er:'rennt',Sie:'rennt',Wir:'rennen',Ich:'renne'}, en:{Er:'runs',Sie:'runs',Wir:'run',Ich:'run'}}, klettern:{de:{Er:'klettert',Sie:'klettert',Wir:'klettern',Ich:'klettere'}, en:{Er:'climbs',Sie:'climbs',Wir:'climb',Ich:'climb'}}, fliegen: {de:{Er:'fliegt',Sie:'fliegt',Wir:'fliegen',Ich:'fliege'}, en:{Er:'flies',Sie:'flies',Wir:'fly',Ich:'fly'}}, schwimmen:{de:{Er:'schwimmt',Sie:'schwimmt',Wir:'schwimmen',Ich:'schwimme'}, en:{Er:'swims',Sie:'swims',Wir:'swim',Ich:'swim'}}, steigen: {de:{Er:'steigt',Sie:'steigt',Wir:'steigen',Ich:'steige'}, en:{Er:'climbs',Sie:'climbs',Wir:'climb',Ich:'climb'}}, werfen: {de:{Er:'wirft',Sie:'wirft',Wir:'werfen',Ich:'werfe'}, en:{Er:'throws',Sie:'throws',Wir:'throw',Ich:'throw'}}, stecken: {de:{Er:'steckt',Sie:'steckt',Wir:'stecken',Ich:'stecke'}, en:{Er:'puts',Sie:'puts',Wir:'put',Ich:'put'}}, setzen: {de:{Er:'setzt',Sie:'setzt',Wir:'setzen',Ich:'setze'}, en:{Er:'sits',Sie:'sits',Wir:'sit',Ich:'sit'}}, kleben: {de:{Er:'klebt',Sie:'klebt',Wir:'kleben',Ich:'klebe'}, en:{Er:'sticks',Sie:'sticks',Wir:'stick',Ich:'stick'}}, schreiben:{de:{Er:'schreibt',Sie:'schreibt',Wir:'schreiben',Ich:'schreibe'}, en:{Er:'writes',Sie:'writes',Wir:'write',Ich:'write'}}, fallen: {de:{Er:'fällt',Sie:'fällt',Wir:'fallen',Ich:'falle'}, en:{Er:'falls',Sie:'falls',Wir:'fall',Ich:'fall'}}, ziehen: {de:{Er:'zieht',Sie:'zieht',Wir:'ziehen',Ich:'ziehe'}, en:{Er:'moves',Sie:'moves',Wir:'move',Ich:'move'}}, tragen: {de:{Er:'trägt',Sie:'trägt',Wir:'tragen',Ich:'trage'}, en:{Er:'carries',Sie:'carries',Wir:'carry',Ich:'carry'}}, schieben:{de:{Er:'schiebt',Sie:'schiebt',Wir:'schieben',Ich:'schiebe'}, en:{Er:'pushes',Sie:'pushes',Wir:'push',Ich:'push'}}, packen: {de:{Er:'packt',Sie:'packt',Wir:'packen',Ich:'packe'}, en:{Er:'packs',Sie:'packs',Wir:'pack',Ich:'pack'}}, tanzen: {de:{Er:'tanzt',Sie:'tanzt',Wir:'tanzen',Ich:'tanze'}, en:{Er:'is dancing',Sie:'is dancing',Wir:'are dancing',Ich:'am dancing'}}, leben: {de:{Er:'lebt',Sie:'lebt',Wir:'leben',Ich:'lebe'}, en:{Er:'lives',Sie:'lives',Wir:'live',Ich:'live'}}, ankommen:{de:{Er:'kommt',Sie:'kommt',Wir:'kommen',Ich:'komme'}, en:{Er:'arrives',Sie:'arrives',Wir:'arrive',Ich:'arrive'}}, }; 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: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, aus, bei, nach, seit...', cases:['dative'], preps:SENTENCE_DAT_PREPS, 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:['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:['accusative','dative','genitive'], preps:[...SENTENCE_ACC_PREPS,...PREPS_BY_CASE.dative,...INDEF_GEN_PREPS,...PREPS_BY_CASE.wechsel], type:'article', artType:'indefinite', mixed:true}, // Daily Practice pseudo-levels (one per tab): a one-tap drill that mixes // questions from every lesson in the tab. Rendered pinned at the top of each // tab by App.jsx (not inline in the level list) and locked until every // lesson in the tab has been passed. {id:7.5, tab:'article', name:'Daily Practice', desc:'Every article lesson, mixed · one-tap', daily: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:10.5, tab:'prep', name:'Daily Practice', desc:'Every preposition lesson, mixed · one-tap', daily:true}, {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 — Akkusativ & Dativ', possessives:['mein','dein','sein'], cases:['accusative','dative'], type:'poss'}, {id:12, tab:'poss', name:'Schwierige Possessive', desc:'ihr · unser · euer — Akkusativ & Dativ', possessives:['ihr','unser','euer'], cases:['accusative','dative'], type:'poss'}, {id:13, tab:'poss', name:'Alle gemischt', desc:'mein, dein, sein, ihr, unser, euer — incl. Genitiv', possessives:['mein','dein','sein','ihr','unser','euer'], cases:['accusative','dative','genitive'], type:'poss'}, {id:13.5, tab:'poss', name:'Daily Practice', desc:'Every possessive lesson, mixed · one-tap', daily:true}, {id:14, tab:'pronoun', name:'Singular Pronomen', desc:'mich, mir, dich, dir…', pronouns:SG_PRONOUN_KEYS, cases:['accusative','dative'], type:'pronoun'}, {id:15, tab:'pronoun', name:'Plural Pronomen', desc:'uns, euch, ihnen…', pronouns:PL_PRONOUN_KEYS, cases:['accusative','dative'], type:'pronoun'}, {id:16, tab:'pronoun', name:'Sie (formal)', desc:'Formal Sie — singular and plural', pronouns:FORMAL_PRONOUN_KEYS, cases:['accusative','dative'], type:'pronoun'}, {id:17, tab:'pronoun', name:'Alle gemischt', desc:'All 10 pronouns, Akkusativ & Dativ', pronouns:ALL_PRONOUN_KEYS, cases:['accusative','dative'], type:'pronoun'}, {id:17.5, tab:'pronoun', name:'Daily Practice', desc:'Every pronoun lesson, mixed · one-tap', daily:true}, // Adjective endings. Nominative IS tested here, unlike everywhere else in the // app: der gute Mann vs ein guter Mann is the clearest illustration of the // whole weak/mixed split, and skipping it would gut the section. {id:20, tab:'adj', name:'Schwach', desc:'After der, diese, jede — only -e and -en', classes:['weak'], cases:['nominative','accusative','dative'], type:'adjective'}, {id:21, tab:'adj', name:'Gemischt', desc:'After ein, kein, mein — weak plus three exceptions', classes:['mixed'], cases:['nominative','accusative','dative'], type:'adjective'}, {id:22, tab:'adj', name:'Stark', desc:'No determiner — the adjective carries the case', classes:['strong'], cases:['nominative','accusative','dative'], type:'adjective'}, {id:23, tab:'adj', name:'Welche Tabelle?', desc:'der-word, ein-word or nothing at all?', classes:['weak','mixed','strong'], cases:['nominative','accusative','dative'], type:'adjClass'}, {id:24, tab:'adj', name:'Nach Präposition', desc:'The full chain: preposition → case → table', classes:['weak','mixed','strong'], cases:['accusative','dative','genitive'], type:'adjective', prepOnly:true}, {id:25, tab:'adj', name:'Alles gemischt', desc:'All three tables, including Genitiv', classes:['weak','mixed','strong'], cases:['nominative','accusative','dative','genitive'], type:'adjective'}, {id:25.5, tab:'adj', name:'Daily Practice', desc:'Every adjective lesson, mixed · one-tap', daily:true}, ]; // 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)); export const ALL_GENDERS = ['masculine','feminine','neuter','plural']; // Genders whose definite article does NOT fuse with this preposition. German // all but requires zum/zur/im/ins/am/ans/beim/vom, so a fill-the-article // sentence must never ask for the two-word form of one of those — it would be // teaching a phrase no German says. Those pairings live in the fused // "Kontraktionen" questions instead, which is where they belong. export function nonFusingGenders(prepKey, cas) { return ALL_GENDERS.filter(g => !CONTRACTABLE[prepKey]?.[DEF[g][cas]]); } // 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. // ═══════════════════════════════════════════ const ZW_POST = {accusative:' und das Fenster', dative:' und dem Fenster', en:' and the window'}; 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:VISITABLE, enPossS:true}, {verbs:['wohnen','sein','warten'], nouns:PERSONS, enPossS:true, possOnly:true}, ], 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, indefNouns:['Woche','Nacht']}], 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, // "seit einer Woche" is "FOR a week" in English, not "since a week". indefNouns:['Woche'], enPrepIndef:'for'}], von: [ {verbs:['kommen'], nouns:VISITABLE, enPossS:true}, {verbs:['kommen'], nouns:['Arbeit','Schule','Büro']}, {verbs:['kommen'], nouns:PERSONS, enPossS:true, possOnly:true}, ], zu: [ {verbs:['gehen','fahren'], nouns:VISITABLE, enPossS:true}, {verbs:['gehen','fahren'], nouns:['Schule','Park','Museum','Büro','Restaurant','Hotel']}, {verbs:['gehen','fahren'], nouns:PERSONS, enPossS:true, possOnly:true}, ], 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']}], // ── Wechselpräpositionen ──────────────────────────────────── // Split by what DECIDES the case, not by "is there movement?". // motion → the phrase is the goal: the thing wasn't there, now it is. // location → the phrase is the arena: the thing was already there. // Motion verbs deliberately appear on the DATIVE side too (schwimmen im See, // tanzen in der Küche, spielen auf der Straße) — that is the whole lesson. // Every frame is one-way: no verb+noun pairing may license both readings, // because level 18 blanks the article and the learner has only the verb. an: { motion: [ {verbs:['gehen','laufen'], nouns:['Tür','Fenster','Tafel']}, {verbs:['hängen'], mid:{de:'das Bild',en:'the picture'}, nouns:['Wand','Tür'], enPrep:'on'}, {verbs:['schreiben'], mid:{de:'das Wort',en:'the word'}, nouns:['Tafel','Wand'], enPrep:'on'}, {verbs:['kleben'], mid:{de:'den Zettel',en:'the note'}, nouns:['Tür','Fenster','Schrank'], enPrep:'on'}, {verbs:['stellen'], mid:{de:'den Stuhl',en:'the chair'}, nouns:['Fenster','Wand','Tür'], enPrep:'against'}, {verbs:['fahren'], nouns:['See','Fluss','Meer']}, ], location: [ {verbs:['stehen','warten','sitzen'], nouns:['Tür','Fenster','Tafel']}, {verbs:['hängen'], fixedSubj:{de:'Das Bild',en:'The picture'}, nouns:['Wand','Tür'], enPrep:'on', enVerb:{Er:'is hanging',Sie:'is hanging',Wir:'are hanging',Ich:'am hanging'}}, {verbs:['wohnen','leben'], nouns:['See','Fluss'], enPrep:'by'}, {verbs:['arbeiten'], nouns:['Tisch','Fenster']}, {verbs:['spielen','schlafen'], nouns:['See','Fluss','Meer'], enPrep:'by'}, // ankommen takes DATIVE although you obviously arrived — the an- prefix // already carries the arrival, so the phrase only locates it. {verbs:['ankommen'], nouns:['Bahnhof','Flughafen'], post:{dative:' an', en:''}}, ], }, auf: { motion: [ {verbs:['legen'], mid:{de:'das Buch',en:'the book'}, nouns:['Tisch','Stuhl','Regal','Bett','Sofa','Boden']}, {verbs:['stellen'], mid:{de:'die Lampe',en:'the lamp'}, nouns:['Tisch','Regal','Boden','Schrank']}, {verbs:['setzen'], mid:{de:'das Kind',en:'the child'}, nouns:['Stuhl','Sofa','Bank','Bett']}, {verbs:['werfen'], mid:{de:'den Ball',en:'the ball'}, nouns:['Dach','Boden','Bett','Sofa']}, {verbs:['klettern','steigen'], nouns:['Berg','Baum','Dach','Schrank']}, {verbs:['springen'], fixedSubj:{de:'Die Katze',en:'The cat'}, nouns:['Tisch','Dach','Sofa','Schrank']}, {verbs:['gehen','laufen'], nouns:['Straße','Wiese','Brücke']}, ], location: [ {verbs:['sitzen'], nouns:['Stuhl','Sofa','Bank','Boden','Bett']}, {verbs:['liegen'], fixedSubj:{de:'Das Buch',en:'The book'}, nouns:['Tisch','Bett','Regal','Boden','Sofa']}, {verbs:['stehen','warten'], nouns:['Straße','Dach','Berg','Brücke','Wiese']}, {verbs:['spielen','tanzen'], nouns:['Straße','Wiese','Boden','Brücke']}, {verbs:['schlafen'], nouns:['Sofa','Boden','Bett']}, {verbs:['arbeiten'], nouns:['Dach','Wiese','Straße']}, ], }, hinter: { motion: [ {verbs:['gehen','laufen','fahren'], nouns:['Haus','Baum','Garten','Schule','Berg','Kirche']}, {verbs:['stellen'], mid:{de:'den Stuhl',en:'the chair'}, nouns:['Tisch','Tür','Schrank','Sofa']}, {verbs:['werfen'], mid:{de:'den Ball',en:'the ball'}, nouns:['Haus','Baum','Schrank','Sofa']}, {verbs:['legen'], mid:{de:'die Tasche',en:'the bag'}, nouns:['Tür','Schrank','Sofa']}, ], location: [ {verbs:['stehen','warten'], nouns:['Haus','Baum','Tür','Schule','Kirche','Bahnhof']}, {verbs:['liegen'], fixedSubj:{de:'Der Ball',en:'The ball'}, nouns:['Schrank','Sofa','Baum','Haus']}, {verbs:['spielen'], nouns:['Haus','Schule','Garten','Kirche']}, {verbs:['sitzen'], nouns:['Tisch','Baum','Schrank']}, {verbs:['wohnen','leben'], nouns:['Bahnhof','Kirche','Supermarkt','Wald']}, ], }, in: { motion: [ // places you can drive into vs places you only enter on foot {verbs:['gehen','fahren','kommen'], nouns:['Park','Garten','Stadt','Schule','Haus','Hotel','Museum','Büro','Restaurant','Wald','Dorf']}, {verbs:['gehen','laufen','rennen','kommen'], nouns:['Küche','Zimmer','Keller','Kirche','Bibliothek','Supermarkt','Haus','Schule','Museum','Restaurant','Büro','Garten']}, {verbs:['springen','fallen'], nouns:['See','Fluss','Wasser','Meer']}, {verbs:['legen'], mid:{de:'das Buch',en:'the book'}, nouns:['Tasche','Schrank','Regal']}, {verbs:['stecken'], mid:{de:'den Schlüssel',en:'the key'}, nouns:['Tasche','Tür','Schrank']}, {verbs:['packen'], mid:{de:'das Buch',en:'the book'}, nouns:['Tasche','Schrank']}, {verbs:['tragen'], mid:{de:'die Tasche',en:'the bag'}, nouns:['Küche','Zimmer','Keller','Haus']}, {verbs:['ziehen'], nouns:['Wohnung','Stadt','Dorf','Haus']}, ], location: [ {verbs:['wohnen','leben'], nouns:['Stadt','Haus','Hotel','Dorf','Wohnung','Wald']}, {verbs:['sein','warten','sitzen','arbeiten'], nouns:[...PLACES_IN,'Kirche','Bibliothek','Supermarkt','Küche','Zimmer','Keller']}, {verbs:['schwimmen'], nouns:['See','Fluss','Meer','Wasser']}, {verbs:['tanzen','spielen'], nouns:['Küche','Zimmer','Garten','Wohnung','Wald']}, {verbs:['schlafen'], nouns:['Bett','Zimmer','Hotel','Keller']}, {verbs:['liegen'], fixedSubj:{de:'Das Buch',en:'The book'}, nouns:['Tasche','Schrank','Regal']}, {verbs:['stecken'], fixedSubj:{de:'Der Schlüssel',en:'The key'}, nouns:['Tür','Tasche','Schrank'], enVerb:{Er:'is stuck',Sie:'is stuck',Wir:'are stuck',Ich:'am stuck'}}, {verbs:['ankommen'], nouns:['Stadt','Hotel','Dorf'], post:{dative:' an', en:''}}, ], }, neben: { motion: [ {verbs:['stellen'], mid:{de:'die Lampe',en:'the lamp'}, nouns:['Tisch','Stuhl','Tür','Bett','Sofa','Schrank']}, {verbs:['legen'], mid:{de:'das Buch',en:'the book'}, nouns:['Stuhl','Tisch','Bett','Sofa']}, {verbs:['setzen'], mid:{de:'das Kind',en:'the child'}, nouns:['Fenster','Tür','Schrank']}, {verbs:['schieben'], mid:{de:'den Stuhl',en:'the chair'}, nouns:['Tisch','Bett','Regal']}, ], location: [ {verbs:['stehen','sitzen'], nouns:['Tisch','Tür','Fenster','Bett','Schrank','Sofa']}, {verbs:['wohnen','leben'], nouns:['Schule','Park','Museum','Kirche','Bahnhof','Supermarkt','Bibliothek']}, {verbs:['liegen'], fixedSubj:{de:'Das Buch',en:'The book'}, nouns:['Bett','Sofa','Tisch','Regal']}, {verbs:['warten'], nouns:['Tür','Bahnhof','Kirche']}, {verbs:['arbeiten'], nouns:['Fenster','Tür']}, ], }, über: { motion: [ {verbs:['gehen','laufen','fahren','rennen'], nouns:['Straße','Brücke','Wiese']}, {verbs:['hängen'], mid:{de:'die Lampe',en:'the lamp'}, nouns:['Tisch','Bett','Sofa']}, {verbs:['fliegen'], nouns:['Stadt','Berg','Meer','Wald','See']}, {verbs:['werfen'], mid:{de:'den Ball',en:'the ball'}, nouns:['Wand','Brücke','Schrank']}, ], location: [ {verbs:['hängen'], fixedSubj:{de:'Die Lampe',en:'The lamp'}, nouns:['Tisch','Bett','Sofa'], enVerb:{Er:'is hanging',Sie:'is hanging',Wir:'are hanging',Ich:'am hanging'}}, {verbs:['wohnen','leben'], nouns:['Restaurant','Supermarkt','Büro','Bibliothek']}, {verbs:['arbeiten'], nouns:['Restaurant','Büro','Supermarkt']}, ], }, unter: { motion: [ {verbs:['legen'], mid:{de:'das Buch',en:'the book'}, nouns:['Tisch','Stuhl','Bett','Sofa','Regal']}, {verbs:['stellen'], mid:{de:'die Tasche',en:'the bag'}, nouns:['Tisch','Bett','Stuhl','Regal']}, {verbs:['werfen'], mid:{de:'den Ball',en:'the ball'}, nouns:['Sofa','Bett','Tisch','Schrank']}, {verbs:['schieben'], mid:{de:'die Tasche',en:'the bag'}, nouns:['Bett','Sofa','Tisch']}, {verbs:['laufen','rennen'], fixedSubj:{de:'Die Katze',en:'The cat'}, nouns:['Tisch','Baum','Bett','Sofa']}, ], location: [ {verbs:['sitzen','stehen','schlafen'], nouns:['Baum','Brücke','Dach']}, {verbs:['liegen'], fixedSubj:{de:'Das Buch',en:'The book'}, nouns:['Bett','Sofa','Tisch','Stuhl','Regal']}, {verbs:['spielen','tanzen'], nouns:['Baum','Brücke','Dach']}, {verbs:['warten'], nouns:['Brücke','Baum','Dach']}, {verbs:['wohnen'], nouns:['Dach']}, ], }, vor: { motion: [ {verbs:['fahren','gehen','laufen'], nouns:['Haus','Tür','Schule','Hotel','Kirche','Bahnhof','Supermarkt']}, {verbs:['stellen'], mid:{de:'den Stuhl',en:'the chair'}, nouns:['Fenster','Tür','Schrank','Regal']}, {verbs:['legen'], mid:{de:'die Tasche',en:'the bag'}, nouns:['Tür','Bett','Sofa']}, ], location: [ {verbs:['stehen','warten'], nouns:['Tür','Haus','Schule','Hotel','Museum','Restaurant','Kirche','Bahnhof','Supermarkt','Bibliothek']}, {verbs:['sitzen'], nouns:['Fenster','Haus','Tür']}, {verbs:['spielen'], nouns:['Haus','Schule','Kirche','Garten']}, {verbs:['tanzen'], nouns:['Haus','Kirche','Hotel']}, {verbs:['liegen'], fixedSubj:{de:'Die Tasche',en:'The bag'}, nouns:['Tür','Bett','Sofa','Schrank']}, ], }, zwischen: { motion: [ {verbs:['stellen'], mid:{de:'die Lampe',en:'the lamp'}, nouns:['Tisch','Stuhl','Sofa','Schrank'], post:ZW_POST}, {verbs:['legen'], mid:{de:'das Buch',en:'the book'}, nouns:['Tisch','Stuhl','Bett'], post:ZW_POST}, {verbs:['setzen'], mid:{de:'das Kind',en:'the child'}, nouns:['Stuhl','Sofa'], post:ZW_POST}, ], location: [ {verbs:['stehen','sitzen'], nouns:['Tisch','Stuhl','Sofa','Schrank'], post:ZW_POST}, {verbs:['liegen'], fixedSubj:{de:'Das Buch',en:'The book'}, nouns:['Tisch','Stuhl','Regal'], post:ZW_POST}, {verbs:['warten'], nouns:['Tisch','Schrank'], post:ZW_POST}, ], }, }; // ═══════════════════════════════════════════ // 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. // Daily levels mix questions from every lesson in their tab: a shuffle-bag // deals the lesson ids so no lesson repeats back-to-back, and each lesson // keeps its own state (their bag pools differ, so they can't share one). export function createSession(levelId) { const lv = LEVELS.find(l => l.id === levelId); if (lv?.daily) { const lessonIds = LEVELS.filter(l => l.tab === lv.tab && !l.daily).map(l => l.id); const meta = newState(); const states = {}; return { next: () => { const id = drawBag(meta, 'lvl', lessonIds, meta.last.lvl); meta.last.lvl = id; if (!states[id]) states[id] = newState(); return gen(id, states[id]); }, state: meta, }; } 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. // Which frames are usable for a given preposition and question flavour. // Pure: no randomness, so the enumerator below sees exactly what players see. export function usableFrames(prepKey, tw, { genderFilter, possOnly, indefOnly } = {}) { let frames = framesFor(prepKey, tw); // possOnly frames exist for possessive questions only — "bei meiner Schwester" // is natural German, "bei der Schwester" is not. frames = possOnly ? frames : frames.filter(f => !f.possOnly); 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 (indefOnly) { // ein-word questions. A frame whose English determiner is a baked-in // definite idiom ("nach der Schule" → after school) can still work with an // ein-word if it says which nouns to use instead: "nach einer Woche" is // fine even though "während einer Woche" is not, so the allowance is // per-frame rather than per-noun. frames = frames .filter(f => f.indefNouns || f.enDet === undefined) .map(f => { const g = {...f, nouns: f.indefNouns || f.nouns.filter(n => !NOUN_BY_NAME[n].noIndef)}; if (f.enPrepIndef) g.enPrep = f.enPrepIndef; delete g.enDet; // the caller supplies a/an return g; }) .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); } return frames; } // Turn one fully-chosen combination into sentence parts. Pure — every sentence // the app can ever show comes through here, which is what makes the corpus // snapshot in the tests exhaustive rather than a sample. export function composeFrame(prepKey, cas, tw, frame, verbKey, nounName, subjKey) { const noun = NOUN_BY_NAME[nounName]; 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 { subjDe = subjKey; subjEn = SUBJ_EN[subjKey]; deVkey = subjKey; enVkey = subjKey; } 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'; // "bei + person" is "at someone's place" in English, never "at the sister". const enPossS = !!frame.enPossS; return { noun, verbKey, frame, subjDe, subjEn, verbDe, verbEn, midDe, midEn, enPrep, postDe, postEn, enDet, enPossS }; } function realizeFrame(prepKey, cas, tw, state, opts = {}) { const frames = usableFrames(prepKey, tw, opts); 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 verbKey = pickAvoid(frame.verbs, state.last.verb); state.last.noun = nounName; state.last.verb = verbKey; let subjKey = null; if (!frame.fixedSubj) { subjKey = pickAvoid(['Er','Sie','Wir','Ich'], state.last.subj); state.last.subj = subjKey; } return composeFrame(prepKey, cas, tw, frame, verbKey, nounName, subjKey); } const enNoun = (r, det, poss = false) => `${det}${det ? ' ' : ''}${poss ? possNounEn(r.noun) : r.noun.en}${r.enPossS ? "'s" : ''}`; // ═══════════════════════════════════════════ // QUESTION GENERATORS // ═══════════════════════════════════════════ // The answer plus 3 distractors drawn from a pool, shuffled. function pickChoices(ans, pool, n = 4) { const rem = [...new Set(pool)].filter(a => a !== ans); const picked = []; while (picked.length < n - 1 && rem.length) picked.push(rem.splice(Math.floor(Math.random()*rem.length), 1)[0]); return [ans, ...picked].sort(() => Math.random() - 0.5); } function articleChoices(ans, artType) { return pickChoices(ans, artType === 'indefinite' ? ['ein','eine','einen','einem','eines','einer','keine','keinen','keiner'] : ['der','die','das','den','dem','des']); } // All declined forms of one possessive stem (mein, meine, meinen, meinem…) — // every distractor shares the stem, so the choices never leak which ending is right. function possChoices(stem, ans) { const pool = []; for (const g of Object.keys(POSS_ENDINGS)) { for (const c of Object.keys(POSS_ENDINGS[g])) pool.push(combinePoss(stem, POSS_ENDINGS[g][c])); } return pickChoices(ans, pool); } // The target pronoun's own case forms first (mich vs mir vs ich is the real // test), topped up with other pronouns' object forms. function pronounChoices(pron, ans) { const own = [...new Set([pron.nom, pron.acc, pron.dat])].filter(f => f !== ans); const others = shuffle([...new Set(PRONOUNS.filter(p => p.key !== pron.key).flatMap(p => [p.acc, p.dat]))]) .filter(f => f !== ans && !own.includes(f)); const picked = [...shuffle(own), ...others].slice(0, 3); return [ans, ...picked].sort(() => Math.random() - 0.5); } export function genArticleQ(level, state = newState(), depth = 0) { const lv = LEVELS.find(l=>l.id===level); const indef = lv.artType === 'indefinite'; // Draw preps until one yields a usable frame. With ein-words, mass nouns and // definite idioms can rule a preposition out entirely (e.g. trotz, whose // nouns are all uncountable — „trotz eines Wetters" is not German). let prepKey, cas, tw = null, r = null; for (let i = 0; i <= lv.preps.length && !r; i++) { prepKey = drawBag(state, 'prep', lv.preps, state.last.prep); const pc = PREPS[prepKey].c; if (pc === 'wechsel') { tw = Math.random() > 0.5; cas = tw ? 'accusative' : 'dative'; } else { tw = null; cas = pc; } // Mixed definite lessons: contraction-capable prep → fused "chunk" question. if (lv.mixed && lv.artType === 'definite' && CHUNK_PREPS.has(prepKey)) { state.last.prep = prepKey; return makeChunkQ(prepKey, cas, tw, state); } // Bare definite article: only genders that don't force a contraction. r = realizeFrame(prepKey, cas, tw, state, indef ? { indefOnly: true } : { genderFilter: nonFusingGenders(prepKey, cas) }); } // No usable pairing for this preposition — draw again. Bounded so a data // mistake degrades to one odd question instead of hanging the app. if (!r) { if (depth >= 8) r = realizeFrame(prepKey, cas, tw, state, {}); else return genArticleQ(level, state, depth + 1); } state.last.prep = prepKey; const tbl = indef ? INDEF : DEF; const ans = tbl[r.noun.g][cas]; // ein-words translate as a/an, never the frame's default "the". const detEn = indef ? (/^[aeiou]/i.test(r.noun.en) ? 'an' : 'a') : r.enDet; 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, detEn)}${r.postEn}.`; const rule = PREPS[prepKey].c === 'wechsel' ? (tw ? `„${prepKey}" + motion (wohin?) → Akkusativ` : `„${prepKey}" + location (wo?) → Dativ`) : `„${prepKey}" → always ${CL[cas]}`; return {type:'article', noun:r.noun, cas, prepKey, verb:r.verbKey, 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 head = `${r.subjDe} ${r.verbDe}${r.midDe}`; const tail = `${nounForm(r.noun, cas)}${r.postDe}.`; // The article is BLANKED. With "unter dem Stuhl" on screen the answer is just // article recognition; blanked, the only evidence is the verb (and whether it // has a direct object) — which is the actual wohin/wo skill. const sentence = `${head} ${prepKey} _____ ${tail}`; // The solved sentence contracts where German really contracts (in dem → im). const fused = CONTRACTABLE[prepKey]?.[art] || null; const answerSentence = fused ? `${head} ${fused} ${tail}` : `${head} ${prepKey} ${art} ${tail}`; const translation = `${r.subjEn} ${r.verbEn}${r.midEn} ${r.enPrep} ${enNoun(r, r.enDet)}${r.postEn}.`; return {type:'wechselContext', prepKey, ans:cas, cas, isMotion, verb:r.verbKey, noun:r.noun, article:art, contraction:fused, sentence, answerSentence, 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} ${possNounEn(r.noun)}${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, verb:r.verbKey, tw, choices:possChoices(stem, ans)}; } } // No preposition: simple object sentence. const cas = pick(lv.cases.filter(c => c !== 'genitive')) || lv.cases[0]; let noun, sentence, translation, tplVerb; if (cas === 'accusative') { const t = pick(POSS_ACC_TEMPLATES); tplVerb = t.de; noun = NOUN_BY_NAME[pickAvoid(t.nouns.filter(n => POSSESSABLE.has(n)), state.last.noun)]; sentence = `Ich ${t.de} _____ ${noun.w}.`; translation = `I ${t.en} ${possMeaning} ${possNounEn(noun)}.`; } else { tplVerb = 'helfen'; noun = NOUN_BY_NAME[pickAvoid(PERSONS.filter(n => POSSESSABLE.has(n)), state.last.noun)]; sentence = `Ich helfe _____ ${noun.w}.`; translation = `I help ${possMeaning} ${possNounEn(noun)}.`; } 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, verb:tplVerb, tw:null, choices:possChoices(stem, ans)}; } 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 ans = pron[{accusative:'acc',dative:'dat'}[cas]]; 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 || ''; const sentence = `${cap(subj.nom)} ${sDe} _____${dePost}.`; const translation = `${cap(subj.en)} ${sEn} ${pron.enObj}${enPost}.`; return {type:'pronoun', pronKey, pron, cas, ans, sentence, translation, choices:pronounChoices(pron, ans)}; } // Every preposition that fuses, and every article that shows up inside a // contraction plus two that never do — so "which article is in there?" can't be // answered by elimination. export const CONTRACTION_PREPS = [...new Set(CONTRACTIONS.map(c => c.prep))]; const CONTRACTION_ARTS = ['das','dem','der','die','den']; // Asked the other way round: the fused form is GIVEN and you take it apart. // Asking "hinter + das = ?" was no test at all — only one option could start // with "hinter". Going backwards, the real question survives: -s is das, -m is // dem, -r is der, and am/ans, im/ins, zum/zur are the pairs that actually cost // you marks. 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, answerKey: `${c.prep} ${c.art}`, prepChoices: pickChoices(c.prep, CONTRACTION_PREPS, 6), artChoices: pickChoices(c.art, CONTRACTION_ARTS, 4)}; } // 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, verb:r.verbKey, 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); } // Build one adjective question. Order matters: the class picks the determiner, // the determiner constrains the nouns ("ein Wasser" is not a thing), the noun // picks the adjective. function buildAdjective(lv, state) { for (let attempt = 0; attempt < 16; attempt++) { const cls = drawBag(state, 'adjcls', lv.classes, state.last.adjcls); let frames = ADJ_FRAMES.filter(f => lv.cases.includes(f.cas) && (!f.classes || f.classes.includes(cls)) && (!lv.prepOnly || f.prep)); // Strong only occurs where German really drops the determiner: mass nouns. if (cls === 'strong') frames = frames.filter(f => framePool(f).some(n => MASS_LIKE.has(n))); if (!frames.length) continue; const frame = pick(frames); const detObj = cls === 'strong' ? null : pick(cls === 'weak' ? DER_WORD_DETS : EIN_WORD_DETS); let pool = framePool(frame); if (cls === 'strong') pool = pool.filter(n => MASS_LIKE.has(n)); // "ein Wasser" is not a thing, but "kein Geld" and "meine Zeit" are. if (detObj?.stem === 'ein') pool = pool.filter(n => !NOUN_BY_NAME[n].noIndef); if (!pool.length) continue; let candidates = ADJECTIVES.filter(a => adjNouns(a).some(n => pool.includes(n))); if (frame.noNeg) candidates = candidates.filter(a => !a.neg); if (!candidates.length) continue; const adj = pickAvoid(candidates, state.last.adj); const nounName = pickAvoid(adjNouns(adj).filter(n => pool.includes(n)), state.last.noun); state.last.adj = adj; state.last.noun = nounName; state.last.adjcls = cls; const noun = NOUN_BY_NAME[nounName]; const cas = frame.cas; const det = determinerFor(cls, detObj, noun.g, cas); const ending = ADJ_ENDINGS[cls][noun.g][cas]; const nounDe = nounForm(noun, cas); // Fuse the preposition with a definite article the way German really does. const fused = frame.prep && det ? CONTRACTABLE[frame.prep]?.[det] : null; const lead = !frame.prep ? (det ? det + ' ' : '') : fused ? fused + ' ' : `${frame.prep} ${det ? det + ' ' : ''}`; let blanked = `${lead}${adj.de}_____ ${nounDe}`; let solved = `${lead}${adj.de}${ending} ${nounDe}`; if (frame.deCap) { blanked = cap(blanked); solved = cap(solved); } let enDet = cls === 'strong' ? '' : (detObj.definite ? 'the' : detObj.en); const adjE = adjEn(adj, noun); if (enDet === 'a' && /^[aeiou]/i.test(adjE)) enDet = 'an'; const nounEn = detObj?.poss ? possNounEn(noun) : noun.en; let enPhrase = `${enDet}${enDet ? ' ' : ''}${adjE} ${nounEn}`; if (frame.enCap) enPhrase = cap(enPhrase); return { cls, cas, frame, adj, noun, det, detObj, ending, fused, sentence: `${frame.dePre}${blanked}${frame.dePost}`, answerSentence: `${frame.dePre}${solved}${frame.dePost}`, translation: `${frame.enPre}${enPhrase}${frame.enPost}`, }; } return null; } export function genAdjectiveQ(level, state = newState()) { const lv = LEVELS.find(l => l.id === level); const b = buildAdjective(lv, state); return { type:'adjective', cls:b.cls, cas:b.cas, frameId:b.frame.id, prepKey:b.frame.prep || null, adj:b.adj, noun:b.noun, det:b.det, ans:b.ending, sentence:b.sentence, answerSentence:b.answerSentence, translation:b.translation, choices: pickChoices(b.ending, ADJ_ALL_ENDINGS, 4), }; } // The meta-skill: name the table before you can pick the ending. export function genAdjClassQ(level, state = newState()) { const lv = LEVELS.find(l => l.id === level); const b = buildAdjective(lv, state); return { type:'adjClass', cls:b.cls, cas:b.cas, frameId:b.frame.id, prepKey:b.frame.prep || null, adj:b.adj, noun:b.noun, det:b.det, ending:b.ending, ans:b.cls, sentence:b.sentence, answerSentence:b.answerSentence, translation:b.translation, choices:['weak','mixed','strong'], }; } export function gen(level, state = newState()) { const lv = LEVELS.find(l=>l.id===level); if (lv.type==='adjective') return genAdjectiveQ(level, state); if (lv.type==='adjClass') return genAdjClassQ(level, state); 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); } // ═══════════════════════════════════════════ // CORPUS ENUMERATION // Every sentence the engine can ever build, so the whole space can be read // and approved instead of spot-checked. src/corpus.txt is the approved // snapshot; engine.test.js fails if generation drifts from it. // ═══════════════════════════════════════════ const CORPUS_SUBJ = 'Er'; function corpusLine(kind, prepKey, cas, tw, frame, verbKey, nounName, artFn) { const r = composeFrame(prepKey, cas, tw, frame, verbKey, nounName, CORPUS_SUBJ); const { art, enDet } = artFn(r, cas); const de = `${r.subjDe} ${r.verbDe}${r.midDe} ${prepKey} ${art} ${nounForm(r.noun, cas)}${r.postDe}.`; const en = `${r.subjEn} ${r.verbEn}${r.midEn} ${r.enPrep} ${enNoun(r, enDet, enDet === 'my')}${r.postEn}.`; return `${kind.padEnd(6)} ${de}\n ${en}`; } // Every case a preposition can govern in a sentence level. function casesFor(prepKey) { const c = PREPS[prepKey].c; return c === 'wechsel' ? [['accusative', true], ['dative', false]] : [[c, null]]; } function corpusRec(kind, prepKey, cas, tw, frame, verbKey, nounName, artFn) { const r = composeFrame(prepKey, cas, tw, frame, verbKey, nounName, CORPUS_SUBJ); const { art, enDet, poss } = artFn(r, cas); const de = `${r.subjDe} ${r.verbDe}${r.midDe} ${prepKey} ${art} ${nounForm(r.noun, cas)}${r.postDe}.`; const en = `${r.subjEn} ${r.verbEn}${r.midEn} ${r.enPrep} ${enNoun(r, enDet, !!poss)}${r.postEn}.`; return { key: `${kind}|${prepKey}|${cas}|${verbKey}|${nounName}`, de, en }; } export function enumerateCorpus() { const out = new Map(); const add = rec => { if (!out.has(rec.key)) out.set(rec.key, rec); }; const artPreps = [...new Set(LEVELS.filter(l => l.type === 'article' && l.preps).flatMap(l => l.preps))]; const wechPreps = PREPS_BY_CASE.wechsel; for (const prepKey of [...new Set([...artPreps, ...wechPreps])]) { for (const [cas, tw] of casesFor(prepKey)) { if (artPreps.includes(prepKey)) { for (const frame of usableFrames(prepKey, tw, { genderFilter: nonFusingGenders(prepKey, cas) })) { for (const v of frame.verbs) for (const n of frame.nouns) { add(corpusRec('ART', prepKey, cas, tw, frame, v, n, r => ({ art: DEF[r.noun.g][cas], enDet: r.enDet }))); } } for (const frame of usableFrames(prepKey, tw, { indefOnly: true })) { for (const v of frame.verbs) for (const n of frame.nouns) { add(corpusRec('INDEF', prepKey, cas, tw, frame, v, n, r => ({ art: INDEF[r.noun.g][cas], enDet: /^[aeiou]/i.test(r.noun.en) ? 'an' : 'a' }))); } } } if (wechPreps.includes(prepKey)) { for (const frame of usableFrames(prepKey, tw, {})) { for (const v of frame.verbs) for (const n of frame.nouns) { const r = composeFrame(prepKey, cas, tw, frame, v, n, CORPUS_SUBJ); const art = DEF[r.noun.g][cas]; const fused = CONTRACTABLE[prepKey]?.[art]; const head = `${r.subjDe} ${r.verbDe}${r.midDe}`; const rest = `${nounForm(r.noun, cas)}${r.postDe}.`; add({ key: `WECH|${prepKey}|${cas}|${v}|${n}`, de: fused ? `${head} ${fused} ${rest}` : `${head} ${prepKey} ${art} ${rest}`, en: `${r.subjEn} ${r.verbEn}${r.midEn} ${r.enPrep} ${enNoun(r, r.enDet)}${r.postEn}.` }); } } } for (const frame of usableFrames(prepKey, tw, { possOnly: true })) { for (const v of frame.verbs) for (const n of frame.nouns) { add(corpusRec('POSS', prepKey, cas, tw, frame, v, n, r => ({ art: combinePoss('mein', POSS_ENDINGS[r.noun.g][cas]), enDet: 'my', poss: true }))); } } if (CHUNK_PREPS.has(prepKey)) { for (const frame of usableFrames(prepKey, tw, {})) { for (const v of frame.verbs) for (const n of frame.nouns) { const art = DEF[NOUN_BY_NAME[n].g][cas]; const fused = CONTRACTABLE[prepKey]?.[art]; const r = composeFrame(prepKey, cas, tw, frame, v, n, CORPUS_SUBJ); add({ key: `CHUNK|${prepKey}|${cas}|${v}|${n}`, de: `${r.subjDe} ${r.verbDe}${r.midDe} ${fused || `${prepKey} ${art}`} ${nounForm(r.noun, cas)}${r.postDe}.`, en: `${r.subjEn} ${r.verbEn}${r.midEn} ${r.enPrep} ${enNoun(r, r.enDet)}${r.postEn}.` }); } } } } } // Adjective sentences. Keyed on the SEMANTIC pairing — frame x adjective x // noun — because that is what carries the risk ("kalter Kaffee" fine, // "kalte Mutter" not). The determiner, class and ending are mechanical and // covered exhaustively by the ending-table tests instead, which keeps this // from exploding into hundreds of thousands of lines. for (const frame of ADJ_FRAMES) { const pool = framePool(frame); for (const adj of ADJECTIVES) { if (frame.noNeg && adj.neg) continue; for (const n of adjNouns(adj)) { if (!pool.includes(n)) continue; const noun = NOUN_BY_NAME[n]; const cas = frame.cas; const det = DEF[noun.g][cas]; const ending = ADJ_ENDINGS.weak[noun.g][cas]; const fused = frame.prep ? CONTRACTABLE[frame.prep]?.[det] : null; const lead = !frame.prep ? det + ' ' : fused ? fused + ' ' : `${frame.prep} ${det} `; let solved = `${lead}${adj.de}${ending} ${nounForm(noun, cas)}`; if (frame.deCap) solved = cap(solved); let enPhrase = `the ${adjEn(adj, noun)} ${noun.en}`; if (frame.enCap) enPhrase = cap(enPhrase); add({ key: `ADJ|${frame.id}|${adj.de}|${n}`, de: `${frame.dePre}${solved}${frame.dePost}`, en: `${frame.enPre}${enPhrase}${frame.enPost}` }); } } } // Possessive sentences with no preposition at all. for (const t of POSS_ACC_TEMPLATES) { for (const n of t.nouns.filter(x => POSSESSABLE.has(x))) { const noun = NOUN_BY_NAME[n]; add({ key: `POSSTPL|-|accusative|${t.de}|${n}`, de: `Ich ${t.de} ${combinePoss('mein', POSS_ENDINGS[noun.g].accusative)} ${noun.w}.`, en: `I ${t.en} my ${possNounEn(noun)}.` }); } } for (const n of PERSONS.filter(x => POSSESSABLE.has(x))) { const noun = NOUN_BY_NAME[n]; add({ key: `POSSTPL|-|dative|helfen|${n}`, de: `Ich helfe ${combinePoss('mein', POSS_ENDINGS[noun.g].dative)} ${noun.w}.`, en: `I help my ${possNounEn(noun)}.` }); } return [...out.values()].sort((a, b) => a.key.localeCompare(b.key)); } // The corpus key for a question the app actually produced. One definition, // used by the coverage test — so the snapshot can't drift into fiction. export function corpusKeyFor(q) { if (!q?.noun) return null; const n = q.noun.w; if (q.type === 'wechselContext') return `WECH|${q.prepKey}|${q.cas}|${q.verb}|${n}`; if (q.type === 'chunk') return `CHUNK|${q.prepKey}|${q.cas}|${q.verb}|${n}`; if (q.type === 'article') return `${q.artType === 'indefinite' ? 'INDEF' : 'ART'}|${q.prepKey}|${q.cas}|${q.verb}|${n}`; if (q.type === 'adjective' || q.type === 'adjClass') return `ADJ|${q.frameId}|${q.adj.de}|${n}`; if (q.type === 'poss') return q.prepKey ? `POSS|${q.prepKey}|${q.cas}|${q.verb}|${n}` : `POSSTPL|-|${q.cas}|${q.verb}|${n}`; return null; } // The reviewed snapshot format: one greppable line per sentence. export const corpusToText = recs => recs.map(r => `${r.key.padEnd(46)} ${r.de.padEnd(58)} ${r.en}`).join('\n') + '\n'; // ═══════════════════════════════════════════ // 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); // Only the trailing window needed for the 7-day dots is kept. function pruneDays(days, today) { const keep = {}; for (const d of Object.keys(days)) { const diff = dayDiff(d, today); if (diff >= 0 && diff < 14) keep[d] = true; } return keep; } // Called on every answered question. Idempotent within a day. export function bumpStreak(st, today = localDateStr()) { if (st.ld === today) { if (st.days?.[today]) return st; return { ...st, days: pruneDays({ ...(st.days || {}), [today]: true }, today) }; } const streak = st.ld && dayDiff(st.ld, today) === 1 ? (st.streak || 0) + 1 : 1; const days = pruneDays({ ...(st.days || {}), [today]: true }, today); return { ...st, streak, ld: today, days }; } // The last 7 days (oldest first, today last), flagged with whether the // user practiced on each — drives the little dots next to the streak. export function weekDots(st, today = localDateStr()) { return Array.from({ length: 7 }, (_, i) => { const key = new Date(Date.parse(today) - (6 - i) * 864e5).toISOString().slice(0, 10); return { date: key, practiced: !!st?.days?.[key], isToday: i === 6 }; }); } // What to display: a streak is only alive if the last practice was // 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; }