mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
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) <noreply@anthropic.com>
This commit is contained in:
parent
a31ccc6c62
commit
7754cf6e00
5 changed files with 218 additions and 472 deletions
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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<IDBPDatabase<SkillTreeDB>> | null = null;
|
||||
|
||||
function getDB(): Promise<IDBPDatabase<SkillTreeDB>> {
|
||||
if (!dbPromise) {
|
||||
dbPromise = openDB<SkillTreeDB>(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<Skill[]> {
|
||||
const db = await getDB();
|
||||
return db.getAll('skills');
|
||||
}
|
||||
|
||||
export async function getSkillById(id: string): Promise<Skill | undefined> {
|
||||
const db = await getDB();
|
||||
return db.get('skills', id);
|
||||
}
|
||||
|
||||
export async function getSkillsByBranch(branch: string): Promise<Skill[]> {
|
||||
const db = await getDB();
|
||||
return db.getAllFromIndex('skills', 'by-branch', branch);
|
||||
}
|
||||
|
||||
export async function getChildSkills(parentId: string): Promise<Skill[]> {
|
||||
const db = await getDB();
|
||||
return db.getAllFromIndex('skills', 'by-parent', parentId);
|
||||
}
|
||||
|
||||
export async function saveSkill(skill: Skill): Promise<void> {
|
||||
const db = await getDB();
|
||||
skill.updatedAt = new Date().toISOString();
|
||||
await db.put('skills', skill);
|
||||
}
|
||||
|
||||
export async function deleteSkill(id: string): Promise<void> {
|
||||
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<Activity[]> {
|
||||
const db = await getDB();
|
||||
return db.getAll('activities');
|
||||
}
|
||||
|
||||
export async function getActivitiesBySkill(skillId: string): Promise<Activity[]> {
|
||||
const db = await getDB();
|
||||
return db.getAllFromIndex('activities', 'by-skill', skillId);
|
||||
}
|
||||
|
||||
export async function getRecentActivities(limit = 10): Promise<Activity[]> {
|
||||
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<void> {
|
||||
const db = await getDB();
|
||||
await db.put('activities', activity);
|
||||
}
|
||||
|
||||
export async function deleteActivity(id: string): Promise<void> {
|
||||
const db = await getDB();
|
||||
await db.delete('activities', id);
|
||||
}
|
||||
|
||||
// User Stats
|
||||
export async function getUserStats(): Promise<UserStats> {
|
||||
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<void> {
|
||||
const db = await getDB();
|
||||
await db.put('stats', stats, 'user-stats');
|
||||
}
|
||||
|
||||
// Utility: Recalculate stats from all skills
|
||||
export async function recalculateStats(): Promise<UserStats> {
|
||||
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<void> {
|
||||
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<AchievementWithStatus[]> {
|
||||
const db = await getDB();
|
||||
return db.getAll('achievements');
|
||||
}
|
||||
|
||||
export async function saveAchievement(achievement: AchievementWithStatus): Promise<void> {
|
||||
const db = await getDB();
|
||||
await db.put('achievements', achievement);
|
||||
}
|
||||
|
||||
export async function saveAllAchievements(
|
||||
achievementsList: AchievementWithStatus[]
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<AchievementWithStatus[]>([]);
|
||||
let isLoading = $state(true);
|
||||
let initialized = $state(false);
|
||||
let useApi = $state(false);
|
||||
|
||||
// Queue of recently unlocked achievements to show celebrations
|
||||
let unlockQueue = $state<AchievementUnlockResult[]>([]);
|
||||
|
||||
// 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,
|
||||
|
|
|
|||
|
|
@ -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<UserStats>({
|
|||
});
|
||||
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<SkillBranch, Skill[]> = {
|
||||
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<Skill>): Promise<Skill> {
|
||||
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<Skill>): Promise<void> {
|
||||
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<LocalSkill> = {};
|
||||
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<void> {
|
||||
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<void> {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue