diff --git a/src/App.jsx b/src/App.jsx index 3b56861..086d786 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { DEF, INDEF, STRONG, WEAK, PREPS, PREPS_BY_CASE, CL, CASE_C, GENDER_C, PRONOUNS, POSSESSIVES, POSS_ENDINGS, combinePoss, LEVELS, - wechselExample, createSession, bumpStreak, currentStreak, + wechselExample, createSession, bumpStreak, currentStreak, weekDots, } from './engine.js'; // ═══════════════════════════════════════════ @@ -60,7 +60,7 @@ const TABS = [ {id:'pronoun', label:'Pronouns'}, ]; -const FREE_LEVEL_IDS = new Set([1, 2, 3, 4, 8, 9, 10, 18]); +const FREE_LEVEL_IDS = new Set([2, 3, 4, 8, 9, 10, 18]); const CORE_ARTICLE_CASES = ['nominative', 'accusative', 'dative']; const PAYMENTS_ENABLED = import.meta.env.VITE_PAYMENTS_ENABLED === 'true'; const STRIPE_PLANS = PAYMENTS_ENABLED ? [ @@ -187,6 +187,8 @@ export default function App() { useEffect(()=>{(async()=>{ const data = await Storage.get(); + // Seed the practice-day history for users from before it was tracked. + if (data?.ld && !data.days) data.days = { [data.ld]: true }; if (data) setSt(data); let nextAccess = Access.get(); const params = new URLSearchParams(window.location.search); @@ -204,6 +206,9 @@ export default function App() { await Storage.set(s); },[]); + const hintHidden = k => !!st.hide?.[k]; + const toggleHint = k => save({...st, hide:{...(st.hide||{}), [k]: !st.hide?.[k]}}); + const hasPaidAccess = !!access?.paid; const hasAccess = !PAYMENTS_ENABLED || hasPaidAccess; const gated = id => PAYMENTS_ENABLED && !hasPaidAccess && !FREE_LEVEL_IDS.has(id); @@ -311,7 +316,7 @@ export default function App() {
{scr === 'practice' && } {PAYMENTS_ENABLED && hasPaidAccess && Unlocked} - 🔥 {currentStreak(st)} +
@@ -471,29 +476,32 @@ export default function App() {
{q.translation}
-
- {(q.type === 'article' || q.type === 'chunk') && <> + {(q.type === 'article' || q.type === 'chunk') ? ( +
+ + ) : ( +
+ {q.type === 'poss' && <> + {q.stem}- + ({q.possMeaning}) + } + {q.type === 'pronoun' && <> + {q.pron.nom} + ({q.pron.en}) + · {q.pron.tag} + } +
+ )} -
+
+ {!rev ? ( (q.type === 'article' || q.type === 'chunk') ? ( @@ -558,21 +566,36 @@ export default function App() { {scr === 'practice' && q?.type === 'wechselContext' && (
{q.sentence}
-
{q.translation}
- -
- {q.prepKey} - ({PREPS[q.prepKey].en}) -
+ {(!hintHidden('meaning') || rev) ? ( + <> +
+ {q.translation} + {!rev && } +
+
+ {q.prepKey} + ({PREPS[q.prepKey].en}) +
+ + ) : ( + <> +
+ +
+
+ {q.prepKey} +
+ + )}
Wechselpräposition · motion or location?
{!rev ? (
- {[['accusative','Akkusativ','1','motion'],['dative','Dativ','2','location']].map(([val,lab,key,hint]) => ( + {[['accusative','Akkusativ','1'],['dative','Dativ','2']].map(([val,lab,key]) => ( ))}
@@ -711,6 +734,45 @@ function FeatureRequestFooter() { ); } +// A hint line with a persistent hide/show toggle. Hidden stays hidden +// (across sessions) until the user shows it again — for authentic testing. +function Hideable({ hidden, onToggle, label, style, children }) { + if (hidden) { + return ( +
+ +
+ ); + } + return ( +
+ {children} + +
+ ); +} + +function StreakBadge({ st }) { + const dots = weekDots(st); + const n = currentStreak(st); + const practiced = dots.filter(d=>d.practiced).length; + return ( + + + {dots.map(d=>( + + ))} + + 🔥 {n} + + ); +} + function CoreReference() { const prepositionGroups = [ ['accusative', 'Akkusativ', PREPS_BY_CASE.accusative], @@ -1196,6 +1258,7 @@ const S = { checkBtn: { padding:'12px 20px', borderRadius:8, background:GREEN, color:'#fff', border:'none', fontSize:14, fontWeight:700, cursor:'pointer', fontFamily:'inherit' }, hintText: { fontSize:11, color:'#bbb' }, kbd: { background:'#f0f0f0', padding:'1px 5px', borderRadius:3, fontSize:10, color:'#888', border:'1px solid #e0e0e0' }, + hintToggle: { background:'none', border:'none', padding:0, color:'#c2c2c2', fontSize:10, fontWeight:600, textTransform:'uppercase', letterSpacing:0.5, cursor:'pointer', fontFamily:'inherit', textDecoration:'underline', textDecorationStyle:'dotted', textUnderlineOffset:3 }, kbdSmall: { background:'#f0f0f0', padding:'1px 4px', borderRadius:3, fontSize:9, color:'#888', border:'1px solid #e0e0e0' }, articleGrid: { display:'grid', gridTemplateColumns:'1fr 1fr', gap:10, maxWidth:320, margin:'12px auto 0' }, diff --git a/src/engine.js b/src/engine.js index 4ceca88..b5536ef 100644 --- a/src/engine.js +++ b/src/engine.js @@ -230,14 +230,13 @@ export const ALL_PRONOUN_KEYS = PRONOUNS.map(p=>p.key); const SAFE_SUBJECT_KEYS = ['ich','du','er','wir','ihr']; export const LEVELS = [ - {id:1, tab:'article', name:'Nominativ', desc:'Gender basics: der, die, or das?', cases:['nominative'], preps:[], type:'article', artType:'definite'}, {id:2, tab:'article', name:'Akkusativ', desc:'durch, für, gegen, ohne, um', cases:['accusative'], preps:SENTENCE_ACC_PREPS, type:'article', artType:'definite'}, {id:3, tab:'article', name:'Dativ', desc:'mit, von, zu, aus, bei, nach, seit...', cases:['dative'], preps:PREPS_BY_CASE.dative, type:'article', artType:'definite'}, {id:4, tab:'article', name:'Wechselpräpositionen', desc:'Two-way: motion (Akk) vs location (Dat)', cases:['accusative','dative'], preps:PREPS_BY_CASE.wechsel, type:'article', artType:'definite', twoWay:true}, {id:4.5, tab:'article', name:'Kontraktionen', desc:'Fused forms in context: ins, am, zum…', cases:['accusative','dative'], type:'chunk', artType:'definite'}, {id:5, tab:'article', name:'Genitiv', desc:'trotz, während, wegen, statt...', cases:['genitive'], preps:PREPS_BY_CASE.genitive, type:'article', artType:'definite'}, - {id:6, tab:'article', name:'Alles gemischt', desc:'All prepositions, definite articles', cases:['nominative','accusative','dative','genitive'], preps:[...SENTENCE_ACC_PREPS,...PREPS_BY_CASE.dative,...PREPS_BY_CASE.genitive,...PREPS_BY_CASE.wechsel], type:'article', artType:'definite', mixed:true}, - {id:7, tab:'article', name:'ein-Wörter', desc:'Everything with ein/eine/einem...', cases:['nominative','accusative','dative','genitive'], preps:[...SENTENCE_ACC_PREPS,...PREPS_BY_CASE.dative,...PREPS_BY_CASE.genitive,...PREPS_BY_CASE.wechsel], type:'article', artType:'indefinite', mixed:true}, + {id: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'}, @@ -245,14 +244,14 @@ export const LEVELS = [ {id:18, tab:'prep', name:'Wechsel im Kontext', desc:'Real sentences: Akkusativ or Dativ?', preps:PREPS_BY_CASE.wechsel, type:'wechselContext'}, {id:19, tab:'prep', name:'Kontraktionen', desc:'Contractions: am, ins, zum, beim…', type:'contraction'}, - {id:11, tab:'poss', name:'Häufige Possessive', desc:'mein · dein · sein — all cases', possessives:['mein','dein','sein'], cases:['nominative','accusative','dative'], type:'poss'}, - {id:12, tab:'poss', name:'Schwierige Possessive', desc:'ihr · unser · euer — all cases', possessives:['ihr','unser','euer'], cases:['nominative','accusative','dative'], type:'poss'}, - {id:13, tab:'poss', name:'Alle gemischt', desc:'mein, dein, sein, ihr, unser, euer — incl. Genitiv', possessives:['mein','dein','sein','ihr','unser','euer'], cases:['nominative','accusative','dative','genitive'], type:'poss'}, + {id: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:'ich, du, er, sie, es — all cases', pronouns:SG_PRONOUN_KEYS, cases:['nominative','accusative','dative'], type:'pronoun'}, - {id:15, tab:'pronoun', name:'Plural Pronomen', desc:'wir, ihr, sie — all cases', pronouns:PL_PRONOUN_KEYS, cases:['nominative','accusative','dative'], type:'pronoun'}, - {id:16, tab:'pronoun', name:'Sie (formal)', desc:'Formal Sie — singular and plural', pronouns:FORMAL_PRONOUN_KEYS, cases:['nominative','accusative','dative'], type:'pronoun'}, - {id:17, tab:'pronoun', name:'Alle gemischt', desc:'All 10 pronouns, all cases', pronouns:ALL_PRONOUN_KEYS, cases:['nominative','accusative','dative'], type:'pronoun'}, + {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. @@ -513,13 +512,6 @@ const enNoun = (r, det) => `${det}${det ? ' ' : ''}${r.noun.en}`; // QUESTION GENERATORS // ═══════════════════════════════════════════ -const NOM_TEMPLATES = [ - {de:'ist hier', en:'is here'}, - {de:'ist da', en:'is there'}, - {de:'ist groß', en:'is big'}, - {de:'ist klein', en:'is small'}, -]; - function articleChoices(ans, artType) { const pool = artType === 'indefinite' ? ['ein','eine','einen','einem','eines','einer','keine','keinen','keiner'] @@ -530,26 +522,8 @@ function articleChoices(ans, artType) { return [ans, ...picked].sort(() => Math.random() - 0.5); } -function nominativeArticleQ(lv, state) { - const noun = NOUN_BY_NAME[drawBag(state, 'noun', NOUNS.map(n=>n.w), state.last.noun)]; - state.last.noun = noun.w; - const tbl = lv.artType === 'indefinite' ? INDEF : DEF; - const ans = tbl[noun.g].nominative; - const t = pick(NOM_TEMPLATES); - return { - type:'article', noun, cas:'nominative', prepKey:null, ans, - sentence:`_____ ${noun.w} ${t.de}.`, - translation:`The ${noun.en} ${t.en}.`, - rule:'Subject of the sentence → Nominativ', - tw:null, artType:lv.artType, choices:articleChoices(ans, lv.artType), - }; -} - export function genArticleQ(level, state = newState()) { const lv = LEVELS.find(l=>l.id===level); - if (lv.id === 1) return nominativeArticleQ(lv, state); - if (lv.mixed && Math.random() < 0.12) return nominativeArticleQ(lv, state); - const prepKey = drawBag(state, 'prep', lv.preps, state.last.prep); state.last.prep = prepKey; const pc = PREPS[prepKey].c; @@ -638,15 +612,10 @@ export function genPossQ(level, state = newState()) { } } - // No preposition: simple subject/object sentence. + // No preposition: simple object sentence. const cas = pick(lv.cases.filter(c => c !== 'genitive')) || lv.cases[0]; let noun, sentence, translation; - if (cas === 'nominative') { - noun = NOUN_BY_NAME[pickAvoid([...POSSESSABLE], state.last.noun)]; - const t = pick(NOM_TEMPLATES); - sentence = `_____ ${noun.w} ${t.de}.`; - translation = `${cap(possMeaning)} ${noun.en} ${t.en}.`; - } else if (cas === 'accusative') { + 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}.`; @@ -668,38 +637,23 @@ export function genPronounQ(level, state = newState()) { state.last.pron = pronKey; const pron = PRONOUNS.find(p=>p.key===pronKey); const cas = pick(lv.cases); - const rawAns = pron[{nominative:'nom',accusative:'acc',dative:'dat'}[cas]]; + const ans = pron[{accusative:'acc',dative:'dat'}[cas]]; - let sentence, translation, ans; - if (cas === 'nominative') { - const tpl = pick([ - {v:'kommen', dePart:' aus Berlin', enPart:' from Berlin'}, - {v:'sein', dePart:' müde', enPart:' tired'}, - {v:'haben', dePart:' einen Hund', enPart:' a dog'}, - ]); - const vDe = VC[tpl.v].de[pronKey]; - const vEn = VC[tpl.v].en[pronKey]; - sentence = `_____ ${vDe}${tpl.dePart}.`; - translation = `${cap(pron.en)} ${vEn}${tpl.enPart}.`; - ans = cap(rawAns); + 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 { - const subjCandidates = SAFE_SUBJECT_KEYS.filter(k => k !== pronKey); - const subjKey = pick(subjCandidates); - const subj = PRONOUNS.find(p=>p.key===subjKey); - let tpl; - if (cas === 'accusative') { - tpl = pick([{v:'sehen'}, {v:'kennen'}, {v:'lieben'}]); - } else { - tpl = pick([{v:'helfen'}, {v:'geben', dePost:' das Buch', enPost:' the book'}]); - } - const sDe = VC[tpl.v].de[subjKey]; - const sEn = VC[tpl.v].en[subjKey]; - const dePost = tpl.dePost || ''; - const enPost = tpl.enPost || ''; - sentence = `${cap(subj.nom)} ${sDe} _____${dePost}.`; - translation = `${cap(subj.en)} ${sEn} ${pron.enObj}${enPost}.`; - ans = rawAns; + 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}; } @@ -777,11 +731,34 @@ export function localDateStr(d = new Date()) { } 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) return st; + 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; - return { ...st, streak, ld: today }; + 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 diff --git a/src/engine.test.js b/src/engine.test.js index 9404e5f..7001573 100644 --- a/src/engine.test.js +++ b/src/engine.test.js @@ -4,7 +4,7 @@ import { NOUNS, nounForm, LEVELS, CONTRACTIONS, CONTRACTABLE, CHUNK_PREPS, POSS_ENDINGS, combinePoss, PRONOUNS, VERBS, PREP_FRAMES, createSession, gen, genContractionContextQ, newState, buildChunkChoices, - bumpStreak, currentStreak, + bumpStreak, currentStreak, weekDots, } from './engine.js'; const runSession = (levelId, n) => { @@ -238,9 +238,22 @@ describe('pronoun questions', () => { it.each([14, 15, 16, 17])('level %s: answer matches the pronoun table', (id) => { for (const q of runSession(id, 200)) { const p = PRONOUNS.find(x => x.key === q.pronKey); - const expected = p[{nominative:'nom',accusative:'acc',dative:'dat'}[q.cas]]; - const want = q.cas === 'nominative' ? expected.charAt(0).toUpperCase() + expected.slice(1) : expected; - expect(q.ans).toBe(want); + expect(q.ans).toBe(p[{accusative:'acc',dative:'dat'}[q.cas]]); + } + }); +}); + +// ── Nominative is never tested ────────────────────────────── + +describe('no nominative anywhere', () => { + it('no level tests the nominative — you already know der/die/das', () => { + expect(LEVELS.some(l => l.name === 'Nominativ')).toBe(false); + for (const lv of LEVELS) { + if (lv.cases) expect(lv.cases, `level ${lv.id} still lists nominative`).not.toContain('nominative'); + for (const q of runSession(lv.id, 150)) { + if (q.cas) expect(q.cas, `level ${lv.id} generated a nominative question`).not.toBe('nominative'); + expect(q.ans).not.toBe('nominative'); + } } }); }); @@ -289,6 +302,29 @@ describe('streaks', () => { expect(st.streak).toBe(10); }); + it('records practice days and prunes old ones', () => { + let st = bumpStreak({ streak: 0, ld: null }, '2026-07-01'); + expect(st.days['2026-07-01']).toBe(true); + st = bumpStreak(st, '2026-07-01'); // same day, already recorded + expect(Object.keys(st.days).length).toBe(1); + st = bumpStreak(st, '2026-07-02'); + expect(st.days['2026-07-01']).toBe(true); + expect(st.days['2026-07-02']).toBe(true); + st = bumpStreak(st, '2026-07-20'); // far in the future → old days pruned + expect(st.days['2026-07-01']).toBeUndefined(); + expect(st.days['2026-07-20']).toBe(true); + }); + + it('weekDots reports the last 7 days, today last', () => { + const st = { days: { '2026-07-10': true, '2026-07-12': true, '2026-07-14': true } }; + const dots = weekDots(st, '2026-07-14'); + expect(dots.length).toBe(7); + expect(dots[0].date).toBe('2026-07-08'); + expect(dots[6].date).toBe('2026-07-14'); + expect(dots[6].isToday).toBe(true); + expect(dots.map(d => d.practiced)).toEqual([false, false, true, false, true, false, true]); + }); + it('displays 0 once the streak has lapsed, without needing a new answer', () => { const st = { streak: 7, ld: '2026-07-01' }; expect(currentStreak(st, '2026-07-01')).toBe(7);