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; cards: Record; xp: number; /** XP earned per local day, `YYYY-MM-DD`. */ days: Record; /** First daily-challenge score per day, as a percentage. */ daily: Record; /** Best score per challenge mode. */ bests: Record; /** Accuracy per endless drill. */ skills: Record; /** Level last chosen for mixed challenges; empty means "what I have learned". */ scope: string; }; export type Settings = { typing: Partial>; audio: boolean; romanization: boolean; /** Daily XP goal. */ goal: number; }; type Store = { version: 1; langs: Partial>; 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): 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(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 = {}; 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();