add language learner project
Some checks failed
Test and deploy staging / staging (push) Has been cancelled
Some checks failed
Test and deploy staging / staging (push) Has been cancelled
Dutch and Korean (explained in English) and English (explained in Dutch) from A1 to C1, with a Hangul level for Korean. Lessons with notes, words and sentences generate multiple-choice, typing, sentence building, cloze, listening and matching exercises. Endless challenges: a seeded daily challenge, sprint, survival, mixed practice, level tests, spaced-repetition reviews and generated drills (numbers, clock times, de/het, plurals, Dutch and Korean conjugation, particles, Hangul reading, irregular verbs). Progress is stored in localStorage with export/import. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
194
src/lib/language-learner/progress.svelte.ts
Normal file
194
src/lib/language-learner/progress.svelte.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { dayKey, dayNumber, newCard, review, type Card } from './srs';
|
||||
import type { Entry, LangCode } from './types';
|
||||
|
||||
export type LessonRecord = { best: number; runs: number; last: string };
|
||||
|
||||
export type LangProgress = {
|
||||
lessons: Record<string, LessonRecord>;
|
||||
cards: Record<string, Card>;
|
||||
xp: number;
|
||||
/** XP earned per local day, `YYYY-MM-DD`. */
|
||||
days: Record<string, number>;
|
||||
/** First daily-challenge score per day, as a percentage. */
|
||||
daily: Record<string, number>;
|
||||
/** Best score per challenge mode. */
|
||||
bests: Record<string, number>;
|
||||
/** Accuracy per endless drill. */
|
||||
skills: Record<string, { seen: number; right: number }>;
|
||||
/** Level last chosen for mixed challenges; empty means "what I have learned". */
|
||||
scope: string;
|
||||
};
|
||||
|
||||
export type Settings = {
|
||||
typing: Partial<Record<LangCode, boolean>>;
|
||||
audio: boolean;
|
||||
romanization: boolean;
|
||||
/** Daily XP goal. */
|
||||
goal: number;
|
||||
};
|
||||
|
||||
type Store = { version: 1; langs: Partial<Record<LangCode, LangProgress>>; settings: Settings };
|
||||
|
||||
const KEY = 'language-learner-v1';
|
||||
|
||||
const emptyLang = (): LangProgress => ({
|
||||
lessons: {},
|
||||
cards: {},
|
||||
xp: 0,
|
||||
days: {},
|
||||
daily: {},
|
||||
bests: {},
|
||||
skills: {},
|
||||
scope: ''
|
||||
});
|
||||
|
||||
const empty = (): Store => ({
|
||||
version: 1,
|
||||
langs: {},
|
||||
settings: { typing: {}, audio: true, romanization: true, goal: 50 }
|
||||
});
|
||||
|
||||
function streakOf(days: Record<string, number>): number {
|
||||
const today = dayNumber();
|
||||
const active = new Set(
|
||||
Object.entries(days)
|
||||
.filter(([, xp]) => xp > 0)
|
||||
.map(([key]) => dayNumber(key))
|
||||
);
|
||||
// An unfinished today does not break a streak that ran until yesterday.
|
||||
let day = active.has(today) ? today : today - 1;
|
||||
let streak = 0;
|
||||
while (active.has(day)) {
|
||||
streak++;
|
||||
day--;
|
||||
}
|
||||
return streak;
|
||||
}
|
||||
|
||||
class Progress {
|
||||
data = $state<Store>(empty());
|
||||
loaded = $state(false);
|
||||
|
||||
load() {
|
||||
if (!browser || this.loaded) return;
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY);
|
||||
if (raw) this.data = { ...empty(), ...JSON.parse(raw) };
|
||||
} catch {
|
||||
// Unreadable progress starts fresh rather than breaking the page.
|
||||
}
|
||||
this.loaded = true;
|
||||
}
|
||||
|
||||
save() {
|
||||
if (!browser) return;
|
||||
try {
|
||||
localStorage.setItem(KEY, JSON.stringify(this.data));
|
||||
} catch {
|
||||
// Storage can be full or disabled; progress then lasts for this visit only.
|
||||
}
|
||||
}
|
||||
|
||||
lang(code: LangCode): LangProgress {
|
||||
return (this.data.langs[code] ??= emptyLang());
|
||||
}
|
||||
|
||||
get settings() {
|
||||
return this.data.settings;
|
||||
}
|
||||
|
||||
typing(code: LangCode) {
|
||||
// Typing Hangul needs a Korean keyboard, so it starts off for Korean.
|
||||
return this.data.settings.typing[code] ?? code !== 'ko';
|
||||
}
|
||||
|
||||
answer(code: LangCode, entries: Entry[], correct: boolean) {
|
||||
const p = this.lang(code);
|
||||
const today = dayNumber();
|
||||
for (const entry of entries)
|
||||
p.cards[entry.id] = review(p.cards[entry.id] ?? newCard(today), correct, today);
|
||||
}
|
||||
|
||||
skill(code: LangCode, skill: string, correct: boolean) {
|
||||
const s = (this.lang(code).skills[skill] ??= { seen: 0, right: 0 });
|
||||
s.seen++;
|
||||
if (correct) s.right++;
|
||||
}
|
||||
|
||||
addXp(code: LangCode, xp: number) {
|
||||
const p = this.lang(code);
|
||||
const key = dayKey();
|
||||
p.xp += xp;
|
||||
p.days[key] = (p.days[key] ?? 0) + xp;
|
||||
this.save();
|
||||
}
|
||||
|
||||
completeLesson(code: LangCode, lessonId: string, score: number, entries: Entry[]) {
|
||||
const p = this.lang(code);
|
||||
const record = p.lessons[lessonId];
|
||||
p.lessons[lessonId] = {
|
||||
best: Math.max(record?.best ?? 0, score),
|
||||
runs: (record?.runs ?? 0) + 1,
|
||||
last: dayKey()
|
||||
};
|
||||
const today = dayNumber();
|
||||
for (const entry of entries) p.cards[entry.id] ??= newCard(today + 1);
|
||||
this.save();
|
||||
}
|
||||
|
||||
recordBest(code: LangCode, mode: string, score: number): boolean {
|
||||
const p = this.lang(code);
|
||||
const record = score > (p.bests[mode] ?? 0);
|
||||
if (record) p.bests[mode] = score;
|
||||
this.save();
|
||||
return record;
|
||||
}
|
||||
|
||||
recordDaily(code: LangCode, score: number) {
|
||||
const p = this.lang(code);
|
||||
p.daily[dayKey()] ??= score;
|
||||
this.save();
|
||||
}
|
||||
|
||||
todayXp(code?: LangCode): number {
|
||||
const key = dayKey();
|
||||
const codes = code ? [code] : (Object.keys(this.data.langs) as LangCode[]);
|
||||
return codes.reduce((sum, c) => sum + (this.data.langs[c]?.days[key] ?? 0), 0);
|
||||
}
|
||||
|
||||
streak(code?: LangCode): number {
|
||||
if (code) return streakOf(this.data.langs[code]?.days ?? {});
|
||||
const merged: Record<string, number> = {};
|
||||
for (const p of Object.values(this.data.langs))
|
||||
for (const [key, xp] of Object.entries(p.days)) merged[key] = (merged[key] ?? 0) + xp;
|
||||
return streakOf(merged);
|
||||
}
|
||||
|
||||
due(code: LangCode): string[] {
|
||||
const today = dayNumber();
|
||||
return Object.entries(this.data.langs[code]?.cards ?? {})
|
||||
.filter(([, card]) => card.due <= today)
|
||||
.sort(([, a], [, b]) => a.due - b.due)
|
||||
.map(([id]) => id);
|
||||
}
|
||||
|
||||
export(): string {
|
||||
return JSON.stringify(this.data);
|
||||
}
|
||||
|
||||
import(json: string) {
|
||||
const parsed = JSON.parse(json) as Store;
|
||||
if (parsed?.version !== 1 || typeof parsed.langs !== 'object')
|
||||
throw new Error('Not a language learner backup');
|
||||
this.data = { ...empty(), ...parsed };
|
||||
this.save();
|
||||
}
|
||||
|
||||
reset(code: LangCode) {
|
||||
delete this.data.langs[code];
|
||||
this.save();
|
||||
}
|
||||
}
|
||||
|
||||
export const progress = new Progress();
|
||||
Reference in New Issue
Block a user