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>
195 lines
5.0 KiB
TypeScript
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();
|