mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
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:
parent
ba6dbf16c4
commit
bf4d9cb9aa
39 changed files with 1313 additions and 1379 deletions
|
|
@ -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>
|
||||
|
|
|
|||
186
apps/skilltree/apps/web/src/lib/data/queries.ts
Normal file
186
apps/skilltree/apps/web/src/lib/data/queries.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 ===
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue