refactor(go-services): integrate shared-go into crawler + gateway, fix Dockerfiles

- mana-crawler: config → envutil, handler → httputil.WriteJSON
- mana-api-gateway: config → envutil, handlers → httputil.WriteJSON
- Fix Dockerfile COPY paths (remove stale -go suffix in all 4 services)
- All services now use packages/shared-go via replace directive

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-28 16:25:30 +01:00
parent ba6dbf16c4
commit bf4d9cb9aa
39 changed files with 1313 additions and 1379 deletions

View file

@ -1,7 +1,22 @@
<script lang="ts">
import { skillStore } from '$lib/stores/skills.svelte';
import { achievementStore } from '$lib/stores/achievements.svelte';
import {
useAllSkills,
useAllActivities,
useAllAchievements,
computeUserStats,
} from '$lib/data/queries';
import { buildAchievementStatus, getAchievementStats } from '$lib/stores/achievements.svelte';
import { Trophy, Lightning, Target, Fire, Medal } from '@manacore/shared-icons';
// Reactive live queries
const allSkills = useAllSkills();
const allActivities = useAllActivities();
const allAchievementsRaw = useAllAchievements();
const userStats = $derived(computeUserStats(allSkills.value, allActivities.value));
const achievementStats = $derived(
getAchievementStats(buildAchievementStatus(allAchievementsRaw.value))
);
</script>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
@ -14,7 +29,7 @@
<div>
<p class="text-sm text-gray-400">Gesamt-XP</p>
<p class="text-2xl font-bold text-white">
{skillStore.userStats.totalXp.toLocaleString()}
{userStats.totalXp.toLocaleString()}
</p>
</div>
</div>
@ -29,7 +44,7 @@
<div>
<p class="text-sm text-gray-400">Skills</p>
<p class="text-2xl font-bold text-white">
{skillStore.userStats.totalSkills}
{userStats.totalSkills}
</p>
</div>
</div>
@ -42,9 +57,9 @@
<Trophy class="h-6 w-6 text-purple-500" />
</div>
<div>
<p class="text-sm text-gray-400">Höchstes Level</p>
<p class="text-sm text-gray-400">Hochstes Level</p>
<p class="text-2xl font-bold text-white">
{skillStore.userStats.highestLevel}
{userStats.highestLevel}
</p>
</div>
</div>
@ -59,7 +74,7 @@
<div>
<p class="text-sm text-gray-400">Streak</p>
<p class="text-2xl font-bold text-white">
{skillStore.userStats.streakDays} Tage
{userStats.streakDays} Tage
</p>
</div>
</div>
@ -77,8 +92,8 @@
<div>
<p class="text-sm text-gray-400">Achievements</p>
<p class="text-2xl font-bold text-white">
{achievementStore.stats().unlocked}<span class="text-sm font-normal text-gray-500"
>/{achievementStore.stats().total}</span
{achievementStats.unlocked}<span class="text-sm font-normal text-gray-500"
>/{achievementStats.total}</span
>
</p>
</div>

View file

@ -0,0 +1,186 @@
/**
* Reactive Queries & Pure Helpers for SkilltTree
*
* Uses Dexie liveQuery to automatically re-render when IndexedDB changes
* (local writes, sync updates, other tabs). Components call these hooks
* at init time; no manual fetch/refresh needed.
*/
import { useLiveQueryWithDefault } from '@manacore/local-store/svelte';
import {
skillCollection,
activityCollection,
achievementCollection,
type LocalSkill,
type LocalActivity,
type LocalAchievement,
} from './local-store';
import type { Skill, Activity, SkillBranch, UserStats } from '$lib/types';
import { BRANCH_INFO } from '$lib/types';
// ─── Type Converters ───────────────────────────────────────
export 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(),
};
}
export 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,
};
}
// ─── Live Query Hooks (call during component init) ─────────
/** All skills, auto-updates on any change. */
export function useAllSkills() {
return useLiveQueryWithDefault(async () => {
const locals = await skillCollection.getAll();
return locals.map(toSkill);
}, [] as Skill[]);
}
/** All activities, auto-updates on any change. */
export function useAllActivities() {
return useLiveQueryWithDefault(async () => {
const locals = await activityCollection.getAll();
return locals.map(toActivity);
}, [] as Activity[]);
}
/** All achievements (raw local records), auto-updates on any change. */
export function useAllAchievements() {
return useLiveQueryWithDefault(async () => {
return await achievementCollection.getAll();
}, [] as LocalAchievement[]);
}
// ─── Pure Filter/Helper Functions (for $derived) ──────────
/** Group skills by branch. */
export function groupByBranch(skills: Skill[]): Record<SkillBranch, Skill[]> {
const grouped: Record<SkillBranch, Skill[]> = {
intellect: [],
body: [],
creativity: [],
social: [],
practical: [],
mindset: [],
custom: [],
};
for (const skill of skills) {
grouped[skill.branch].push(skill);
}
return grouped;
}
/** Get top N skills by total XP. */
export function getTopSkills(skills: Skill[], n = 5): Skill[] {
return [...skills].sort((a, b) => b.totalXp - a.totalXp).slice(0, n);
}
/** Get recent N activities sorted by timestamp descending. */
export function getRecentActivities(activities: Activity[], n = 10): Activity[] {
return [...activities]
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
.slice(0, n);
}
/** Compute branch-level stats. */
export function computeBranchStats(
skills: Skill[]
): Record<SkillBranch, { count: number; totalXp: number; avgLevel: number }> {
const stats = {} as Record<SkillBranch, { count: number; totalXp: number; avgLevel: number }>;
for (const branch of Object.keys(BRANCH_INFO) as SkillBranch[]) {
const branchSkills = skills.filter((s) => s.branch === branch);
stats[branch] = {
count: branchSkills.length,
totalXp: branchSkills.reduce((sum, s) => sum + s.totalXp, 0),
avgLevel:
branchSkills.length > 0
? branchSkills.reduce((sum, s) => sum + s.level, 0) / branchSkills.length
: 0,
};
}
return stats;
}
/** Calculate activity streak in days. */
export 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)
.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;
}
/** Compute aggregate user stats from skills and activities. */
export function computeUserStats(skills: Skill[], activities: Activity[]): UserStats {
const sortedActivities = [...activities].sort(
(a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
);
return {
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: sortedActivities.length > 0 ? sortedActivities[0].timestamp : null,
};
}
/** Filter skills by branch (or return all if 'all'). */
export function filterByBranch(skills: Skill[], branch: SkillBranch | 'all'): Skill[] {
if (branch === 'all') return skills;
return skills.filter((s) => s.branch === branch);
}
/** Find a skill by ID. */
export function getSkillById(skills: Skill[], id: string): Skill | undefined {
return skills.find((s) => s.id === id);
}
/** Get all activities for a specific skill. */
export function getSkillActivities(activities: Activity[], skillId: string): Activity[] {
return activities.filter((a) => a.skillId === skillId);
}

View file

@ -1,8 +1,8 @@
/**
* Achievements Store Local-First with @manacore/local-store
* Achievements Store Write Actions + Unlock Queue
*
* All achievement state stored in IndexedDB via Dexie.js.
* Sync to server happens automatically when authenticated.
* Reads are handled by useLiveQuery hooks in queries.ts.
* This store handles achievement checking logic and the unlock celebration queue.
*/
import type {
@ -16,25 +16,47 @@ import type {
import { ACHIEVEMENT_DEFINITIONS } from '$lib/types';
import { achievementCollection, type LocalAchievement } from '$lib/data/local-store';
// Reactive state
let achievements = $state<AchievementWithStatus[]>([]);
let isLoading = $state(true);
let initialized = $state(false);
// Queue of recently unlocked achievements to show celebrations
let unlockQueue = $state<AchievementUnlockResult[]>([]);
// ─── Derived values ──────────────────────────────────────────
// ─── Derived helpers (pure functions for consumers) ──────────
const unlockedAchievements = $derived(() => {
/** Build achievement status list from stored records and definitions. */
export function buildAchievementStatus(stored: LocalAchievement[]): AchievementWithStatus[] {
if (stored.length === 0) {
return ACHIEVEMENT_DEFINITIONS.map((def) => ({
...def,
unlocked: false,
unlockedAt: null,
progress: 0,
}));
}
return 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,
};
});
}
export function getUnlockedAchievements(
achievements: AchievementWithStatus[]
): AchievementWithStatus[] {
return achievements.filter((a) => a.unlocked);
});
}
const lockedAchievements = $derived(() => {
export function getLockedAchievements(
achievements: AchievementWithStatus[]
): AchievementWithStatus[] {
return achievements.filter((a) => !a.unlocked);
});
}
const achievementsByCategory = $derived(() => {
export function getAchievementsByCategory(
achievements: AchievementWithStatus[]
): Record<AchievementCategory, AchievementWithStatus[]> {
const grouped: Record<AchievementCategory, AchievementWithStatus[]> = {
xp: [],
skills: [],
@ -48,81 +70,41 @@ const achievementsByCategory = $derived(() => {
grouped[a.category].push(a);
}
return grouped;
});
}
const stats = $derived(() => {
export function getAchievementStats(achievements: AchievementWithStatus[]): {
total: number;
unlocked: number;
} {
return {
total: achievements.length,
unlocked: achievements.filter((a) => a.unlocked).length,
};
});
}
const completionPercentage = $derived(() => {
export function getCompletionPercentage(achievements: AchievementWithStatus[]): number {
if (achievements.length === 0) return 0;
return Math.round((achievements.filter((a) => a.unlocked).length / achievements.length) * 100);
});
}
// ─── Actions ─────────────────────────────────────────────────
async function initialize() {
if (initialized) return;
isLoading = true;
try {
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,
};
async function seedIfEmpty() {
const stored = await achievementCollection.getAll();
if (stored.length === 0) {
for (const def of ACHIEVEMENT_DEFINITIONS) {
await achievementCollection.insert({
id: def.id,
key: def.id,
name: def.name,
description: def.description,
icon: def.icon,
unlockedAt: '',
});
}
initialized = true;
} catch (error) {
console.error('Failed to initialize achievements store:', error);
// Fallback to definitions
achievements = ACHIEVEMENT_DEFINITIONS.map((def) => ({
...def,
unlocked: false,
unlockedAt: null,
progress: 0,
}));
} finally {
isLoading = false;
}
}
async function reinitialize() {
initialized = false;
achievements = [];
unlockQueue = [];
await initialize();
}
/**
* Check achievements locally (offline mode).
* Called after skill/activity changes.
@ -135,6 +117,10 @@ async function checkLocal(context: {
}): Promise<AchievementUnlockResult[]> {
const { skills, activities: allActivities, userStats: stats, lastActivityXp } = context;
// Get current achievements from DB
const stored = await achievementCollection.getAll();
const achievements = buildAchievementStatus(stored);
const uniqueBranches = new Set(skills.map((s) => s.branch).filter((b) => b !== 'custom'));
const mainBranches = ['intellect', 'body', 'creativity', 'social', 'practical', 'mindset'];
@ -161,8 +147,7 @@ async function checkLocal(context: {
const newlyUnlocked: AchievementUnlockResult[] = [];
for (let i = 0; i < achievements.length; i++) {
const a = achievements[i];
for (const a of achievements) {
if (a.unlocked) continue;
const condition = a.condition;
@ -205,23 +190,10 @@ async function checkLocal(context: {
}
if (met) {
const unlocked: AchievementWithStatus = {
...a,
unlocked: true,
unlockedAt: new Date().toISOString(),
progress: condition.threshold,
};
achievements = [...achievements.slice(0, i), unlocked, ...achievements.slice(i + 1)];
await achievementCollection.update(a.id, {
unlockedAt: unlocked.unlockedAt!,
unlockedAt: new Date().toISOString(),
});
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)];
}
}
}
@ -232,31 +204,6 @@ async function checkLocal(context: {
return newlyUnlocked;
}
/**
* Handle achievements returned from server sync.
*/
function handleApiUnlocks(results: AchievementUnlockResult[]) {
if (results.length === 0) return;
for (const result of results) {
const index = achievements.findIndex((a) => a.id === result.achievement.id);
if (index !== -1) {
achievements = [
...achievements.slice(0, index),
{
...achievements[index],
unlocked: true,
unlockedAt: new Date().toISOString(),
progress: achievements[index].condition.threshold,
},
...achievements.slice(index + 1),
];
}
}
unlockQueue = [...unlockQueue, ...results];
}
function popUnlockQueue(): AchievementUnlockResult | null {
if (unlockQueue.length === 0) return null;
const [first, ...rest] = unlockQueue;
@ -265,37 +212,11 @@ function popUnlockQueue(): AchievementUnlockResult | null {
}
export const achievementStore = {
get achievements() {
return achievements;
},
get isLoading() {
return isLoading;
},
get initialized() {
return initialized;
},
get unlockedAchievements() {
return unlockedAchievements;
},
get lockedAchievements() {
return lockedAchievements;
},
get achievementsByCategory() {
return achievementsByCategory;
},
get stats() {
return stats;
},
get completionPercentage() {
return completionPercentage;
},
get unlockQueue() {
return unlockQueue;
},
initialize,
reinitialize,
seedIfEmpty,
checkLocal,
handleApiUnlocks,
popUnlockQueue,
};

View file

@ -1,12 +1,12 @@
/**
* Skills Store Local-First with @manacore/local-store
* Skills Store Write Actions Only
*
* All reads and writes go to IndexedDB (Dexie.js) first.
* When authenticated, changes sync to the server in the background.
* Reads are handled by useLiveQuery hooks in queries.ts.
* This store only exposes mutation actions that write to IndexedDB.
*/
import type { Skill, Activity, UserStats, SkillBranch } from '$lib/types';
import { calculateLevel, createDefaultSkill, createActivity, BRANCH_INFO } from '$lib/types';
import type { Skill, Activity } from '$lib/types';
import { calculateLevel, createDefaultSkill, createActivity } from '$lib/types';
import { SkillTreeEvents } from '@manacore/shared-utils/analytics';
import {
skillCollection,
@ -14,118 +14,9 @@ import {
type LocalSkill,
type LocalActivity,
} from '$lib/data/local-store';
import { achievementStore } from './achievements.svelte';
// Reactive state using Svelte 5 runes
let skills = $state<Skill[]>([]);
let activities = $state<Activity[]>([]);
let userStats = $state<UserStats>({
totalXp: 0,
totalSkills: 0,
highestLevel: 0,
streakDays: 0,
lastActivityDate: null,
});
let isLoading = $state(true);
let initialized = $state(false);
// ─── 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: [],
body: [],
creativity: [],
social: [],
practical: [],
mindset: [],
custom: [],
};
for (const skill of skills) {
grouped[skill.branch].push(skill);
}
return grouped;
});
const topSkills = $derived(() => {
return [...skills].sort((a, b) => b.totalXp - a.totalXp).slice(0, 5);
});
const recentActivities = $derived(() => {
return [...activities]
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
.slice(0, 10);
});
const branchStats = $derived(() => {
const stats: Record<SkillBranch, { count: number; totalXp: number; avgLevel: number }> =
{} as Record<SkillBranch, { count: number; totalXp: number; avgLevel: number }>;
for (const branch of Object.keys(BRANCH_INFO) as SkillBranch[]) {
const branchSkills = skills.filter((s) => s.branch === branch);
stats[branch] = {
count: branchSkills.length,
totalXp: branchSkills.reduce((sum, s) => sum + s.totalXp, 0),
avgLevel:
branchSkills.length > 0
? branchSkills.reduce((sum, s) => sum + s.level, 0) / branchSkills.length
: 0,
};
}
return stats;
});
// ─── Actions ─────────────────────────────────────────────────
async function initialize() {
if (initialized) return;
isLoading = true;
try {
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);
} finally {
isLoading = false;
}
}
async function addSkill(data: Partial<Skill>): Promise<Skill> {
const skill = createDefaultSkill(data);
const localSkill: LocalSkill = {
@ -141,16 +32,11 @@ async function addSkill(data: Partial<Skill>): Promise<Skill> {
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;
const localUpdates: Partial<LocalSkill> = {};
if (updates.name !== undefined) localUpdates.name = updates.name;
if (updates.description !== undefined) localUpdates.description = updates.description;
@ -160,9 +46,6 @@ async function updateSkill(id: string, updates: Partial<Skill>): Promise<void> {
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> {
@ -172,10 +55,7 @@ async function deleteSkill(id: string): Promise<void> {
await activityCollection.delete(a.id);
}
await skillCollection.delete(id);
skills = skills.filter((s) => s.id !== id);
activities = activities.filter((a) => a.skillId !== id);
SkillTreeEvents.skillDeleted();
recalculateStats();
}
async function addXp(
@ -184,10 +64,10 @@ async function addXp(
description: string,
duration?: number
): Promise<{ leveledUp: boolean; newLevel: number }> {
const index = skills.findIndex((s) => s.id === skillId);
if (index === -1) return { leveledUp: false, newLevel: 0 };
const existing = await skillCollection.getAll({ id: skillId });
const skill = existing.find((s) => s.id === skillId);
if (!skill) return { leveledUp: false, newLevel: 0 };
const skill = skills[index];
const newTotalXp = skill.totalXp + xp;
const newCurrentXp = skill.currentXp + xp;
const newLevel = calculateLevel(newTotalXp);
@ -210,124 +90,15 @@ async function addXp(
};
await activityCollection.insert(localActivity);
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];
SkillTreeEvents.xpAdded(xp, leveledUp);
recalculateStats();
return { leveledUp, newLevel };
}
function recalculateStats(): void {
const sortedActivities = [...activities].sort(
(a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
);
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: 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);
}
function getSkillActivities(skillId: string): Activity[] {
return activities.filter((a) => a.skillId === skillId);
}
async function reinitialize() {
initialized = false;
skills = [];
activities = [];
userStats = {
totalXp: 0,
totalSkills: 0,
highestLevel: 0,
streakDays: 0,
lastActivityDate: null,
};
await initialize();
}
// Export store
// Export store (write-only actions)
export const skillStore = {
get skills() {
return skills;
},
get activities() {
return activities;
},
get userStats() {
return userStats;
},
get isLoading() {
return isLoading;
},
get initialized() {
return initialized;
},
get skillsByBranch() {
return skillsByBranch;
},
get topSkills() {
return topSkills;
},
get recentActivities() {
return recentActivities;
},
get branchStats() {
return branchStats;
},
initialize,
reinitialize,
addSkill,
updateSkill,
deleteSkill,
addXp,
getSkill,
getSkillActivities,
};

View file

@ -2,9 +2,8 @@
import '../app.css';
import '$lib/i18n';
import { isLoading as i18nLoading, _ as t } from 'svelte-i18n';
import { skillStore } from '$lib/stores/skills.svelte';
import { achievementStore } from '$lib/stores/achievements.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { achievementStore } from '$lib/stores/achievements.svelte';
import { MiniOnboardingModal } from '@manacore/shared-app-onboarding';
import { skilltreeOnboarding } from '$lib/stores/app-onboarding.svelte';
import { SessionExpiredBanner, AuthGate } from '@manacore/shared-auth-ui';
@ -18,9 +17,8 @@
if (authStore.isAuthenticated) {
skilltreeStore.startSync(() => authStore.getValidToken());
}
// Load data from IndexedDB into reactive stores
await skillStore.initialize();
await achievementStore.initialize();
// Seed achievement definitions into IndexedDB if first run
await achievementStore.seedIfEmpty();
}
</script>

View file

@ -1,6 +1,16 @@
<script lang="ts">
import { skillStore } from '$lib/stores/skills.svelte';
import { achievementStore } from '$lib/stores/achievements.svelte';
import {
useAllSkills,
useAllActivities,
useAllAchievements,
filterByBranch,
getRecentActivities,
getSkillById,
computeUserStats,
} from '$lib/data/queries';
import { buildAchievementStatus, getAchievementStats } from '$lib/stores/achievements.svelte';
import { BRANCH_INFO } from '$lib/types';
import type { Skill, SkillBranch, AchievementUnlockResult } from '$lib/types';
import SkillCard from '$lib/components/SkillCard.svelte';
@ -22,6 +32,18 @@
Trophy,
} from '@manacore/shared-icons';
// Reactive live queries — auto-update when IndexedDB changes
const allSkills = useAllSkills();
const allActivities = useAllActivities();
const allAchievementsRaw = useAllAchievements();
// Derived values from live queries
const skills = $derived(allSkills.value);
const activities = $derived(allActivities.value);
const achievements = $derived(buildAchievementStatus(allAchievementsRaw.value));
const achievementStats = $derived(getAchievementStats(achievements));
const userStats = $derived(computeUserStats(skills, activities));
// Modal states
let showAddSkillModal = $state(false);
let showAddXpModal = $state(false);
@ -39,10 +61,7 @@
let showAchievementCelebration = $state(false);
let currentAchievementUnlock = $state<AchievementUnlockResult | null>(null);
const filteredSkills = $derived(() => {
if (selectedBranch === 'all') return skillStore.skills;
return skillStore.skills.filter((s) => s.branch === selectedBranch);
});
const filteredSkills = $derived(filterByBranch(skills, selectedBranch));
function openAddXpModal(skill: Skill) {
selectedSkill = skill;
@ -78,15 +97,13 @@
}
async function checkAchievementsLocal(lastActivityXp?: number) {
if (!achievementStore.useApi) {
await achievementStore.checkLocal({
skills: skillStore.skills,
activities: skillStore.activities,
userStats: skillStore.userStats,
lastActivityXp,
});
showNextAchievement();
}
await achievementStore.checkLocal({
skills,
activities,
userStats,
lastActivityXp,
});
showNextAchievement();
}
async function handleAddXp(xp: number, description: string, duration?: number) {
@ -158,11 +175,11 @@
title="Achievements"
>
<Trophy class="h-5 w-5" />
{#if achievementStore.stats().unlocked > 0}
{#if achievementStats.unlocked > 0}
<span
class="absolute -right-0.5 -top-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-yellow-500 text-[10px] font-bold text-gray-900"
>
{achievementStore.stats().unlocked}
{achievementStats.unlocked}
</span>
{/if}
</a>
@ -224,10 +241,10 @@
? 'bg-emerald-600 text-white'
: 'bg-gray-800 text-gray-300 hover:bg-gray-700'}"
>
Alle ({skillStore.skills.length})
Alle ({skills.length})
</button>
{#each Object.entries(BRANCH_INFO) as [branch, info]}
{@const count = skillStore.skills.filter((s) => s.branch === branch).length}
{@const count = skills.filter((s) => s.branch === branch).length}
{#if count > 0 || branch !== 'custom'}
<button
onclick={() => (selectedBranch = branch as SkillBranch)}
@ -244,7 +261,7 @@
</div>
<!-- Skills Grid -->
{#if filteredSkills().length === 0}
{#if filteredSkills.length === 0}
<div class="mt-16 text-center">
<div
class="mx-auto mb-6 flex h-24 w-24 items-center justify-center rounded-full bg-gray-800"
@ -263,7 +280,7 @@
</div>
{:else}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each filteredSkills() as skill (skill.id)}
{#each filteredSkills as skill (skill.id)}
<SkillCard
{skill}
onAddXp={() => openAddXpModal(skill)}
@ -275,15 +292,15 @@
{/if}
<!-- Recent Activity -->
{#if skillStore.recentActivities().length > 0}
{#if getRecentActivities(activities).length > 0}
<div class="mt-12">
<h2 class="mb-4 flex items-center gap-2 text-lg font-semibold text-white">
<Lightning class="h-5 w-5 text-yellow-500" />
Letzte Aktivitäten
</h2>
<div class="space-y-2">
{#each skillStore.recentActivities().slice(0, 5) as activity}
{@const skill = skillStore.getSkill(activity.skillId)}
{#each getRecentActivities(activities).slice(0, 5) as activity}
{@const skill = getSkillById(skills, activity.skillId)}
{#if skill}
<div class="flex items-center justify-between rounded-lg bg-gray-800/50 px-4 py-3">
<div class="flex items-center gap-3">

View file

@ -1,15 +1,26 @@
<script lang="ts">
import { achievementStore } from '$lib/stores/achievements.svelte';
import { useAllAchievements } from '$lib/data/queries';
import {
buildAchievementStatus,
getAchievementStats,
getCompletionPercentage,
} from '$lib/stores/achievements.svelte';
import { ACHIEVEMENT_CATEGORY_INFO, RARITY_INFO } from '$lib/types';
import type { AchievementCategory } from '$lib/types';
import AchievementCard from '$lib/components/AchievementCard.svelte';
import { ArrowLeft, Trophy, Star } from '@manacore/shared-icons';
// Reactive live query
const allAchievementsRaw = useAllAchievements();
const achievements = $derived(buildAchievementStatus(allAchievementsRaw.value));
const stats = $derived(getAchievementStats(achievements));
const completion = $derived(getCompletionPercentage(achievements));
let selectedCategory = $state<AchievementCategory | 'all'>('all');
let showOnlyUnlocked = $state(false);
const filteredAchievements = $derived(() => {
let list = achievementStore.achievements;
let list = achievements;
if (selectedCategory !== 'all') {
list = list.filter((a) => a.category === selectedCategory);
}
@ -46,7 +57,7 @@
<div class="flex items-center gap-2 rounded-full bg-yellow-500/10 px-4 py-2">
<Trophy class="h-4 w-4 text-yellow-400" />
<span class="font-semibold text-yellow-400">
{achievementStore.stats().unlocked} / {achievementStore.stats().total}
{stats.unlocked} / {stats.total}
</span>
</div>
</div>
@ -59,22 +70,18 @@
<div class="mb-8 rounded-xl border border-gray-700 bg-gray-800/50 p-6">
<div class="flex items-center justify-between mb-3">
<h2 class="text-lg font-semibold text-white">Fortschritt</h2>
<span class="text-2xl font-bold text-yellow-400"
>{achievementStore.completionPercentage()}%</span
>
<span class="text-2xl font-bold text-yellow-400">{completion}%</span>
</div>
<div class="h-3 overflow-hidden rounded-full bg-gray-700">
<div
class="h-full rounded-full bg-gradient-to-r from-yellow-500 to-yellow-400 transition-all duration-500"
style="width: {achievementStore.completionPercentage()}%"
style="width: {completion}%"
></div>
</div>
<div class="mt-3 flex flex-wrap gap-4 text-sm">
{#each Object.entries(RARITY_INFO) as [rarity, info]}
{@const count = achievementStore.achievements.filter(
(a) => a.rarity === rarity && a.unlocked
).length}
{@const total = achievementStore.achievements.filter((a) => a.rarity === rarity).length}
{@const count = achievements.filter((a) => a.rarity === rarity && a.unlocked).length}
{@const total = achievements.filter((a) => a.rarity === rarity).length}
<span class="flex items-center gap-1.5 {info.color}">
<Star class="h-3 w-3" />
{info.name}: {count}/{total}
@ -92,10 +99,10 @@
? 'bg-yellow-500 text-gray-900'
: 'bg-gray-800 text-gray-300 hover:bg-gray-700'}"
>
Alle ({achievementStore.achievements.length})
Alle ({achievements.length})
</button>
{#each categoryEntries as [category, info]}
{@const count = achievementStore.achievements.filter((a) => a.category === category).length}
{@const count = achievements.filter((a) => a.category === category).length}
<button
onclick={() => (selectedCategory = category)}
class="rounded-full px-4 py-2 text-sm font-medium transition-colors {selectedCategory ===

View file

@ -1,9 +1,13 @@
<script lang="ts">
import { skillStore } from '$lib/stores/skills.svelte';
import { useAllSkills } from '$lib/data/queries';
import { BRANCH_INFO, LEVEL_NAMES } from '$lib/types';
import type { SkillBranch } from '$lib/types';
import { ArrowLeft, Star } from '@manacore/shared-icons';
// Reactive live query
const allSkills = useAllSkills();
const skills = $derived(allSkills.value);
// Group skills by branch for radial layout
const branches = Object.keys(BRANCH_INFO) as SkillBranch[];
@ -74,7 +78,7 @@
</header>
<main class="p-4">
{#if skillStore.skills.length === 0}
{#if skills.length === 0}
<div class="mt-16 text-center">
<p class="text-gray-400">Noch keine Skills vorhanden. Erstelle zuerst einige Skills!</p>
<a
@ -88,7 +92,7 @@
<!-- Legend -->
<div class="mb-6 flex flex-wrap justify-center gap-4">
{#each Object.entries(BRANCH_INFO) as [branch, info]}
{@const count = skillStore.skills.filter((s) => s.branch === branch).length}
{@const count = skills.filter((s) => s.branch === branch).length}
{#if count > 0}
<div class="flex items-center gap-2 rounded-full bg-gray-800 px-3 py-1.5 text-sm">
<span class="h-3 w-3 rounded-full" style="background-color: {info.color}"></span>
@ -145,7 +149,7 @@
<!-- Branch lines and labels -->
{#each branches as branch, i}
{@const pos = getBranchPosition(i, branches.length)}
{@const branchSkills = skillStore.skills.filter((s) => s.branch === branch)}
{@const branchSkills = skills.filter((s) => s.branch === branch)}
{#if branchSkills.length > 0}
<!-- Line from center to branch -->
<line