Remove nominative testing, add hideable hints and week streak dots
- Remove the Nominativ level and drop nominative-case testing from mixed/ possessive/pronoun levels (nominative kept only in reference charts) - Add persistent hide/show toggles on the two giveaway hint lines in practice (noun gender, case/preposition info); state persists in localStorage - De-spoil "Wechsel im Kontext": remove motion/location hint from answer buttons; put sentence translation + preposition meaning behind a toggle - Add a 7-day practice-history dot strip beside the streak counter - Extend engine + tests (61 vitest tests) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+50
-73
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user