/**
* Zentrales User-Profil — localStorage-basiert.
*
* Konsolidiert alle lernbezogenen Daten (Fortschritt, Assessment-Ergebnisse,
* Interessen) in einem einzigen localStorage-Key. Bestehende Legacy-Keys
* (lp_progress, ai_unboxed_universe_progress) werden beim ersten Laden
* eingelesen und in das neue Schema migriert.
*
* @fileoverview
* @author Alexander Wolf
* @version 1.0
* @see docs/conventions/DATA_MODEL_CONVENTIONS.md §2
*/
const STORAGE_KEY = 'aiu_user_profile';
const CURRENT_VERSION = 1;
/** Legacy-Keys, die beim ersten Laden eingelesen werden. */
const LEGACY_KEYS = {
LP_PROGRESS: 'lp_progress',
UNIVERSE_PROGRESS: 'ai_unboxed_universe_progress',
INTRO_SEEN: 'aiu_intro_seen'
};
/**
* Default-Profil-Daten. Wird als Basis für neue Profile verwendet.
* @returns {Object} Frisches Profil-Objekt.
*/
function createDefaults() {
const now = new Date().toISOString();
return {
version: CURRENT_VERSION,
onboardingCompleted: null,
level: 'unknown',
interests: [],
assessmentScores: {},
completedResources: [],
badges: [],
lastVisited: null,
recommendationsDismissed: [],
createdAt: now,
updatedAt: now
};
}
/**
* Zentrales User-Profil für die ai-unboxed Plattform.
*
* Speichert ausschließlich lernbezogene Daten (keine PII).
* Verwendet localStorage als Persistenzschicht.
*
* @example
* const profile = UserProfile.load();
* profile.completeResource('game_rotatebox');
* profile.setAssessmentScore('math', 0.65);
* profile.save();
*/
class UserProfile {
/**
* @param {Object} data - Profildaten (siehe Schema in DATA_MODEL_CONVENTIONS.md §2.2)
*/
constructor(data) {
/** @type {number} Schema-Version */
this.version = data.version;
/** @type {string|null} Art des abgeschlossenen Onboardings */
this.onboardingCompleted = data.onboardingCompleted;
/** @type {string} Einstufung: 'unknown'|'beginner'|'intermediate'|'advanced' */
this.level = data.level;
/** @type {string[]} Topic-Tags (identisch mit Content-Registry Topics) */
this.interests = data.interests;
/** @type {Object<string, number>} Kategorie → Score (0.0–1.0) */
this.assessmentScores = data.assessmentScores;
/** @type {string[]} Content-Registry IDs abgeschlossener Ressourcen */
this.completedResources = data.completedResources;
/** @type {string[]} Badge-IDs */
this.badges = data.badges;
/** @type {string|null} Letzte besuchte Ressource-ID */
this.lastVisited = data.lastVisited;
/** @type {string[]} Weggeklickte Empfehlungs-IDs */
this.recommendationsDismissed = data.recommendationsDismissed;
/** @type {string} ISO 8601 Timestamp */
this.createdAt = data.createdAt;
/** @type {string} ISO 8601 Timestamp */
this.updatedAt = data.updatedAt;
}
// ── Factory Methods ─────────────────────────────────────────
/**
* Lädt das Profil aus localStorage oder erstellt ein neues.
* Bei erstem Laden werden Legacy-Keys eingelesen und konsolidiert.
*
* @returns {UserProfile} Das geladene oder neu erstellte Profil.
*/
static load() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
const data = JSON.parse(raw);
if (data.version !== CURRENT_VERSION) {
return UserProfile._migrate(data);
}
return new UserProfile(data);
}
} catch (err) {
console.warn('[UserProfile] Fehler beim Laden, erstelle neues Profil:', err);
}
// Kein gespeichertes Profil → neues erstellen und Legacy migrieren
const profile = new UserProfile(createDefaults());
UserProfile._importLegacy(profile);
profile.save();
return profile;
}
/**
* Löscht alle Profildaten und alle Legacy-Keys aus localStorage.
*/
static reset() {
try {
localStorage.removeItem(STORAGE_KEY);
Object.values(LEGACY_KEYS).forEach(key => localStorage.removeItem(key));
} catch (err) {
console.warn('[UserProfile] Fehler beim Zurücksetzen:', err);
}
}
// ── Read API ────────────────────────────────────────────────
/**
* Prüft, ob eine Ressource abgeschlossen wurde.
* @param {string} resourceId - Content-Registry ID.
* @returns {boolean}
*/
hasCompleted(resourceId) {
return this.completedResources.includes(resourceId);
}
/**
* Prüft, ob das Onboarding abgeschlossen wurde.
* @returns {boolean}
*/
hasCompletedOnboarding() {
return this.onboardingCompleted !== null;
}
/**
* Gibt den Assessment-Score für eine Kategorie zurück.
* @param {string} category - z.B. 'math', 'history'
* @returns {number|null} Score (0.0–1.0) oder null falls nicht vorhanden.
*/
getAssessmentScore(category) {
return this.assessmentScores[category] ?? null;
}
/**
* Berechnet den Durchschnittsscore über alle Assessment-Kategorien.
* @returns {number} Durchschnitt (0.0–1.0), oder 0 wenn keine Scores vorhanden.
*/
getAverageScore() {
const scores = Object.values(this.assessmentScores);
if (scores.length === 0) return 0;
return scores.reduce((sum, s) => sum + s, 0) / scores.length;
}
/**
* Gibt die Anzahl abgeschlossener Ressourcen zurück.
* @returns {number}
*/
getCompletedCount() {
return this.completedResources.length;
}
// ── Write API ───────────────────────────────────────────────
/**
* Markiert eine Ressource als abgeschlossen.
* @param {string} resourceId - Content-Registry ID.
* @returns {UserProfile} this (für Chaining)
*/
completeResource(resourceId) {
if (!this.completedResources.includes(resourceId)) {
this.completedResources.push(resourceId);
this._touch();
}
return this;
}
/**
* Setzt das Level des Nutzers.
* @param {'unknown'|'beginner'|'intermediate'|'advanced'} level
* @returns {UserProfile} this
*/
setLevel(level) {
const VALID = ['unknown', 'beginner', 'intermediate', 'advanced'];
if (!VALID.includes(level)) {
console.warn(`[UserProfile] Ungültiges Level: "${level}". Erlaubt: ${VALID.join(', ')}`);
return this;
}
this.level = level;
this._touch();
return this;
}
/**
* Setzt den Assessment-Score für eine Kategorie.
* @param {string} category - z.B. 'math', 'history'
* @param {number} score - Wert zwischen 0.0 und 1.0
* @returns {UserProfile} this
*/
setAssessmentScore(category, score) {
this.assessmentScores[category] = Math.max(0, Math.min(1, score));
this._touch();
return this;
}
/**
* Markiert das Onboarding als abgeschlossen.
* @param {'test'|'skip'|'quick'} method - Art des Onboardings
* @returns {UserProfile} this
*/
completeOnboarding(method) {
this.onboardingCompleted = method;
this._touch();
return this;
}
/**
* Fügt eine Interesse/Topic hinzu.
* @param {string} topic - Topic-Tag
* @returns {UserProfile} this
*/
addInterest(topic) {
if (!this.interests.includes(topic)) {
this.interests.push(topic);
this._touch();
}
return this;
}
/**
* Entfernt ein Interesse.
* @param {string} topic - Topic-Tag
* @returns {UserProfile} this
*/
removeInterest(topic) {
const idx = this.interests.indexOf(topic);
if (idx !== -1) {
this.interests.splice(idx, 1);
this._touch();
}
return this;
}
/**
* Setzt die zuletzt besuchte Ressource.
* @param {string} resourceId - Content-Registry ID
* @returns {UserProfile} this
*/
setLastVisited(resourceId) {
this.lastVisited = resourceId;
this._touch();
return this;
}
/**
* Vergibt ein Badge.
* @param {string} badgeId - Badge-ID
* @returns {UserProfile} this
*/
addBadge(badgeId) {
if (!this.badges.includes(badgeId)) {
this.badges.push(badgeId);
this._touch();
}
return this;
}
/**
* Markiert eine Empfehlung als weggeklickt.
* @param {string} resourceId - Content-Registry ID
* @returns {UserProfile} this
*/
dismissRecommendation(resourceId) {
if (!this.recommendationsDismissed.includes(resourceId)) {
this.recommendationsDismissed.push(resourceId);
this._touch();
}
return this;
}
// ── Persistence ─────────────────────────────────────────────
/**
* Speichert das Profil in localStorage.
* @returns {UserProfile} this
*/
save() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.toJSON()));
} catch (err) {
console.warn('[UserProfile] Fehler beim Speichern:', err);
}
return this;
}
/**
* Serialisiert das Profil als JSON-kompatibles Objekt.
* @returns {Object}
*/
toJSON() {
return {
version: this.version,
onboardingCompleted: this.onboardingCompleted,
level: this.level,
interests: [...this.interests],
assessmentScores: { ...this.assessmentScores },
completedResources: [...this.completedResources],
badges: [...this.badges],
lastVisited: this.lastVisited,
recommendationsDismissed: [...this.recommendationsDismissed],
createdAt: this.createdAt,
updatedAt: this.updatedAt
};
}
// ── Private ─────────────────────────────────────────────────
/**
* Aktualisiert den updatedAt-Timestamp.
* @private
*/
_touch() {
this.updatedAt = new Date().toISOString();
}
/**
* Migriert ein Profil von einer älteren Version.
* @param {Object} data - Gespeicherte Profildaten mit veralteter Version
* @returns {UserProfile}
* @private
*/
static _migrate(data) {
// Aktuell gibt es nur Version 1. Zukünftige Migrationen hier ergänzen.
// Beispiel für v1 → v2:
// if (data.version === 1) { data.newField = defaultValue; data.version = 2; }
console.info(`[UserProfile] Migration von v${data.version} zu v${CURRENT_VERSION}`);
data.version = CURRENT_VERSION;
const profile = new UserProfile({ ...createDefaults(), ...data });
profile.save();
return profile;
}
/**
* Importiert Daten aus Legacy-localStorage-Keys.
* Bestehende Keys werden NICHT gelöscht (Rückwärtskompatibilität).
* @param {UserProfile} profile - Das Profil, in das importiert wird
* @private
*/
static _importLegacy(profile) {
try {
// Legacy: lp_progress (Learning-Path Fortschritt)
const lpRaw = localStorage.getItem(LEGACY_KEYS.LP_PROGRESS);
if (lpRaw) {
const lpData = JSON.parse(lpRaw);
// lpData hat Struktur: { courseId: { completed: [...pageIndices] } }
if (lpData && typeof lpData === 'object') {
Object.keys(lpData).forEach(courseId => {
// Mapping: courseId → Content-Registry-ID
const registryId = UserProfile._mapLegacyCourseId(courseId);
if (registryId && lpData[courseId]?.completed?.length > 0) {
profile.completeResource(registryId);
}
});
}
}
// Legacy: ai_unboxed_universe_progress (Challenge-Fortschritt)
const universeRaw = localStorage.getItem(LEGACY_KEYS.UNIVERSE_PROGRESS);
if (universeRaw) {
const universeData = JSON.parse(universeRaw);
if (universeData?.completedNodes) {
Object.keys(universeData.completedNodes).forEach(nodeId => {
if (universeData.completedNodes[nodeId]) {
profile.completeResource(nodeId);
}
});
}
}
// Legacy: aiu_intro_seen
const introSeen = localStorage.getItem(LEGACY_KEYS.INTRO_SEEN);
if (introSeen) {
profile.onboardingCompleted = 'legacy';
}
} catch (err) {
console.warn('[UserProfile] Fehler beim Import von Legacy-Daten:', err);
}
}
/**
* Mappt Legacy-Course-IDs auf Content-Registry-IDs.
* @param {string} courseId - z.B. 'search-trees-rotatebox'
* @returns {string|null}
* @private
*/
static _mapLegacyCourseId(courseId) {
const MAPPING = {
'search-trees-rotatebox': 'expedition_search_trees',
'expedition-searchtrees': 'expedition_search_trees',
'minimax-ttt': 'expedition_minimax',
'expedition-minimax': 'expedition_minimax'
};
return MAPPING[courseId] || null;
}
}
// Global Export (analog zu iframe-bridge.js)
if (typeof window !== 'undefined') {
window.UserProfile = UserProfile;
}