From 7754cf6e00fdcec4559eb0120301c6f600f8993b Mon Sep 17 00:00:00 2001 From: Till JS Date: Fri, 27 Mar 2026 21:32:47 +0100 Subject: [PATCH] refactor(skilltree): replace custom idb storage with @manacore/local-store Remove the custom IndexedDB implementation (idb package + services/storage.ts) and rewrite skills + achievements stores to use @manacore/local-store collections. Changes: - Rewrite skills.svelte.ts: all CRUD via skillCollection/activityCollection - Rewrite achievements.svelte.ts: all persistence via achievementCollection - Delete services/storage.ts (282 lines of custom idb code) - Remove idb dependency from package.json - Simplify layout comments The stores now follow the same pattern as all other migrated apps: reads/writes go to IndexedDB (Dexie.js), sync happens automatically via mana-sync when authenticated. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/skilltree/apps/web/package.json | 1 - .../apps/web/src/lib/services/storage.ts | 281 ---------------- .../web/src/lib/stores/achievements.svelte.ts | 98 +++--- .../apps/web/src/lib/stores/skills.svelte.ts | 306 +++++++++--------- .../apps/web/src/routes/+layout.svelte | 4 +- 5 files changed, 218 insertions(+), 472 deletions(-) delete mode 100644 apps/skilltree/apps/web/src/lib/services/storage.ts diff --git a/apps/skilltree/apps/web/package.json b/apps/skilltree/apps/web/package.json index 472cb47e5..92a0d3123 100644 --- a/apps/skilltree/apps/web/package.json +++ b/apps/skilltree/apps/web/package.json @@ -49,7 +49,6 @@ "@manacore/shared-theme": "workspace:*", "@manacore/shared-ui": "workspace:^", "@manacore/shared-utils": "workspace:*", - "idb": "^8.0.0", "svelte-i18n": "^4.0.1", "uuid": "^11.0.0" }, diff --git a/apps/skilltree/apps/web/src/lib/services/storage.ts b/apps/skilltree/apps/web/src/lib/services/storage.ts deleted file mode 100644 index 0864a8308..000000000 --- a/apps/skilltree/apps/web/src/lib/services/storage.ts +++ /dev/null @@ -1,281 +0,0 @@ -import { openDB, type IDBPDatabase } from 'idb'; -import type { Skill, Activity, UserStats, AchievementWithStatus } from '$lib/types'; - -interface SkillTreeDB { - skills: { - key: string; - value: Skill; - indexes: { - 'by-branch': string; - 'by-parent': string | null; - 'by-level': number; - }; - }; - activities: { - key: string; - value: Activity; - indexes: { - 'by-skill': string; - 'by-timestamp': string; - }; - }; - stats: { - key: 'user-stats'; - value: UserStats; - }; - achievements: { - key: string; - value: AchievementWithStatus; - indexes: { - 'by-category': string; - 'by-unlocked': number; - }; - }; -} - -const DB_NAME = 'skilltree-db'; -const DB_VERSION = 2; - -let dbPromise: Promise> | null = null; - -function getDB(): Promise> { - if (!dbPromise) { - dbPromise = openDB(DB_NAME, DB_VERSION, { - upgrade(db) { - // Skills store - if (!db.objectStoreNames.contains('skills')) { - const skillStore = db.createObjectStore('skills', { keyPath: 'id' }); - skillStore.createIndex('by-branch', 'branch'); - skillStore.createIndex('by-parent', 'parentId'); - skillStore.createIndex('by-level', 'level'); - } - - // Activities store - if (!db.objectStoreNames.contains('activities')) { - const activityStore = db.createObjectStore('activities', { keyPath: 'id' }); - activityStore.createIndex('by-skill', 'skillId'); - activityStore.createIndex('by-timestamp', 'timestamp'); - } - - // Stats store - if (!db.objectStoreNames.contains('stats')) { - db.createObjectStore('stats'); - } - - // Achievements store (added in v2) - if (!db.objectStoreNames.contains('achievements')) { - const achievementStore = db.createObjectStore('achievements', { keyPath: 'id' }); - achievementStore.createIndex('by-category', 'category'); - achievementStore.createIndex('by-unlocked', 'unlocked'); - } - }, - }); - } - return dbPromise; -} - -// Skills CRUD -export async function getAllSkills(): Promise { - const db = await getDB(); - return db.getAll('skills'); -} - -export async function getSkillById(id: string): Promise { - const db = await getDB(); - return db.get('skills', id); -} - -export async function getSkillsByBranch(branch: string): Promise { - const db = await getDB(); - return db.getAllFromIndex('skills', 'by-branch', branch); -} - -export async function getChildSkills(parentId: string): Promise { - const db = await getDB(); - return db.getAllFromIndex('skills', 'by-parent', parentId); -} - -export async function saveSkill(skill: Skill): Promise { - const db = await getDB(); - skill.updatedAt = new Date().toISOString(); - await db.put('skills', skill); -} - -export async function deleteSkill(id: string): Promise { - const db = await getDB(); - // Delete skill and all its activities - const activities = await db.getAllFromIndex('activities', 'by-skill', id); - const tx = db.transaction(['skills', 'activities'], 'readwrite'); - await Promise.all([ - tx.objectStore('skills').delete(id), - ...activities.map((a) => tx.objectStore('activities').delete(a.id)), - ]); - await tx.done; -} - -// Activities CRUD -export async function getAllActivities(): Promise { - const db = await getDB(); - return db.getAll('activities'); -} - -export async function getActivitiesBySkill(skillId: string): Promise { - const db = await getDB(); - return db.getAllFromIndex('activities', 'by-skill', skillId); -} - -export async function getRecentActivities(limit = 10): Promise { - const db = await getDB(); - const all = await db.getAllFromIndex('activities', 'by-timestamp'); - return all.reverse().slice(0, limit); -} - -export async function saveActivity(activity: Activity): Promise { - const db = await getDB(); - await db.put('activities', activity); -} - -export async function deleteActivity(id: string): Promise { - const db = await getDB(); - await db.delete('activities', id); -} - -// User Stats -export async function getUserStats(): Promise { - const db = await getDB(); - const stats = await db.get('stats', 'user-stats'); - return ( - stats ?? { - totalXp: 0, - totalSkills: 0, - highestLevel: 0, - streakDays: 0, - lastActivityDate: null, - } - ); -} - -export async function saveUserStats(stats: UserStats): Promise { - const db = await getDB(); - await db.put('stats', stats, 'user-stats'); -} - -// Utility: Recalculate stats from all skills -export async function recalculateStats(): Promise { - const skills = await getAllSkills(); - const activities = await getAllActivities(); - - const stats: UserStats = { - totalXp: skills.reduce((sum, s) => sum + s.totalXp, 0), - totalSkills: skills.length, - highestLevel: skills.reduce((max, s) => Math.max(max, s.level), 0), - streakDays: calculateStreak(activities), - lastActivityDate: activities.length > 0 ? activities[activities.length - 1].timestamp : null, - }; - - await saveUserStats(stats); - return stats; -} - -function calculateStreak(activities: Activity[]): number { - if (activities.length === 0) return 0; - - const today = new Date(); - today.setHours(0, 0, 0, 0); - - const sortedDates = activities - .map((a) => { - const d = new Date(a.timestamp); - d.setHours(0, 0, 0, 0); - return d.getTime(); - }) - .filter((v, i, a) => a.indexOf(v) === i) // unique dates - .sort((a, b) => b - a); // newest first - - let streak = 0; - let expectedDate = today.getTime(); - - for (const date of sortedDates) { - if (date === expectedDate || date === expectedDate - 86400000) { - streak++; - expectedDate = date - 86400000; - } else if (date < expectedDate - 86400000) { - break; - } - } - - return streak; -} - -// Export all data (for backup) -export async function exportData(): Promise<{ - skills: Skill[]; - activities: Activity[]; - stats: UserStats; -}> { - const [skills, activities, stats] = await Promise.all([ - getAllSkills(), - getAllActivities(), - getUserStats(), - ]); - return { skills, activities, stats }; -} - -// Import data (restore backup) -export async function importData(data: { - skills: Skill[]; - activities: Activity[]; - stats: UserStats; -}): Promise { - const db = await getDB(); - const tx = db.transaction(['skills', 'activities', 'stats'], 'readwrite'); - - // Clear existing data - await tx.objectStore('skills').clear(); - await tx.objectStore('activities').clear(); - - // Import new data - for (const skill of data.skills) { - await tx.objectStore('skills').put(skill); - } - for (const activity of data.activities) { - await tx.objectStore('activities').put(activity); - } - await tx.objectStore('stats').put(data.stats, 'user-stats'); - - await tx.done; -} - -// Achievements CRUD -export async function getAllAchievements(): Promise { - const db = await getDB(); - return db.getAll('achievements'); -} - -export async function saveAchievement(achievement: AchievementWithStatus): Promise { - const db = await getDB(); - await db.put('achievements', achievement); -} - -export async function saveAllAchievements( - achievementsList: AchievementWithStatus[] -): Promise { - const db = await getDB(); - const tx = db.transaction('achievements', 'readwrite'); - await tx.objectStore('achievements').clear(); - for (const a of achievementsList) { - await tx.objectStore('achievements').put(a); - } - await tx.done; -} - -export async function unlockAchievement(id: string): Promise { - const db = await getDB(); - const achievement = await db.get('achievements', id); - if (achievement && !achievement.unlocked) { - achievement.unlocked = true; - achievement.unlockedAt = new Date().toISOString(); - achievement.progress = achievement.condition.threshold; - await db.put('achievements', achievement); - } -} diff --git a/apps/skilltree/apps/web/src/lib/stores/achievements.svelte.ts b/apps/skilltree/apps/web/src/lib/stores/achievements.svelte.ts index b59ec086e..036bd89cd 100644 --- a/apps/skilltree/apps/web/src/lib/stores/achievements.svelte.ts +++ b/apps/skilltree/apps/web/src/lib/stores/achievements.svelte.ts @@ -1,3 +1,10 @@ +/** + * Achievements Store — Local-First with @manacore/local-store + * + * All achievement state stored in IndexedDB via Dexie.js. + * Sync to server happens automatically when authenticated. + */ + import type { AchievementWithStatus, AchievementUnlockResult, @@ -7,20 +14,18 @@ import type { UserStats, } from '$lib/types'; import { ACHIEVEMENT_DEFINITIONS } from '$lib/types'; -import * as storage from '$lib/services/storage'; -import * as achievementsApi from '$lib/api/achievements'; -import { authStore } from './auth.svelte'; +import { achievementCollection, type LocalAchievement } from '$lib/data/local-store'; // Reactive state let achievements = $state([]); let isLoading = $state(true); let initialized = $state(false); -let useApi = $state(false); // Queue of recently unlocked achievements to show celebrations let unlockQueue = $state([]); -// Derived values +// ─── Derived values ────────────────────────────────────────── + const unlockedAchievements = $derived(() => { return achievements.filter((a) => a.unlocked); }); @@ -57,48 +62,55 @@ const completionPercentage = $derived(() => { return Math.round((achievements.filter((a) => a.unlocked).length / achievements.length) * 100); }); -// Actions +// ─── Actions ───────────────────────────────────────────────── + async function initialize() { if (initialized) return; isLoading = true; try { - if (authStore.isAuthenticated) { - useApi = true; - achievements = await achievementsApi.getAchievements(); - } else { - useApi = false; - const stored = await storage.getAllAchievements(); - if (stored.length === 0) { - // First time: seed from definitions - achievements = ACHIEVEMENT_DEFINITIONS.map((def) => ({ - ...def, - unlocked: false, - unlockedAt: null, - progress: 0, - })); - await storage.saveAllAchievements(achievements); - } else { - achievements = stored; + const stored = await achievementCollection.getAll(); + if (stored.length === 0) { + // First time: seed from definitions + achievements = ACHIEVEMENT_DEFINITIONS.map((def) => ({ + ...def, + unlocked: false, + unlockedAt: null, + progress: 0, + })); + // Save each to IndexedDB + for (const a of achievements) { + await achievementCollection.insert({ + id: a.id, + key: a.id, + name: a.name, + description: a.description, + icon: a.icon, + unlockedAt: '', + }); } + } else { + // Merge stored data with definitions (in case new achievements were added) + achievements = ACHIEVEMENT_DEFINITIONS.map((def) => { + const found = stored.find((s) => s.key === def.id || s.id === def.id); + return { + ...def, + unlocked: found?.unlockedAt ? true : false, + unlockedAt: found?.unlockedAt || null, + progress: 0, + }; + }); } initialized = true; } catch (error) { console.error('Failed to initialize achievements store:', error); - // Fallback to local definitions - if (useApi) { - useApi = false; - const stored = await storage.getAllAchievements(); - achievements = - stored.length > 0 - ? stored - : ACHIEVEMENT_DEFINITIONS.map((def) => ({ - ...def, - unlocked: false, - unlockedAt: null, - progress: 0, - })); - } + // Fallback to definitions + achievements = ACHIEVEMENT_DEFINITIONS.map((def) => ({ + ...def, + unlocked: false, + unlockedAt: null, + progress: 0, + })); } finally { isLoading = false; } @@ -113,7 +125,7 @@ async function reinitialize() { /** * Check achievements locally (offline mode). - * Called after skill/activity changes when not using API. + * Called after skill/activity changes. */ async function checkLocal(context: { skills: Skill[]; @@ -200,14 +212,15 @@ async function checkLocal(context: { progress: condition.threshold, }; achievements = [...achievements.slice(0, i), unlocked, ...achievements.slice(i + 1)]; - await storage.saveAchievement(unlocked); + await achievementCollection.update(a.id, { + unlockedAt: unlocked.unlockedAt!, + }); newlyUnlocked.push({ achievement: a, xpReward: a.xpReward }); } else { // Update progress const updated = { ...a, progress: Math.min(current, condition.threshold) }; if (updated.progress !== a.progress) { achievements = [...achievements.slice(0, i), updated, ...achievements.slice(i + 1)]; - await storage.saveAchievement(updated); } } } @@ -220,7 +233,7 @@ async function checkLocal(context: { } /** - * Handle achievements returned from the API after a skill/XP action. + * Handle achievements returned from server sync. */ function handleApiUnlocks(results: AchievementUnlockResult[]) { if (results.length === 0) return; @@ -279,9 +292,6 @@ export const achievementStore = { get unlockQueue() { return unlockQueue; }, - get useApi() { - return useApi; - }, initialize, reinitialize, diff --git a/apps/skilltree/apps/web/src/lib/stores/skills.svelte.ts b/apps/skilltree/apps/web/src/lib/stores/skills.svelte.ts index 852af0e7d..f6e6fc59e 100644 --- a/apps/skilltree/apps/web/src/lib/stores/skills.svelte.ts +++ b/apps/skilltree/apps/web/src/lib/stores/skills.svelte.ts @@ -1,10 +1,19 @@ -import type { Skill, Activity, UserStats, SkillBranch, AchievementUnlockResult } from '$lib/types'; +/** + * Skills Store — Local-First with @manacore/local-store + * + * All reads and writes go to IndexedDB (Dexie.js) first. + * When authenticated, changes sync to the server in the background. + */ + +import type { Skill, Activity, UserStats, SkillBranch } from '$lib/types'; import { calculateLevel, createDefaultSkill, createActivity, BRANCH_INFO } from '$lib/types'; import { SkillTreeEvents } from '@manacore/shared-utils/analytics'; -import * as storage from '$lib/services/storage'; -import * as skillsApi from '$lib/api/skills'; -import * as activitiesApi from '$lib/api/activities'; -import { authStore } from './auth.svelte'; +import { + skillCollection, + activityCollection, + type LocalSkill, + type LocalActivity, +} from '$lib/data/local-store'; import { achievementStore } from './achievements.svelte'; // Reactive state using Svelte 5 runes @@ -19,9 +28,39 @@ let userStats = $state({ }); let isLoading = $state(true); let initialized = $state(false); -let useApi = $state(false); -// Derived values +// ─── Converters ────────────────────────────────────────────── + +function toSkill(local: LocalSkill): Skill { + return { + id: local.id, + name: local.name, + description: local.description, + branch: local.branch, + parentId: local.parentId ?? null, + icon: local.icon, + color: local.color ?? null, + currentXp: local.currentXp, + totalXp: local.totalXp, + level: local.level, + createdAt: local.createdAt ?? new Date().toISOString(), + updatedAt: local.updatedAt ?? new Date().toISOString(), + }; +} + +function toActivity(local: LocalActivity): Activity { + return { + id: local.id, + skillId: local.skillId, + xpEarned: local.xpEarned, + description: local.description, + duration: local.duration ?? null, + timestamp: local.timestamp, + }; +} + +// ─── Derived values ────────────────────────────────────────── + const skillsByBranch = $derived(() => { const grouped: Record = { intellect: [], @@ -65,118 +104,78 @@ const branchStats = $derived(() => { return stats; }); -// Actions +// ─── Actions ───────────────────────────────────────────────── + async function initialize() { if (initialized) return; isLoading = true; try { - // Check if user is authenticated - if (authStore.isAuthenticated) { - useApi = true; - const [loadedSkills, loadedActivities, loadedStats] = await Promise.all([ - skillsApi.getSkills(), - activitiesApi.getRecentActivities(50), - skillsApi.getStats(), - ]); - skills = loadedSkills; - activities = loadedActivities; - userStats = loadedStats; - } else { - // Fallback to IndexedDB for offline/unauthenticated use - useApi = false; - const [loadedSkills, loadedActivities, loadedStats] = await Promise.all([ - storage.getAllSkills(), - storage.getAllActivities(), - storage.getUserStats(), - ]); - skills = loadedSkills; - activities = loadedActivities; - userStats = loadedStats; - } + const [localSkills, localActivities] = await Promise.all([ + skillCollection.getAll(), + activityCollection.getAll(), + ]); + skills = localSkills.map(toSkill); + activities = localActivities.map(toActivity); + recalculateStats(); initialized = true; } catch (error) { console.error('Failed to initialize skills store:', error); - // On error, try IndexedDB as fallback - if (useApi) { - try { - useApi = false; - const [loadedSkills, loadedActivities, loadedStats] = await Promise.all([ - storage.getAllSkills(), - storage.getAllActivities(), - storage.getUserStats(), - ]); - skills = loadedSkills; - activities = loadedActivities; - userStats = loadedStats; - } catch (fallbackError) { - console.error('Fallback to IndexedDB also failed:', fallbackError); - } - } } finally { isLoading = false; } } async function addSkill(data: Partial): Promise { - if (useApi && authStore.isAuthenticated) { - const result = await skillsApi.createSkill({ - name: data.name || '', - description: data.description, - branch: data.branch || 'custom', - parentId: data.parentId ?? undefined, - icon: data.icon, - color: data.color ?? undefined, - }); - skills = [...skills, result.skill]; - SkillTreeEvents.skillCreated(data.branch || 'custom'); - await updateStats(); - if (result.newAchievements?.length > 0) { - achievementStore.handleApiUnlocks(result.newAchievements); - } - return result.skill; - } else { - const skill = createDefaultSkill(data); - await storage.saveSkill(skill); - skills = [...skills, skill]; - SkillTreeEvents.skillCreated(data.branch || 'custom'); - await updateStats(); - return skill; - } + const skill = createDefaultSkill(data); + const localSkill: LocalSkill = { + id: skill.id, + name: skill.name, + description: skill.description, + branch: skill.branch, + parentId: skill.parentId, + icon: skill.icon, + color: skill.color, + currentXp: skill.currentXp, + totalXp: skill.totalXp, + level: skill.level, + }; + await skillCollection.insert(localSkill); + skills = [...skills, skill]; + SkillTreeEvents.skillCreated(data.branch || 'custom'); + recalculateStats(); + return skill; } async function updateSkill(id: string, updates: Partial): Promise { const index = skills.findIndex((s) => s.id === id); if (index === -1) return; - if (useApi && authStore.isAuthenticated) { - const skill = await skillsApi.updateSkill(id, { - name: updates.name, - description: updates.description, - branch: updates.branch, - parentId: updates.parentId, - icon: updates.icon, - color: updates.color, - }); - skills = [...skills.slice(0, index), skill, ...skills.slice(index + 1)]; - } else { - const updatedSkill = { ...skills[index], ...updates, updatedAt: new Date().toISOString() }; - await storage.saveSkill(updatedSkill); - skills = [...skills.slice(0, index), updatedSkill, ...skills.slice(index + 1)]; - } - await updateStats(); + const localUpdates: Partial = {}; + if (updates.name !== undefined) localUpdates.name = updates.name; + if (updates.description !== undefined) localUpdates.description = updates.description; + if (updates.branch !== undefined) localUpdates.branch = updates.branch; + if (updates.parentId !== undefined) localUpdates.parentId = updates.parentId; + if (updates.icon !== undefined) localUpdates.icon = updates.icon; + if (updates.color !== undefined) localUpdates.color = updates.color; + + await skillCollection.update(id, localUpdates); + const updatedSkill = { ...skills[index], ...updates, updatedAt: new Date().toISOString() }; + skills = [...skills.slice(0, index), updatedSkill, ...skills.slice(index + 1)]; + recalculateStats(); } async function deleteSkill(id: string): Promise { - if (useApi && authStore.isAuthenticated) { - await skillsApi.deleteSkill(id); - } else { - await storage.deleteSkill(id); + // Delete all activities for this skill + const skillActivities = await activityCollection.getAll({ skillId: id }); + for (const a of skillActivities) { + await activityCollection.delete(a.id); } + await skillCollection.delete(id); skills = skills.filter((s) => s.id !== id); - SkillTreeEvents.skillDeleted(); activities = activities.filter((a) => a.skillId !== id); - await updateStats(); + SkillTreeEvents.skillDeleted(); + recalculateStats(); } async function addXp( @@ -188,66 +187,89 @@ async function addXp( const index = skills.findIndex((s) => s.id === skillId); if (index === -1) return { leveledUp: false, newLevel: 0 }; - if (useApi && authStore.isAuthenticated) { - const result = await skillsApi.addXp(skillId, { xp, description, duration }); - skills = [...skills.slice(0, index), result.skill, ...skills.slice(index + 1)]; - activities = [...activities, result.activity]; - SkillTreeEvents.xpAdded(xp, result.leveledUp); - await updateStats(); - if (result.newAchievements?.length > 0) { - achievementStore.handleApiUnlocks(result.newAchievements); - } - return { leveledUp: result.leveledUp, newLevel: result.newLevel }; - } else { - const skill = skills[index]; - const newTotalXp = skill.totalXp + xp; - const newCurrentXp = skill.currentXp + xp; - const newLevel = calculateLevel(newTotalXp); - const leveledUp = newLevel > skill.level; + const skill = skills[index]; + const newTotalXp = skill.totalXp + xp; + const newCurrentXp = skill.currentXp + xp; + const newLevel = calculateLevel(newTotalXp); + const leveledUp = newLevel > skill.level; - const updatedSkill: Skill = { - ...skill, - totalXp: newTotalXp, - currentXp: newCurrentXp, - level: newLevel, - updatedAt: new Date().toISOString(), - }; + await skillCollection.update(skillId, { + totalXp: newTotalXp, + currentXp: newCurrentXp, + level: newLevel, + }); - const activity = createActivity(skillId, xp, description, duration); + const activity = createActivity(skillId, xp, description, duration); + const localActivity: LocalActivity = { + id: activity.id, + skillId: activity.skillId, + xpEarned: activity.xpEarned, + description: activity.description, + duration: activity.duration, + timestamp: activity.timestamp, + }; + await activityCollection.insert(localActivity); - await Promise.all([storage.saveSkill(updatedSkill), storage.saveActivity(activity)]); + const updatedSkill: Skill = { + ...skill, + totalXp: newTotalXp, + currentXp: newCurrentXp, + level: newLevel, + updatedAt: new Date().toISOString(), + }; - skills = [...skills.slice(0, index), updatedSkill, ...skills.slice(index + 1)]; - activities = [...activities, activity]; - await updateStats(); + skills = [...skills.slice(0, index), updatedSkill, ...skills.slice(index + 1)]; + activities = [...activities, activity]; + SkillTreeEvents.xpAdded(xp, leveledUp); + recalculateStats(); - return { leveledUp, newLevel }; - } + return { leveledUp, newLevel }; } -async function updateStats(): Promise { - if (useApi && authStore.isAuthenticated) { - try { - userStats = await skillsApi.getStats(); - } catch { - // Calculate locally as fallback - userStats = calculateLocalStats(); - } - } else { - userStats = await storage.recalculateStats(); - } -} +function recalculateStats(): void { + const sortedActivities = [...activities].sort( + (a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime() + ); -function calculateLocalStats(): UserStats { - return { + userStats = { totalXp: skills.reduce((sum, s) => sum + s.totalXp, 0), totalSkills: skills.length, highestLevel: skills.reduce((max, s) => Math.max(max, s.level), 0), - streakDays: 0, - lastActivityDate: activities.length > 0 ? activities[activities.length - 1].timestamp : null, + streakDays: calculateStreak(activities), + lastActivityDate: sortedActivities.length > 0 ? sortedActivities[0].timestamp : null, }; } +function calculateStreak(activityList: Activity[]): number { + if (activityList.length === 0) return 0; + + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const sortedDates = activityList + .map((a) => { + const d = new Date(a.timestamp); + d.setHours(0, 0, 0, 0); + return d.getTime(); + }) + .filter((v, i, a) => a.indexOf(v) === i) + .sort((a, b) => b - a); + + let streak = 0; + let expectedDate = today.getTime(); + + for (const date of sortedDates) { + if (date === expectedDate || date === expectedDate - 86400000) { + streak++; + expectedDate = date - 86400000; + } else if (date < expectedDate - 86400000) { + break; + } + } + + return streak; +} + function getSkill(id: string): Skill | undefined { return skills.find((s) => s.id === id); } @@ -256,7 +278,6 @@ function getSkillActivities(skillId: string): Activity[] { return activities.filter((a) => a.skillId === skillId); } -// Reinitialize when auth state changes async function reinitialize() { initialized = false; skills = []; @@ -271,7 +292,7 @@ async function reinitialize() { await initialize(); } -// Export store as object with getters for reactive access +// Export store export const skillStore = { get skills() { return skills; @@ -300,9 +321,6 @@ export const skillStore = { get branchStats() { return branchStats; }, - get useApi() { - return useApi; - }, initialize, reinitialize, diff --git a/apps/skilltree/apps/web/src/routes/+layout.svelte b/apps/skilltree/apps/web/src/routes/+layout.svelte index 7f2045f7f..2e0d7f1f8 100644 --- a/apps/skilltree/apps/web/src/routes/+layout.svelte +++ b/apps/skilltree/apps/web/src/routes/+layout.svelte @@ -13,12 +13,12 @@ let { children } = $props(); async function handleAuthReady() { - // Initialize unified local-store (IndexedDB + sync) + // Initialize local-first database (IndexedDB via Dexie.js) await skilltreeStore.initialize(); if (authStore.isAuthenticated) { skilltreeStore.startSync(() => authStore.getValidToken()); } - // Initialize existing idb-based stores + // Load data from IndexedDB into reactive stores await skillStore.initialize(); await achievementStore.initialize(); }