// ═══════════════════════════════════════════ // 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: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:['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,...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 — 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: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'}, ]; // 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 // ═══════════════════════════════════════════ 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); } export function genArticleQ(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 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 object sentence. const cas = pick(lv.cases.filter(c => c !== 'genitive')) || lv.cases[0]; let noun, sentence, translation; 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 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}; } 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); // 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; }