Files
personal-website/src/lib/language-learner/progress.svelte.ts
Stan Runge 438623d857
Some checks failed
Test and deploy staging / staging (push) Has been cancelled
add language learner project
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>
2026-09-24 17:34:56 +00:00

195 lines
5.0 KiB
TypeScript

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();