diff --git a/apps/skilltree/CLAUDE.md b/apps/skilltree/CLAUDE.md new file mode 100644 index 000000000..a7d31ce8a --- /dev/null +++ b/apps/skilltree/CLAUDE.md @@ -0,0 +1,104 @@ +# SkillTree + +Gamified personal skill tracking app - like an RPG skill tree for real life. + +## Overview + +Track your skills, earn XP through activities, and level up your abilities across different life domains. + +## Tech Stack + +- **Web**: SvelteKit + Svelte 5 + Tailwind CSS +- **Storage**: IndexedDB (offline-first, no backend needed) +- **State**: Svelte 5 runes (`$state`, `$derived`) + +## Development + +```bash +# Start development server (port 5195) +pnpm dev:web + +# Or from monorepo root +pnpm --filter @skilltree/web dev +``` + +## Project Structure + +``` +apps/skilltree/ +├── apps/ +│ └── web/ # SvelteKit web app +│ ├── src/ +│ │ ├── lib/ +│ │ │ ├── components/ # UI components +│ │ │ ├── services/ # IndexedDB storage +│ │ │ ├── stores/ # Svelte 5 reactive stores +│ │ │ └── types/ # TypeScript types +│ │ └── routes/ # SvelteKit routes +│ └── static/ # Static assets +└── package.json +``` + +## Features + +### MVP (Current) + +- [x] Skill creation with name, description, and branch +- [x] Six skill branches: Intellect, Body, Creativity, Social, Practical, Mindset +- [x] XP system with 6 levels (0-5) +- [x] Activity logging with XP rewards +- [x] Stats overview (total XP, skills, highest level, streak) +- [x] Offline-first with IndexedDB +- [x] Branch filtering +- [x] Recent activities feed + +### Planned + +- [ ] Skill editing +- [ ] Skill tree visualization (graph view) +- [ ] Skill dependencies/prerequisites +- [ ] Achievements/badges +- [ ] Data export/import +- [ ] Cloud sync (optional) + +## Data Model + +### Skill +```typescript +interface Skill { + id: string; + name: string; + description: string; + branch: SkillBranch; + parentId: string | null; + icon: string; + color: string | null; + currentXp: number; + totalXp: number; + level: number; + createdAt: string; + updatedAt: string; +} +``` + +### Levels + +| Level | Name | XP Required | +|-------|---------------|-------------| +| 0 | Unbekannt | 0 | +| 1 | Anfänger | 100 | +| 2 | Fortgeschritten | 500 | +| 3 | Kompetent | 1,500 | +| 4 | Experte | 4,000 | +| 5 | Meister | 10,000 | + +## Branches + +| Branch | Icon | Color | Description | +|------------|-----------|---------|--------------------------------| +| Intellect | brain | blue | Knowledge, languages, science | +| Body | dumbbell | red | Fitness, sports, health | +| Creativity | palette | pink | Art, music, writing | +| Social | users | purple | Communication, leadership | +| Practical | wrench | orange | Crafts, cooking, tech | +| Mindset | heart | emerald | Meditation, focus, resilience | diff --git a/apps/skilltree/apps/web/package.json b/apps/skilltree/apps/web/package.json new file mode 100644 index 000000000..66774ff58 --- /dev/null +++ b/apps/skilltree/apps/web/package.json @@ -0,0 +1,36 @@ +{ + "name": "@skilltree/web", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "type-check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json" + }, + "devDependencies": { + "@sveltejs/adapter-node": "^5.0.0", + "@sveltejs/kit": "^2.0.0", + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@tailwindcss/vite": "^4.1.7", + "@types/node": "^20.0.0", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "tailwindcss": "^4.1.7", + "tslib": "^2.4.1", + "typescript": "^5.0.0", + "vite": "^6.0.0" + }, + "dependencies": { + "@manacore/shared-tailwind": "workspace:*", + "@manacore/shared-theme": "workspace:*", + "@manacore/shared-utils": "workspace:*", + "idb": "^8.0.0", + "lucide-svelte": "^0.556.0", + "uuid": "^11.0.0" + }, + "type": "module" +} diff --git a/apps/skilltree/apps/web/src/app.css b/apps/skilltree/apps/web/src/app.css new file mode 100644 index 000000000..48822d4bf --- /dev/null +++ b/apps/skilltree/apps/web/src/app.css @@ -0,0 +1,141 @@ +@import 'tailwindcss'; +@import '@manacore/shared-tailwind/themes.css'; + +:root { + /* SkillTree - Emerald/Green Theme (Growth & Progress) */ + --color-primary: #10b981; + --color-primary-hover: #059669; + --color-primary-light: #34d399; + --color-primary-dark: #047857; + + --color-secondary: #ecfdf5; + --color-secondary-hover: #d1fae5; + + --color-accent: #6ee7b7; + --color-accent-hover: #34d399; + + /* XP & Level Colors */ + --color-xp: #fbbf24; + --color-xp-glow: rgba(251, 191, 36, 0.4); + --color-level-up: #f59e0b; + + /* Skill Levels */ + --color-level-0: #6b7280; + --color-level-1: #3b82f6; + --color-level-2: #8b5cf6; + --color-level-3: #ec4899; + --color-level-4: #f97316; + --color-level-5: #fbbf24; + + /* Branch Colors */ + --color-branch-intellect: #3b82f6; + --color-branch-body: #ef4444; + --color-branch-creativity: #ec4899; + --color-branch-social: #8b5cf6; + --color-branch-practical: #f97316; + --color-branch-mindset: #10b981; +} + +/* Dark mode overrides */ +:root.dark { + --color-secondary: #064e3b; + --color-secondary-hover: #065f46; +} + +/* Skill card */ +.skill-card { + transition: + transform 0.2s ease, + box-shadow 0.2s ease, + border-color 0.2s ease; +} + +.skill-card:hover { + transform: translateY(-2px); + box-shadow: 0 8px 25px -5px rgba(16, 185, 129, 0.2); +} + +/* XP Progress Bar */ +.xp-bar { + background: linear-gradient(90deg, var(--color-primary) 0%, var(--color-xp) 100%); + transition: width 0.5s ease; +} + +.xp-bar-container { + background: rgba(107, 114, 128, 0.2); + overflow: hidden; +} + +/* Level badge glow animation */ +@keyframes level-glow { + 0%, + 100% { + box-shadow: 0 0 5px var(--color-xp-glow); + } + 50% { + box-shadow: 0 0 20px var(--color-xp-glow); + } +} + +.level-badge-glow { + animation: level-glow 2s ease-in-out infinite; +} + +/* Level up animation */ +@keyframes level-up { + 0% { + transform: scale(1); + } + 50% { + transform: scale(1.2); + } + 100% { + transform: scale(1); + } +} + +.level-up-animation { + animation: level-up 0.5s ease-in-out; +} + +/* Branch indicator */ +.branch-indicator { + width: 4px; + border-radius: 2px; +} + +/* Skill tree node */ +.tree-node { + transition: + transform 0.2s ease, + opacity 0.2s ease; +} + +.tree-node:hover { + transform: scale(1.05); +} + +.tree-node.locked { + opacity: 0.5; + filter: grayscale(0.8); +} + +/* Progress ring */ +.progress-ring { + transition: stroke-dashoffset 0.5s ease; +} + +/* Add XP button pulse */ +@keyframes pulse-xp { + 0%, + 100% { + box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.4); + } + 50% { + box-shadow: 0 0 0 10px rgba(16, 185, 129, 0); + } +} + +.pulse-xp:hover { + animation: pulse-xp 1.5s infinite; +} diff --git a/apps/skilltree/apps/web/src/app.d.ts b/apps/skilltree/apps/web/src/app.d.ts new file mode 100644 index 000000000..da08e6da5 --- /dev/null +++ b/apps/skilltree/apps/web/src/app.d.ts @@ -0,0 +1,13 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/apps/skilltree/apps/web/src/app.html b/apps/skilltree/apps/web/src/app.html new file mode 100644 index 000000000..d8bfadbb4 --- /dev/null +++ b/apps/skilltree/apps/web/src/app.html @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/apps/skilltree/apps/web/src/lib/components/AddSkillModal.svelte b/apps/skilltree/apps/web/src/lib/components/AddSkillModal.svelte new file mode 100644 index 000000000..d03d15fe5 --- /dev/null +++ b/apps/skilltree/apps/web/src/lib/components/AddSkillModal.svelte @@ -0,0 +1,132 @@ + + + diff --git a/apps/skilltree/apps/web/src/lib/components/AddXpModal.svelte b/apps/skilltree/apps/web/src/lib/components/AddXpModal.svelte new file mode 100644 index 000000000..36ce4d803 --- /dev/null +++ b/apps/skilltree/apps/web/src/lib/components/AddXpModal.svelte @@ -0,0 +1,172 @@ + + + diff --git a/apps/skilltree/apps/web/src/lib/components/SkillCard.svelte b/apps/skilltree/apps/web/src/lib/components/SkillCard.svelte new file mode 100644 index 000000000..e0840e1e6 --- /dev/null +++ b/apps/skilltree/apps/web/src/lib/components/SkillCard.svelte @@ -0,0 +1,102 @@ + + +
+ +
+ + +
+
+

{skill.name}

+

{branchInfo.name}

+
+
+ {#each Array(skill.level) as _, i} + + {/each} + {#each Array(5 - skill.level) as _, i} + + {/each} +
+
+ + +
+ + Lvl {skill.level} - {levelName} + +
+ + +
+
+ XP + + {skill.totalXp.toLocaleString()} + {#if !isMaxLevel} + / {nextLevelXp.toLocaleString()} + {/if} + +
+
+
+
+
+ + + {#if skill.description} +

{skill.description}

+ {/if} + + +
+ + +
+
diff --git a/apps/skilltree/apps/web/src/lib/components/StatsOverview.svelte b/apps/skilltree/apps/web/src/lib/components/StatsOverview.svelte new file mode 100644 index 000000000..52ac64441 --- /dev/null +++ b/apps/skilltree/apps/web/src/lib/components/StatsOverview.svelte @@ -0,0 +1,66 @@ + + +
+ +
+
+
+ +
+
+

Gesamt-XP

+

+ {skillStore.userStats.totalXp.toLocaleString()} +

+
+
+
+ + +
+
+
+ +
+
+

Skills

+

+ {skillStore.userStats.totalSkills} +

+
+
+
+ + +
+
+
+ +
+
+

Höchstes Level

+

+ {skillStore.userStats.highestLevel} +

+
+
+
+ + +
+
+
+ +
+
+

Streak

+

+ {skillStore.userStats.streakDays} Tage +

+
+
+
+
diff --git a/apps/skilltree/apps/web/src/lib/services/storage.ts b/apps/skilltree/apps/web/src/lib/services/storage.ts new file mode 100644 index 000000000..115a0e337 --- /dev/null +++ b/apps/skilltree/apps/web/src/lib/services/storage.ts @@ -0,0 +1,232 @@ +import { openDB, type DBSchema, type IDBPDatabase } from 'idb'; +import type { Skill, Activity, UserStats } from '$lib/types'; + +interface SkillTreeDB extends DBSchema { + 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; + }; +} + +const DB_NAME = 'skilltree-db'; +const DB_VERSION = 1; + +let dbPromise: Promise> | null = null; + +function getDB(): Promise> { + if (!dbPromise) { + dbPromise = openDB(DB_NAME, DB_VERSION, { + upgrade(db) { + // Skills store + if (!db.objectStoreNames.contains('skills')) { + const skillStore = db.createObjectStore('skills', { keyPath: 'id' }); + skillStore.createIndex('by-branch', 'branch'); + skillStore.createIndex('by-parent', 'parentId'); + skillStore.createIndex('by-level', 'level'); + } + + // Activities store + if (!db.objectStoreNames.contains('activities')) { + const activityStore = db.createObjectStore('activities', { keyPath: 'id' }); + activityStore.createIndex('by-skill', 'skillId'); + activityStore.createIndex('by-timestamp', 'timestamp'); + } + + // Stats store + if (!db.objectStoreNames.contains('stats')) { + db.createObjectStore('stats'); + } + }, + }); + } + return dbPromise; +} + +// Skills CRUD +export async function getAllSkills(): Promise { + const db = await getDB(); + return db.getAll('skills'); +} + +export async function getSkillById(id: string): Promise { + const db = await getDB(); + return db.get('skills', id); +} + +export async function getSkillsByBranch(branch: string): Promise { + const db = await getDB(); + return db.getAllFromIndex('skills', 'by-branch', branch); +} + +export async function getChildSkills(parentId: string): Promise { + const db = await getDB(); + return db.getAllFromIndex('skills', 'by-parent', parentId); +} + +export async function saveSkill(skill: Skill): Promise { + const db = await getDB(); + skill.updatedAt = new Date().toISOString(); + await db.put('skills', skill); +} + +export async function deleteSkill(id: string): Promise { + const db = await getDB(); + // Delete skill and all its activities + const activities = await db.getAllFromIndex('activities', 'by-skill', id); + const tx = db.transaction(['skills', 'activities'], 'readwrite'); + await Promise.all([ + tx.objectStore('skills').delete(id), + ...activities.map((a) => tx.objectStore('activities').delete(a.id)), + ]); + await tx.done; +} + +// Activities CRUD +export async function getAllActivities(): Promise { + const db = await getDB(); + return db.getAll('activities'); +} + +export async function getActivitiesBySkill(skillId: string): Promise { + const db = await getDB(); + return db.getAllFromIndex('activities', 'by-skill', skillId); +} + +export async function getRecentActivities(limit = 10): Promise { + const db = await getDB(); + const all = await db.getAllFromIndex('activities', 'by-timestamp'); + return all.reverse().slice(0, limit); +} + +export async function saveActivity(activity: Activity): Promise { + const db = await getDB(); + await db.put('activities', activity); +} + +export async function deleteActivity(id: string): Promise { + const db = await getDB(); + await db.delete('activities', id); +} + +// User Stats +export async function getUserStats(): Promise { + const db = await getDB(); + const stats = await db.get('stats', 'user-stats'); + return ( + stats ?? { + totalXp: 0, + totalSkills: 0, + highestLevel: 0, + streakDays: 0, + lastActivityDate: null, + } + ); +} + +export async function saveUserStats(stats: UserStats): Promise { + const db = await getDB(); + await db.put('stats', stats, 'user-stats'); +} + +// Utility: Recalculate stats from all skills +export async function recalculateStats(): Promise { + const skills = await getAllSkills(); + const activities = await getAllActivities(); + + const stats: UserStats = { + totalXp: skills.reduce((sum, s) => sum + s.totalXp, 0), + totalSkills: skills.length, + highestLevel: skills.reduce((max, s) => Math.max(max, s.level), 0), + streakDays: calculateStreak(activities), + lastActivityDate: activities.length > 0 ? activities[activities.length - 1].timestamp : null, + }; + + await saveUserStats(stats); + return stats; +} + +function calculateStreak(activities: Activity[]): number { + if (activities.length === 0) return 0; + + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const sortedDates = activities + .map((a) => { + const d = new Date(a.timestamp); + d.setHours(0, 0, 0, 0); + return d.getTime(); + }) + .filter((v, i, a) => a.indexOf(v) === i) // unique dates + .sort((a, b) => b - a); // newest first + + let streak = 0; + let expectedDate = today.getTime(); + + for (const date of sortedDates) { + if (date === expectedDate || date === expectedDate - 86400000) { + streak++; + expectedDate = date - 86400000; + } else if (date < expectedDate - 86400000) { + break; + } + } + + return streak; +} + +// Export all data (for backup) +export async function exportData(): Promise<{ + skills: Skill[]; + activities: Activity[]; + stats: UserStats; +}> { + const [skills, activities, stats] = await Promise.all([ + getAllSkills(), + getAllActivities(), + getUserStats(), + ]); + return { skills, activities, stats }; +} + +// Import data (restore backup) +export async function importData(data: { + skills: Skill[]; + activities: Activity[]; + stats: UserStats; +}): Promise { + const db = await getDB(); + const tx = db.transaction(['skills', 'activities', 'stats'], 'readwrite'); + + // Clear existing data + await tx.objectStore('skills').clear(); + await tx.objectStore('activities').clear(); + + // Import new data + for (const skill of data.skills) { + await tx.objectStore('skills').put(skill); + } + for (const activity of data.activities) { + await tx.objectStore('activities').put(activity); + } + await tx.objectStore('stats').put(data.stats, 'user-stats'); + + await tx.done; +} diff --git a/apps/skilltree/apps/web/src/lib/stores/skills.svelte.ts b/apps/skilltree/apps/web/src/lib/stores/skills.svelte.ts new file mode 100644 index 000000000..c5a5c0e96 --- /dev/null +++ b/apps/skilltree/apps/web/src/lib/stores/skills.svelte.ts @@ -0,0 +1,176 @@ +import type { Skill, Activity, UserStats, SkillBranch } from '$lib/types'; +import { + calculateLevel, + createDefaultSkill, + createActivity, + BRANCH_INFO, +} from '$lib/types'; +import * as storage from '$lib/services/storage'; + +// Reactive state using Svelte 5 runes +let skills = $state([]); +let activities = $state([]); +let userStats = $state({ + totalXp: 0, + totalSkills: 0, + highestLevel: 0, + streakDays: 0, + lastActivityDate: null, +}); +let isLoading = $state(true); +let initialized = $state(false); + +// Derived values +const skillsByBranch = $derived(() => { + const grouped: Record = { + 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 = {} as any; + 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 [loadedSkills, loadedActivities, loadedStats] = await Promise.all([ + storage.getAllSkills(), + storage.getAllActivities(), + storage.getUserStats(), + ]); + skills = loadedSkills; + activities = loadedActivities; + userStats = loadedStats; + initialized = true; + } catch (error) { + console.error('Failed to initialize skills store:', error); + } finally { + isLoading = false; + } +} + +async function addSkill(data: Partial): Promise { + const skill = createDefaultSkill(data); + await storage.saveSkill(skill); + skills = [...skills, skill]; + await updateStats(); + return skill; +} + +async function updateSkill(id: string, updates: Partial): Promise { + const index = skills.findIndex((s) => s.id === id); + if (index === -1) return; + + 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(); +} + +async function deleteSkill(id: string): Promise { + await storage.deleteSkill(id); + skills = skills.filter((s) => s.id !== id); + activities = activities.filter((a) => a.skillId !== id); + await updateStats(); +} + +async function addXp(skillId: string, xp: number, 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 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(), + }; + + const activity = createActivity(skillId, xp, description, duration); + + await Promise.all([ + storage.saveSkill(updatedSkill), + storage.saveActivity(activity), + ]); + + skills = [...skills.slice(0, index), updatedSkill, ...skills.slice(index + 1)]; + activities = [...activities, activity]; + await updateStats(); + + return { leveledUp, newLevel }; +} + +async function updateStats(): Promise { + userStats = await storage.recalculateStats(); +} + +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); +} + +// Export store as object with getters for reactive access +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, + addSkill, + updateSkill, + deleteSkill, + addXp, + getSkill, + getSkillActivities, +}; diff --git a/apps/skilltree/apps/web/src/lib/types/index.ts b/apps/skilltree/apps/web/src/lib/types/index.ts new file mode 100644 index 000000000..43079c9ab --- /dev/null +++ b/apps/skilltree/apps/web/src/lib/types/index.ts @@ -0,0 +1,164 @@ +// Skill Tree Types + +export type SkillBranch = + | 'intellect' + | 'body' + | 'creativity' + | 'social' + | 'practical' + | 'mindset' + | 'custom'; + +export interface Skill { + id: string; + name: string; + description: string; + branch: SkillBranch; + parentId: string | null; + icon: string; + color: string | null; + currentXp: number; + totalXp: number; + level: number; + createdAt: string; + updatedAt: string; +} + +export interface Activity { + id: string; + skillId: string; + xpEarned: number; + description: string; + duration: number | null; // minutes + timestamp: string; +} + +export interface UserStats { + totalXp: number; + totalSkills: number; + highestLevel: number; + streakDays: number; + lastActivityDate: string | null; +} + +// Level thresholds (XP needed for each level) +export const LEVEL_THRESHOLDS = [0, 100, 500, 1500, 4000, 10000] as const; + +export const LEVEL_NAMES = [ + 'Unbekannt', + 'Anfänger', + 'Fortgeschritten', + 'Kompetent', + 'Experte', + 'Meister', +] as const; + +export const BRANCH_INFO: Record< + SkillBranch, + { name: string; icon: string; color: string; description: string } +> = { + intellect: { + name: 'Intellekt', + icon: 'brain', + color: 'var(--color-branch-intellect)', + description: 'Wissen, Sprachen, Wissenschaft', + }, + body: { + name: 'Körper', + icon: 'dumbbell', + color: 'var(--color-branch-body)', + description: 'Fitness, Sport, Gesundheit', + }, + creativity: { + name: 'Kreativität', + icon: 'palette', + color: 'var(--color-branch-creativity)', + description: 'Kunst, Musik, Schreiben', + }, + social: { + name: 'Sozial', + icon: 'users', + color: 'var(--color-branch-social)', + description: 'Kommunikation, Leadership, Empathie', + }, + practical: { + name: 'Praktisch', + icon: 'wrench', + color: 'var(--color-branch-practical)', + description: 'Handwerk, Kochen, Technologie', + }, + mindset: { + name: 'Mindset', + icon: 'heart', + color: 'var(--color-branch-mindset)', + description: 'Meditation, Fokus, Resilienz', + }, + custom: { + name: 'Eigene', + icon: 'star', + color: 'var(--color-primary)', + description: 'Eigene Kategorien', + }, +}; + +// Helper functions +export function calculateLevel(xp: number): number { + for (let i = LEVEL_THRESHOLDS.length - 1; i >= 0; i--) { + if (xp >= LEVEL_THRESHOLDS[i]) { + return i; + } + } + return 0; +} + +export function xpForNextLevel(currentLevel: number): number { + if (currentLevel >= LEVEL_THRESHOLDS.length - 1) { + return Infinity; + } + return LEVEL_THRESHOLDS[currentLevel + 1]; +} + +export function xpProgress(xp: number, level: number): number { + if (level >= LEVEL_THRESHOLDS.length - 1) { + return 100; + } + const currentThreshold = LEVEL_THRESHOLDS[level]; + const nextThreshold = LEVEL_THRESHOLDS[level + 1]; + const progress = ((xp - currentThreshold) / (nextThreshold - currentThreshold)) * 100; + return Math.min(100, Math.max(0, progress)); +} + +export function createDefaultSkill(partial: Partial = {}): Skill { + const now = new Date().toISOString(); + return { + id: crypto.randomUUID(), + name: '', + description: '', + branch: 'custom', + parentId: null, + icon: 'star', + color: null, + currentXp: 0, + totalXp: 0, + level: 0, + createdAt: now, + updatedAt: now, + ...partial, + }; +} + +export function createActivity( + skillId: string, + xpEarned: number, + description: string, + duration?: number +): Activity { + return { + id: crypto.randomUUID(), + skillId, + xpEarned, + description, + duration: duration ?? null, + timestamp: new Date().toISOString(), + }; +} diff --git a/apps/skilltree/apps/web/src/routes/+layout.svelte b/apps/skilltree/apps/web/src/routes/+layout.svelte new file mode 100644 index 000000000..a4e23339c --- /dev/null +++ b/apps/skilltree/apps/web/src/routes/+layout.svelte @@ -0,0 +1,32 @@ + + + + SkillTree - Level Up Your Life + + + +{#if loading} +
+
+
🌳
+
Loading SkillTree...
+
+
+{:else} +
+ {@render children()} +
+{/if} diff --git a/apps/skilltree/apps/web/src/routes/+page.svelte b/apps/skilltree/apps/web/src/routes/+page.svelte new file mode 100644 index 000000000..5c8cdeadb --- /dev/null +++ b/apps/skilltree/apps/web/src/routes/+page.svelte @@ -0,0 +1,178 @@ + + +
+ +
+
+
+
+ +

SkillTree

+
+ +
+
+
+ +
+ + + + +
+
+ + {#each Object.entries(BRANCH_INFO) as [branch, info]} + {@const count = skillStore.skills.filter((s) => s.branch === branch).length} + {#if count > 0 || branch !== 'custom'} + + {/if} + {/each} +
+
+ + + {#if filteredSkills().length === 0} +
+
+ +
+

Noch keine Skills

+

+ Füge deinen ersten Skill hinzu und beginne dein Abenteuer! +

+ +
+ {:else} +
+ {#each filteredSkills() as skill (skill.id)} + openAddXpModal(skill)} + onEdit={() => {}} + onDelete={() => skillStore.deleteSkill(skill.id)} + /> + {/each} +
+ {/if} + + + {#if skillStore.recentActivities().length > 0} +
+

+ + Letzte Aktivitäten +

+
+ {#each skillStore.recentActivities().slice(0, 5) as activity} + {@const skill = skillStore.getSkill(activity.skillId)} + {#if skill} +
+
+
+ +{activity.xpEarned} +
+
+ {skill.name} + - {activity.description} +
+
+ + {new Date(activity.timestamp).toLocaleDateString('de-DE')} + +
+ {/if} + {/each} +
+
+ {/if} +
+
+ + +{#if showAddSkillModal} + (showAddSkillModal = false)} + onSave={async (skill) => { + await skillStore.addSkill(skill); + showAddSkillModal = false; + }} + /> +{/if} + +{#if showAddXpModal && selectedSkillForXp} + { + if (selectedSkillForXp) { + const result = await skillStore.addXp(selectedSkillForXp.id, xp, description, duration); + if (result.leveledUp) { + // Could show a level-up celebration here + } + } + closeAddXpModal(); + }} + /> +{/if} diff --git a/apps/skilltree/apps/web/static/manifest.json b/apps/skilltree/apps/web/static/manifest.json new file mode 100644 index 000000000..d7d1624a9 --- /dev/null +++ b/apps/skilltree/apps/web/static/manifest.json @@ -0,0 +1,21 @@ +{ + "name": "SkillTree", + "short_name": "SkillTree", + "description": "Track your skills like a game. Level up in real life.", + "start_url": "/", + "display": "standalone", + "background_color": "#111827", + "theme_color": "#10b981", + "icons": [ + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} diff --git a/apps/skilltree/apps/web/svelte.config.js b/apps/skilltree/apps/web/svelte.config.js new file mode 100644 index 000000000..a7a917e4c --- /dev/null +++ b/apps/skilltree/apps/web/svelte.config.js @@ -0,0 +1,14 @@ +import adapter from '@sveltejs/adapter-node'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + preprocess: vitePreprocess(), + kit: { + adapter: adapter({ + out: 'build', + }), + }, +}; + +export default config; diff --git a/apps/skilltree/apps/web/tsconfig.json b/apps/skilltree/apps/web/tsconfig.json new file mode 100644 index 000000000..a8f10c8e3 --- /dev/null +++ b/apps/skilltree/apps/web/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } +} diff --git a/apps/skilltree/apps/web/vite.config.ts b/apps/skilltree/apps/web/vite.config.ts new file mode 100644 index 000000000..8b51fcdc6 --- /dev/null +++ b/apps/skilltree/apps/web/vite.config.ts @@ -0,0 +1,17 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import tailwindcss from '@tailwindcss/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [tailwindcss(), sveltekit()], + server: { + port: 5195, + strictPort: true, + }, + ssr: { + noExternal: ['@manacore/shared-tailwind', '@manacore/shared-theme'], + }, + optimizeDeps: { + exclude: ['@manacore/shared-tailwind', '@manacore/shared-theme'], + }, +}); diff --git a/apps/skilltree/package.json b/apps/skilltree/package.json new file mode 100644 index 000000000..30cf3c910 --- /dev/null +++ b/apps/skilltree/package.json @@ -0,0 +1,14 @@ +{ + "name": "skilltree", + "version": "1.0.0", + "private": true, + "description": "SkillTree - Gamified Personal Skill Tracking", + "scripts": { + "dev": "pnpm run --filter=@skilltree/* --parallel dev", + "dev:web": "pnpm --filter @skilltree/web dev" + }, + "devDependencies": { + "typescript": "^5.9.3" + }, + "packageManager": "pnpm@9.15.0" +}