From 12ad8e83d5ed190437411af210619af6ccb06977 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Jan 2026 20:32:05 +0000 Subject: [PATCH 1/4] feat(skilltree): add SkillTree MVP - gamified skill tracking app - SvelteKit web app with Svelte 5 runes - IndexedDB storage for offline-first experience - 6 skill branches: Intellect, Body, Creativity, Social, Practical, Mindset - XP system with 6 levels (Unbekannt -> Meister) - Activity logging with timestamps - Stats overview (total XP, skills, streak) - Branch filtering and recent activities feed https://claude.ai/code/session_015XCsTDS9aLZ64Zin4HU6ex --- apps/skilltree/CLAUDE.md | 104 ++++++++ apps/skilltree/apps/web/package.json | 36 +++ apps/skilltree/apps/web/src/app.css | 141 +++++++++++ apps/skilltree/apps/web/src/app.d.ts | 13 + apps/skilltree/apps/web/src/app.html | 27 ++ .../src/lib/components/AddSkillModal.svelte | 132 ++++++++++ .../web/src/lib/components/AddXpModal.svelte | 172 +++++++++++++ .../web/src/lib/components/SkillCard.svelte | 102 ++++++++ .../src/lib/components/StatsOverview.svelte | 66 +++++ .../apps/web/src/lib/services/storage.ts | 232 ++++++++++++++++++ .../apps/web/src/lib/stores/skills.svelte.ts | 176 +++++++++++++ .../skilltree/apps/web/src/lib/types/index.ts | 164 +++++++++++++ .../apps/web/src/routes/+layout.svelte | 32 +++ .../apps/web/src/routes/+page.svelte | 178 ++++++++++++++ apps/skilltree/apps/web/static/manifest.json | 21 ++ apps/skilltree/apps/web/svelte.config.js | 14 ++ apps/skilltree/apps/web/tsconfig.json | 14 ++ apps/skilltree/apps/web/vite.config.ts | 17 ++ apps/skilltree/package.json | 14 ++ 19 files changed, 1655 insertions(+) create mode 100644 apps/skilltree/CLAUDE.md create mode 100644 apps/skilltree/apps/web/package.json create mode 100644 apps/skilltree/apps/web/src/app.css create mode 100644 apps/skilltree/apps/web/src/app.d.ts create mode 100644 apps/skilltree/apps/web/src/app.html create mode 100644 apps/skilltree/apps/web/src/lib/components/AddSkillModal.svelte create mode 100644 apps/skilltree/apps/web/src/lib/components/AddXpModal.svelte create mode 100644 apps/skilltree/apps/web/src/lib/components/SkillCard.svelte create mode 100644 apps/skilltree/apps/web/src/lib/components/StatsOverview.svelte create mode 100644 apps/skilltree/apps/web/src/lib/services/storage.ts create mode 100644 apps/skilltree/apps/web/src/lib/stores/skills.svelte.ts create mode 100644 apps/skilltree/apps/web/src/lib/types/index.ts create mode 100644 apps/skilltree/apps/web/src/routes/+layout.svelte create mode 100644 apps/skilltree/apps/web/src/routes/+page.svelte create mode 100644 apps/skilltree/apps/web/static/manifest.json create mode 100644 apps/skilltree/apps/web/svelte.config.js create mode 100644 apps/skilltree/apps/web/tsconfig.json create mode 100644 apps/skilltree/apps/web/vite.config.ts create mode 100644 apps/skilltree/package.json 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" +} From 5b291c1a17534633a5b1897f7f752024bbc15966 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Jan 2026 20:38:49 +0000 Subject: [PATCH 2/4] feat(skilltree): add edit, level-up celebration, templates, and tree view - Edit skill modal with delete confirmation - Animated level-up celebration with particles - Skill templates for quick onboarding (Web Dev, Fitness, Languages, etc.) - Radial skill tree visualization (/tree) - Export/import functionality for data backup - Tree view link in header https://claude.ai/code/session_015XCsTDS9aLZ64Zin4HU6ex --- .../src/lib/components/EditSkillModal.svelte | 196 ++++++++++++++ .../lib/components/LevelUpCelebration.svelte | 171 +++++++++++++ .../web/src/lib/components/SkillCard.svelte | 7 + .../src/lib/components/SkillTemplates.svelte | 211 +++++++++++++++ .../apps/web/src/routes/+page.svelte | 185 ++++++++++++-- .../apps/web/src/routes/tree/+page.svelte | 241 ++++++++++++++++++ 6 files changed, 985 insertions(+), 26 deletions(-) create mode 100644 apps/skilltree/apps/web/src/lib/components/EditSkillModal.svelte create mode 100644 apps/skilltree/apps/web/src/lib/components/LevelUpCelebration.svelte create mode 100644 apps/skilltree/apps/web/src/lib/components/SkillTemplates.svelte create mode 100644 apps/skilltree/apps/web/src/routes/tree/+page.svelte diff --git a/apps/skilltree/apps/web/src/lib/components/EditSkillModal.svelte b/apps/skilltree/apps/web/src/lib/components/EditSkillModal.svelte new file mode 100644 index 000000000..ce137d615 --- /dev/null +++ b/apps/skilltree/apps/web/src/lib/components/EditSkillModal.svelte @@ -0,0 +1,196 @@ + + + diff --git a/apps/skilltree/apps/web/src/lib/components/LevelUpCelebration.svelte b/apps/skilltree/apps/web/src/lib/components/LevelUpCelebration.svelte new file mode 100644 index 000000000..df79c4c41 --- /dev/null +++ b/apps/skilltree/apps/web/src/lib/components/LevelUpCelebration.svelte @@ -0,0 +1,171 @@ + + + + + diff --git a/apps/skilltree/apps/web/src/lib/components/SkillCard.svelte b/apps/skilltree/apps/web/src/lib/components/SkillCard.svelte index e0840e1e6..4e824e5a2 100644 --- a/apps/skilltree/apps/web/src/lib/components/SkillCard.svelte +++ b/apps/skilltree/apps/web/src/lib/components/SkillCard.svelte @@ -91,6 +91,13 @@ XP hinzufügen + + + +

+ Starte schnell mit vorgefertigten Skill-Sets. Wähle eine Vorlage und füge einzelne Skills oder alle auf einmal hinzu. +

+ + +
+ {#each Object.entries(templates) as [name, skills]} +
+ + + + {selectedTemplate === name ? '−' : '+'} + +
+ + + + {#if selectedTemplate === name} +
+ {#each skills as skill} + {@const isAdded = addedSkills.has(skill.name)} +
+
+ +
+ {skill.name} + - {skill.description} +
+
+ +
+ {/each} +
+ {/if} +
+ {/each} + + + +
+ +
+ + diff --git a/apps/skilltree/apps/web/src/routes/+page.svelte b/apps/skilltree/apps/web/src/routes/+page.svelte index 5c8cdeadb..7275bf2db 100644 --- a/apps/skilltree/apps/web/src/routes/+page.svelte +++ b/apps/skilltree/apps/web/src/routes/+page.svelte @@ -1,37 +1,110 @@ @@ -44,13 +117,47 @@

SkillTree

- +
+ + + + + + + + + + + +
@@ -110,7 +217,7 @@ openAddXpModal(skill)} - onEdit={() => {}} + onEdit={() => openEditModal(skill)} onDelete={() => skillStore.deleteSkill(skill.id)} /> {/each} @@ -130,7 +237,7 @@ {#if skill}
-
+
+{activity.xpEarned}
@@ -161,18 +268,44 @@ /> {/if} -{#if showAddXpModal && selectedSkillForXp} +{#if showAddXpModal && selectedSkill} { - if (selectedSkillForXp) { - const result = await skillStore.addXp(selectedSkillForXp.id, xp, description, duration); - if (result.leveledUp) { - // Could show a level-up celebration here - } + skill={selectedSkill} + onClose={closeModals} + onSave={handleAddXp} + /> +{/if} + +{#if showEditSkillModal && selectedSkill} + { + if (selectedSkill) { + await skillStore.updateSkill(selectedSkill.id, updates); + } + }} + onDelete={() => { + if (selectedSkill) { + skillStore.deleteSkill(selectedSkill.id); } - closeAddXpModal(); + }} + /> +{/if} + +{#if showLevelUp} + (showLevelUp = false)} + /> +{/if} + +{#if showTemplatesModal} + (showTemplatesModal = false)} + onAddSkill={async (skill) => { + await skillStore.addSkill(skill); }} /> {/if} diff --git a/apps/skilltree/apps/web/src/routes/tree/+page.svelte b/apps/skilltree/apps/web/src/routes/tree/+page.svelte new file mode 100644 index 000000000..955ce25bc --- /dev/null +++ b/apps/skilltree/apps/web/src/routes/tree/+page.svelte @@ -0,0 +1,241 @@ + + + + Skill Tree View - SkillTree + + +
+ +
+
+
+ + + Zurück + +

Skill Tree Visualisierung

+
+
+
+ +
+ {#if skillStore.skills.length === 0} +
+

Noch keine Skills vorhanden. Erstelle zuerst einige Skills!

+ + Skills erstellen + +
+ {:else} + +
+ {#each Object.entries(BRANCH_INFO) as [branch, info]} + {@const count = skillStore.skills.filter((s) => s.branch === branch).length} + {#if count > 0} +
+ + {info.name} ({count}) +
+ {/if} + {/each} +
+ + +
+ + + + + + + + + + + + YOU + + + + {#each branches as branch, i} + {@const pos = getBranchPosition(i, branches.length)} + {@const branchSkills = skillStore.skills.filter((s) => s.branch === branch)} + {#if branchSkills.length > 0} + + + + + + {BRANCH_INFO[branch].name} + + + + {#each branchSkills as skill, j} + {@const skillPos = getSkillPosition(i, j, branchSkills.length, branches.length)} + {@const size = getNodeSize(skill.level)} + + + + + + + + {#if skill.level >= 4} + + {/if} + + + + + + + {skill.level} + + + + {skill.name} (Level {skill.level} - {skill.totalXp} XP) + + + + + {skill.name.length > 12 ? skill.name.slice(0, 12) + '...' : skill.name} + + {/each} + {/if} + {/each} + +
+ + +
+ {#each LEVEL_NAMES as name, level} +
+
+ {level} +
+ {name} +
+ {/each} +
+ {/if} +
+
+ + From 7a0b26eb3d3b343f6851bef5322ed0f83b66e728 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Jan 2026 23:59:50 +0000 Subject: [PATCH 3/4] feat(skilltree): add NestJS backend with Docker deployment - Add NestJS backend with Drizzle ORM and PostgreSQL - Implement skills CRUD API with XP/level progression system - Add activities tracking endpoint - Configure Docker containers for backend (port 3024) and web (port 5195) - Add skilltree services to docker-compose.macmini.yml - Add CI build jobs for skilltree-backend and skilltree-web https://claude.ai/code/session_015XCsTDS9aLZ64Zin4HU6ex --- .github/workflows/ci.yml | 82 ++++++ apps/skilltree/apps/backend/Dockerfile | 67 +++++ .../apps/backend/docker-entrypoint.sh | 31 +++ apps/skilltree/apps/backend/drizzle.config.ts | 11 + apps/skilltree/apps/backend/nest-cli.json | 8 + apps/skilltree/apps/backend/package.json | 47 ++++ .../src/activity/activity.controller.ts | 27 ++ .../backend/src/activity/activity.module.ts | 10 + .../backend/src/activity/activity.service.ts | 36 +++ apps/skilltree/apps/backend/src/app.module.ts | 22 ++ .../apps/backend/src/db/connection.ts | 38 +++ .../apps/backend/src/db/database.module.ts | 20 ++ .../src/db/schema/activities.schema.ts | 37 +++ .../apps/backend/src/db/schema/index.ts | 3 + .../backend/src/db/schema/skills.schema.ts | 52 ++++ .../src/db/schema/user-stats.schema.ts | 34 +++ .../backend/src/health/health.controller.ts | 13 + .../apps/backend/src/health/health.module.ts | 7 + apps/skilltree/apps/backend/src/main.ts | 102 ++++++++ .../apps/backend/src/metrics/index.ts | 3 + .../backend/src/metrics/metrics.controller.ts | 13 + .../backend/src/metrics/metrics.module.ts | 11 + .../backend/src/metrics/metrics.service.ts | 37 +++ .../apps/backend/src/skill/dto/add-xp.dto.ts | 17 ++ .../backend/src/skill/dto/create-skill.dto.ts | 32 +++ .../apps/backend/src/skill/dto/index.ts | 3 + .../backend/src/skill/dto/update-skill.dto.ts | 34 +++ .../backend/src/skill/skill.controller.ts | 64 +++++ .../apps/backend/src/skill/skill.module.ts | 10 + .../apps/backend/src/skill/skill.service.ts | 238 ++++++++++++++++++ apps/skilltree/apps/backend/tsconfig.json | 25 ++ apps/skilltree/apps/web/Dockerfile | 66 +++++ docker-compose.macmini.yml | 56 ++++- 33 files changed, 1255 insertions(+), 1 deletion(-) create mode 100644 apps/skilltree/apps/backend/Dockerfile create mode 100644 apps/skilltree/apps/backend/docker-entrypoint.sh create mode 100644 apps/skilltree/apps/backend/drizzle.config.ts create mode 100644 apps/skilltree/apps/backend/nest-cli.json create mode 100644 apps/skilltree/apps/backend/package.json create mode 100644 apps/skilltree/apps/backend/src/activity/activity.controller.ts create mode 100644 apps/skilltree/apps/backend/src/activity/activity.module.ts create mode 100644 apps/skilltree/apps/backend/src/activity/activity.service.ts create mode 100644 apps/skilltree/apps/backend/src/app.module.ts create mode 100644 apps/skilltree/apps/backend/src/db/connection.ts create mode 100644 apps/skilltree/apps/backend/src/db/database.module.ts create mode 100644 apps/skilltree/apps/backend/src/db/schema/activities.schema.ts create mode 100644 apps/skilltree/apps/backend/src/db/schema/index.ts create mode 100644 apps/skilltree/apps/backend/src/db/schema/skills.schema.ts create mode 100644 apps/skilltree/apps/backend/src/db/schema/user-stats.schema.ts create mode 100644 apps/skilltree/apps/backend/src/health/health.controller.ts create mode 100644 apps/skilltree/apps/backend/src/health/health.module.ts create mode 100644 apps/skilltree/apps/backend/src/main.ts create mode 100644 apps/skilltree/apps/backend/src/metrics/index.ts create mode 100644 apps/skilltree/apps/backend/src/metrics/metrics.controller.ts create mode 100644 apps/skilltree/apps/backend/src/metrics/metrics.module.ts create mode 100644 apps/skilltree/apps/backend/src/metrics/metrics.service.ts create mode 100644 apps/skilltree/apps/backend/src/skill/dto/add-xp.dto.ts create mode 100644 apps/skilltree/apps/backend/src/skill/dto/create-skill.dto.ts create mode 100644 apps/skilltree/apps/backend/src/skill/dto/index.ts create mode 100644 apps/skilltree/apps/backend/src/skill/dto/update-skill.dto.ts create mode 100644 apps/skilltree/apps/backend/src/skill/skill.controller.ts create mode 100644 apps/skilltree/apps/backend/src/skill/skill.module.ts create mode 100644 apps/skilltree/apps/backend/src/skill/skill.service.ts create mode 100644 apps/skilltree/apps/backend/tsconfig.json create mode 100644 apps/skilltree/apps/web/Dockerfile diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5cc4fbd98..c1c4ff138 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,6 +68,8 @@ jobs: telegram-stats-bot: ${{ steps.changes.outputs.telegram-stats-bot }} nutriphi-backend: ${{ steps.changes.outputs.nutriphi-backend }} nutriphi-web: ${{ steps.changes.outputs.nutriphi-web }} + skilltree-backend: ${{ steps.changes.outputs.skilltree-backend }} + skilltree-web: ${{ steps.changes.outputs.skilltree-web }} any-changes: ${{ steps.changes.outputs.any-changes }} steps: - name: Checkout code @@ -100,6 +102,8 @@ jobs: echo "telegram-stats-bot=true" >> $GITHUB_OUTPUT echo "nutriphi-backend=true" >> $GITHUB_OUTPUT echo "nutriphi-web=true" >> $GITHUB_OUTPUT + echo "skilltree-backend=true" >> $GITHUB_OUTPUT + echo "skilltree-web=true" >> $GITHUB_OUTPUT echo "any-changes=true" >> $GITHUB_OUTPUT exit 0 fi @@ -136,6 +140,8 @@ jobs: echo "telegram-stats-bot=true" >> $GITHUB_OUTPUT echo "nutriphi-backend=true" >> $GITHUB_OUTPUT echo "nutriphi-web=true" >> $GITHUB_OUTPUT + echo "skilltree-backend=true" >> $GITHUB_OUTPUT + echo "skilltree-web=true" >> $GITHUB_OUTPUT echo "any-changes=true" >> $GITHUB_OUTPUT exit 0 fi @@ -319,6 +325,22 @@ jobs: echo "nutriphi-web=false" >> $GITHUB_OUTPUT fi + # skilltree-backend + SKILLTREE_BACKEND_CHANGED=$(check_pattern "apps/skilltree/apps/backend/|apps/skilltree/packages/") + if [ "$COMMON_CHANGED" == "true" ] || [ "$SHARED_AUTH_CHANGED" == "true" ] || [ "$SKILLTREE_BACKEND_CHANGED" == "true" ]; then + echo "skilltree-backend=true" >> $GITHUB_OUTPUT + else + echo "skilltree-backend=false" >> $GITHUB_OUTPUT + fi + + # skilltree-web + SKILLTREE_WEB_CHANGED=$(check_pattern "apps/skilltree/apps/web/|apps/skilltree/packages/") + if [ "$COMMON_CHANGED" == "true" ] || [ "$SHARED_AUTH_CHANGED" == "true" ] || [ "$SHARED_UI_CHANGED" == "true" ] || [ "$SHARED_WEB_CHANGED" == "true" ] || [ "$SKILLTREE_WEB_CHANGED" == "true" ]; then + echo "skilltree-web=true" >> $GITHUB_OUTPUT + else + echo "skilltree-web=false" >> $GITHUB_OUTPUT + fi + # Check if any service needs building if grep -q "=true" $GITHUB_OUTPUT; then echo "any-changes=true" >> $GITHUB_OUTPUT @@ -351,6 +373,8 @@ jobs: echo "| telegram-stats-bot | ${{ steps.changes.outputs.telegram-stats-bot }} |" >> $GITHUB_STEP_SUMMARY echo "| nutriphi-backend | ${{ steps.changes.outputs.nutriphi-backend }} |" >> $GITHUB_STEP_SUMMARY echo "| nutriphi-web | ${{ steps.changes.outputs.nutriphi-web }} |" >> $GITHUB_STEP_SUMMARY + echo "| skilltree-backend | ${{ steps.changes.outputs.skilltree-backend }} |" >> $GITHUB_STEP_SUMMARY + echo "| skilltree-web | ${{ steps.changes.outputs.skilltree-web }} |" >> $GITHUB_STEP_SUMMARY # =========================================== # Validation job - runs on PRs @@ -940,3 +964,61 @@ jobs: tags: ${{ steps.meta.outputs.tags }} cache-from: type=gha cache-to: type=gha,mode=max + + build-skilltree-backend: + name: Build skilltree-backend + runs-on: ubuntu-latest + needs: detect-changes + if: needs.detect-changes.outputs.skilltree-backend == 'true' + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - uses: docker/metadata-action@v5 + id: meta + with: + images: ghcr.io/${{ github.repository_owner }}/skilltree-backend + tags: type=raw,value=latest + - uses: docker/build-push-action@v5 + with: + context: . + file: apps/skilltree/apps/backend/Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + cache-from: type=gha + cache-to: type=gha,mode=max + + build-skilltree-web: + name: Build skilltree-web + runs-on: ubuntu-latest + needs: detect-changes + if: needs.detect-changes.outputs.skilltree-web == 'true' + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - uses: docker/metadata-action@v5 + id: meta + with: + images: ghcr.io/${{ github.repository_owner }}/skilltree-web + tags: type=raw,value=latest + - uses: docker/build-push-action@v5 + with: + context: . + file: apps/skilltree/apps/web/Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/apps/skilltree/apps/backend/Dockerfile b/apps/skilltree/apps/backend/Dockerfile new file mode 100644 index 000000000..6c03c81bc --- /dev/null +++ b/apps/skilltree/apps/backend/Dockerfile @@ -0,0 +1,67 @@ +# Build stage +FROM node:20-alpine AS builder + +# Install pnpm +RUN corepack enable && corepack prepare pnpm@9.15.0 --activate + +WORKDIR /app + +# Copy root workspace files +COPY pnpm-workspace.yaml ./ +COPY package.json ./ +COPY pnpm-lock.yaml ./ + +# Copy shared packages +COPY packages/shared-errors ./packages/shared-errors +COPY packages/shared-nestjs-auth ./packages/shared-nestjs-auth + +# Copy skilltree backend +COPY apps/skilltree/apps/backend ./apps/skilltree/apps/backend + +# Install dependencies +RUN pnpm install --frozen-lockfile + +# Build shared packages first +WORKDIR /app/packages/shared-errors +RUN pnpm build + +WORKDIR /app/packages/shared-nestjs-auth +RUN pnpm build + +# Build the backend +WORKDIR /app/apps/skilltree/apps/backend +RUN pnpm build + +# Production stage +FROM node:20-alpine AS production + +# Install pnpm and postgresql-client for health checks +RUN corepack enable && corepack prepare pnpm@9.15.0 --activate \ + && apk add --no-cache postgresql-client + +WORKDIR /app + +# Copy everything from builder (including node_modules) +COPY --from=builder /app/pnpm-workspace.yaml ./ +COPY --from=builder /app/package.json ./ +COPY --from=builder /app/pnpm-lock.yaml ./ +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/packages ./packages +COPY --from=builder /app/apps/skilltree/apps/backend ./apps/skilltree/apps/backend + +# Copy entrypoint script +COPY apps/skilltree/apps/backend/docker-entrypoint.sh /usr/local/bin/ +RUN chmod +x /usr/local/bin/docker-entrypoint.sh + +WORKDIR /app/apps/skilltree/apps/backend + +# Expose port +EXPOSE 3024 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:3024/health || exit 1 + +# Run entrypoint script +ENTRYPOINT ["docker-entrypoint.sh"] +CMD ["node", "dist/main.js"] diff --git a/apps/skilltree/apps/backend/docker-entrypoint.sh b/apps/skilltree/apps/backend/docker-entrypoint.sh new file mode 100644 index 000000000..1bee031c8 --- /dev/null +++ b/apps/skilltree/apps/backend/docker-entrypoint.sh @@ -0,0 +1,31 @@ +#!/bin/sh +set -e + +echo "Starting SkillTree Backend..." + +# Wait for PostgreSQL to be ready +if [ -n "$DATABASE_URL" ]; then + echo "Waiting for PostgreSQL..." + + # Extract host and port from DATABASE_URL + DB_HOST=$(echo $DATABASE_URL | sed -n 's/.*@\([^:]*\):.*/\1/p') + DB_PORT=$(echo $DATABASE_URL | sed -n 's/.*:\([0-9]*\)\/.*/\1/p') + + # Default to postgres:5432 if extraction fails + DB_HOST=${DB_HOST:-postgres} + DB_PORT=${DB_PORT:-5432} + + until pg_isready -h "$DB_HOST" -p "$DB_PORT" -U postgres 2>/dev/null; do + echo "PostgreSQL is unavailable - sleeping" + sleep 2 + done + + echo "PostgreSQL is ready!" + + # Run database migrations/push + echo "Pushing database schema..." + pnpm db:push || echo "Schema push completed (may have no changes)" +fi + +echo "Starting server..." +exec "$@" diff --git a/apps/skilltree/apps/backend/drizzle.config.ts b/apps/skilltree/apps/backend/drizzle.config.ts new file mode 100644 index 000000000..140553e32 --- /dev/null +++ b/apps/skilltree/apps/backend/drizzle.config.ts @@ -0,0 +1,11 @@ +import 'dotenv/config'; +import { defineConfig } from 'drizzle-kit'; + +export default defineConfig({ + schema: './src/db/schema/index.ts', + out: './drizzle', + dialect: 'postgresql', + dbCredentials: { + url: process.env.DATABASE_URL!, + }, +}); diff --git a/apps/skilltree/apps/backend/nest-cli.json b/apps/skilltree/apps/backend/nest-cli.json new file mode 100644 index 000000000..95538fb90 --- /dev/null +++ b/apps/skilltree/apps/backend/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/apps/skilltree/apps/backend/package.json b/apps/skilltree/apps/backend/package.json new file mode 100644 index 000000000..2f1dd3c59 --- /dev/null +++ b/apps/skilltree/apps/backend/package.json @@ -0,0 +1,47 @@ +{ + "name": "@skilltree/backend", + "version": "1.0.0", + "private": true, + "description": "SkillTree Backend API", + "scripts": { + "dev": "nest start --watch", + "build": "nest build", + "start": "nest start", + "start:prod": "node dist/main", + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "test": "jest", + "test:watch": "jest --watch", + "test:cov": "jest --coverage", + "db:push": "drizzle-kit push", + "db:studio": "drizzle-kit studio", + "db:generate": "drizzle-kit generate" + }, + "dependencies": { + "@manacore/shared-nestjs-auth": "workspace:*", + "@nestjs/common": "^10.4.9", + "@nestjs/config": "^3.3.0", + "@nestjs/core": "^10.4.9", + "@nestjs/platform-express": "^10.4.9", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.1", + "dotenv": "^16.4.7", + "drizzle-orm": "^0.38.3", + "postgres": "^3.4.5", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1", + "prom-client": "^15.1.0" + }, + "devDependencies": { + "@nestjs/cli": "^10.4.9", + "@nestjs/schematics": "^10.2.3", + "@nestjs/testing": "^11.1.9", + "@types/express": "^5.0.1", + "@types/jest": "^30.0.0", + "@types/node": "^22.15.21", + "drizzle-kit": "^0.30.2", + "jest": "^30.2.0", + "ts-jest": "^29.2.5", + "tsx": "^4.19.4", + "typescript": "^5.9.3" + } +} diff --git a/apps/skilltree/apps/backend/src/activity/activity.controller.ts b/apps/skilltree/apps/backend/src/activity/activity.controller.ts new file mode 100644 index 000000000..7e52f471e --- /dev/null +++ b/apps/skilltree/apps/backend/src/activity/activity.controller.ts @@ -0,0 +1,27 @@ +import { Controller, Get, Query, Param, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; +import { ActivityService } from './activity.service'; + +@Controller('activities') +@UseGuards(JwtAuthGuard) +export class ActivityController { + constructor(private readonly activityService: ActivityService) {} + + @Get() + async findAll(@CurrentUser() user: CurrentUserData, @Query('limit') limit?: number) { + const activities = await this.activityService.findAll(user.userId, limit ?? 50); + return { activities }; + } + + @Get('recent') + async getRecent(@CurrentUser() user: CurrentUserData, @Query('limit') limit?: number) { + const activities = await this.activityService.getRecent(user.userId, limit ?? 10); + return { activities }; + } + + @Get('skill/:skillId') + async findBySkill(@CurrentUser() user: CurrentUserData, @Param('skillId') skillId: string) { + const activities = await this.activityService.findBySkill(user.userId, skillId); + return { activities }; + } +} diff --git a/apps/skilltree/apps/backend/src/activity/activity.module.ts b/apps/skilltree/apps/backend/src/activity/activity.module.ts new file mode 100644 index 000000000..b747ea624 --- /dev/null +++ b/apps/skilltree/apps/backend/src/activity/activity.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { ActivityController } from './activity.controller'; +import { ActivityService } from './activity.service'; + +@Module({ + controllers: [ActivityController], + providers: [ActivityService], + exports: [ActivityService], +}) +export class ActivityModule {} diff --git a/apps/skilltree/apps/backend/src/activity/activity.service.ts b/apps/skilltree/apps/backend/src/activity/activity.service.ts new file mode 100644 index 000000000..adafd9b38 --- /dev/null +++ b/apps/skilltree/apps/backend/src/activity/activity.service.ts @@ -0,0 +1,36 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { eq, desc } from 'drizzle-orm'; +import { DATABASE_TOKEN } from '../db/database.module'; +import { Database } from '../db/connection'; +import { activities, Activity } from '../db/schema'; + +@Injectable() +export class ActivityService { + constructor(@Inject(DATABASE_TOKEN) private db: Database) {} + + async findAll(userId: string, limit = 50): Promise { + return this.db + .select() + .from(activities) + .where(eq(activities.userId, userId)) + .orderBy(desc(activities.timestamp)) + .limit(limit); + } + + async findBySkill(userId: string, skillId: string): Promise { + return this.db + .select() + .from(activities) + .where(eq(activities.skillId, skillId)) + .orderBy(desc(activities.timestamp)); + } + + async getRecent(userId: string, limit = 10): Promise { + return this.db + .select() + .from(activities) + .where(eq(activities.userId, userId)) + .orderBy(desc(activities.timestamp)) + .limit(limit); + } +} diff --git a/apps/skilltree/apps/backend/src/app.module.ts b/apps/skilltree/apps/backend/src/app.module.ts new file mode 100644 index 000000000..1cc59968e --- /dev/null +++ b/apps/skilltree/apps/backend/src/app.module.ts @@ -0,0 +1,22 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { DatabaseModule } from './db/database.module'; +import { HealthModule } from './health/health.module'; +import { MetricsModule } from './metrics'; +import { SkillModule } from './skill/skill.module'; +import { ActivityModule } from './activity/activity.module'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + envFilePath: '.env', + }), + MetricsModule, + DatabaseModule, + HealthModule, + SkillModule, + ActivityModule, + ], +}) +export class AppModule {} diff --git a/apps/skilltree/apps/backend/src/db/connection.ts b/apps/skilltree/apps/backend/src/db/connection.ts new file mode 100644 index 000000000..34773decd --- /dev/null +++ b/apps/skilltree/apps/backend/src/db/connection.ts @@ -0,0 +1,38 @@ +import { drizzle } from 'drizzle-orm/postgres-js'; +import postgres from 'postgres'; +import * as schema from './schema'; + +let db: ReturnType> | null = null; +let connection: ReturnType | null = null; + +export function getDb(connectionString?: string) { + if (db) return db; + + const url = connectionString || process.env.DATABASE_URL; + if (!url) { + throw new Error('DATABASE_URL is not defined'); + } + + connection = postgres(url, { + max: 10, + idle_timeout: 20, + connect_timeout: 10, + }); + + db = drizzle(connection, { schema }); + return db; +} + +export function getConnection() { + return connection; +} + +export async function closeConnection() { + if (connection) { + await connection.end(); + connection = null; + db = null; + } +} + +export type Database = ReturnType; diff --git a/apps/skilltree/apps/backend/src/db/database.module.ts b/apps/skilltree/apps/backend/src/db/database.module.ts new file mode 100644 index 000000000..955eefe5f --- /dev/null +++ b/apps/skilltree/apps/backend/src/db/database.module.ts @@ -0,0 +1,20 @@ +import { Module, Global, OnModuleDestroy } from '@nestjs/common'; +import { getDb, closeConnection, Database } from './connection'; + +export const DATABASE_TOKEN = 'DATABASE'; + +@Global() +@Module({ + providers: [ + { + provide: DATABASE_TOKEN, + useFactory: () => getDb(), + }, + ], + exports: [DATABASE_TOKEN], +}) +export class DatabaseModule implements OnModuleDestroy { + async onModuleDestroy() { + await closeConnection(); + } +} diff --git a/apps/skilltree/apps/backend/src/db/schema/activities.schema.ts b/apps/skilltree/apps/backend/src/db/schema/activities.schema.ts new file mode 100644 index 000000000..e832ad1f2 --- /dev/null +++ b/apps/skilltree/apps/backend/src/db/schema/activities.schema.ts @@ -0,0 +1,37 @@ +import { + pgTable, + uuid, + timestamp, + varchar, + text, + integer, + index, +} from 'drizzle-orm/pg-core'; +import { skills } from './skills.schema'; + +export const activities = pgTable( + 'activities', + { + id: uuid('id').primaryKey().defaultRandom(), + userId: text('user_id').notNull(), + skillId: uuid('skill_id') + .references(() => skills.id, { onDelete: 'cascade' }) + .notNull(), + + // Activity details + xpEarned: integer('xp_earned').notNull(), + description: varchar('description', { length: 500 }).notNull(), + duration: integer('duration'), // in minutes + + // Timestamp + timestamp: timestamp('timestamp', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => ({ + userIdx: index('activities_user_idx').on(table.userId), + skillIdx: index('activities_skill_idx').on(table.skillId), + timestampIdx: index('activities_timestamp_idx').on(table.userId, table.timestamp), + }) +); + +export type Activity = typeof activities.$inferSelect; +export type NewActivity = typeof activities.$inferInsert; diff --git a/apps/skilltree/apps/backend/src/db/schema/index.ts b/apps/skilltree/apps/backend/src/db/schema/index.ts new file mode 100644 index 000000000..f37fa5cb3 --- /dev/null +++ b/apps/skilltree/apps/backend/src/db/schema/index.ts @@ -0,0 +1,3 @@ +export * from './skills.schema'; +export * from './activities.schema'; +export * from './user-stats.schema'; diff --git a/apps/skilltree/apps/backend/src/db/schema/skills.schema.ts b/apps/skilltree/apps/backend/src/db/schema/skills.schema.ts new file mode 100644 index 000000000..f4d2f672b --- /dev/null +++ b/apps/skilltree/apps/backend/src/db/schema/skills.schema.ts @@ -0,0 +1,52 @@ +import { + pgTable, + uuid, + timestamp, + varchar, + text, + integer, + index, +} from 'drizzle-orm/pg-core'; + +export type SkillBranch = + | 'intellect' + | 'body' + | 'creativity' + | 'social' + | 'practical' + | 'mindset' + | 'custom'; + +export const skills = pgTable( + 'skills', + { + id: uuid('id').primaryKey().defaultRandom(), + userId: text('user_id').notNull(), + + // Content + name: varchar('name', { length: 200 }).notNull(), + description: text('description'), + branch: varchar('branch', { length: 20 }).notNull().$type(), + parentId: uuid('parent_id'), + icon: varchar('icon', { length: 50 }).default('star'), + color: varchar('color', { length: 20 }), + + // Progress + currentXp: integer('current_xp').default(0).notNull(), + totalXp: integer('total_xp').default(0).notNull(), + level: integer('level').default(0).notNull(), + + // Timestamps + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => ({ + userIdx: index('skills_user_idx').on(table.userId), + branchIdx: index('skills_branch_idx').on(table.userId, table.branch), + parentIdx: index('skills_parent_idx').on(table.parentId), + levelIdx: index('skills_level_idx').on(table.userId, table.level), + }) +); + +export type Skill = typeof skills.$inferSelect; +export type NewSkill = typeof skills.$inferInsert; diff --git a/apps/skilltree/apps/backend/src/db/schema/user-stats.schema.ts b/apps/skilltree/apps/backend/src/db/schema/user-stats.schema.ts new file mode 100644 index 000000000..a5ba16422 --- /dev/null +++ b/apps/skilltree/apps/backend/src/db/schema/user-stats.schema.ts @@ -0,0 +1,34 @@ +import { + pgTable, + uuid, + timestamp, + text, + integer, + date, + index, +} from 'drizzle-orm/pg-core'; + +export const userStats = pgTable( + 'user_stats', + { + id: uuid('id').primaryKey().defaultRandom(), + userId: text('user_id').notNull().unique(), + + // Aggregated stats + totalXp: integer('total_xp').default(0).notNull(), + totalSkills: integer('total_skills').default(0).notNull(), + highestLevel: integer('highest_level').default(0).notNull(), + streakDays: integer('streak_days').default(0).notNull(), + lastActivityDate: date('last_activity_date'), + + // Timestamps + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => ({ + userIdx: index('user_stats_user_idx').on(table.userId), + }) +); + +export type UserStat = typeof userStats.$inferSelect; +export type NewUserStat = typeof userStats.$inferInsert; diff --git a/apps/skilltree/apps/backend/src/health/health.controller.ts b/apps/skilltree/apps/backend/src/health/health.controller.ts new file mode 100644 index 000000000..8fb4727a8 --- /dev/null +++ b/apps/skilltree/apps/backend/src/health/health.controller.ts @@ -0,0 +1,13 @@ +import { Controller, Get } from '@nestjs/common'; + +@Controller('health') +export class HealthController { + @Get() + check() { + return { + status: 'ok', + timestamp: new Date().toISOString(), + service: 'skilltree-backend', + }; + } +} diff --git a/apps/skilltree/apps/backend/src/health/health.module.ts b/apps/skilltree/apps/backend/src/health/health.module.ts new file mode 100644 index 000000000..a61d8b044 --- /dev/null +++ b/apps/skilltree/apps/backend/src/health/health.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common'; +import { HealthController } from './health.controller'; + +@Module({ + controllers: [HealthController], +}) +export class HealthModule {} diff --git a/apps/skilltree/apps/backend/src/main.ts b/apps/skilltree/apps/backend/src/main.ts new file mode 100644 index 000000000..3cd031213 --- /dev/null +++ b/apps/skilltree/apps/backend/src/main.ts @@ -0,0 +1,102 @@ +import { NestFactory } from '@nestjs/core'; +import { ValidationPipe, Logger } from '@nestjs/common'; +import { Request, Response, NextFunction } from 'express'; +import { AppModule } from './app.module'; +import { MetricsService } from './metrics/metrics.service'; + +// Normalize route paths to prevent high cardinality +function normalizeRoute(path: string): string { + return path + .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, ':id') + .replace(/\/\d+/g, '/:id'); +} + +async function bootstrap() { + const logger = new Logger('Bootstrap'); + const app = await NestFactory.create(AppModule); + + // Get MetricsService for request tracking + const metricsService = app.get(MetricsService); + + // Global Express middleware to track ALL HTTP requests + app.use((req: Request, res: Response, next: NextFunction) => { + if (req.path === '/metrics') { + return next(); + } + + const startTime = Date.now(); + const method = req.method; + const route = normalizeRoute(req.path); + + res.once('finish', () => { + const duration = (Date.now() - startTime) / 1000; + metricsService.httpRequestsTotal.inc({ + method, + route, + status: res.statusCode.toString(), + }); + metricsService.httpRequestDuration.observe( + { method, route, status: res.statusCode.toString() }, + duration + ); + }); + + next(); + }); + + // Enable CORS + app.enableCors({ + origin: (origin, callback) => { + if (!origin) { + callback(null, true); + return; + } + + const allowedOrigins = process.env.CORS_ORIGINS?.split(',') || [ + 'http://localhost:5173', + 'http://localhost:5195', + 'http://localhost:8081', + ]; + + if (process.env.NODE_ENV === 'development' && origin.includes('localhost')) { + callback(null, true); + return; + } + + if (allowedOrigins.includes(origin)) { + callback(null, true); + } else { + logger.warn(`Blocked request from origin: ${origin}`); + callback(new Error('Not allowed by CORS'), false); + } + }, + credentials: true, + methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization'], + }); + + // Global validation pipe + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + transformOptions: { + enableImplicitConversion: true, + }, + }) + ); + + // API prefix + app.setGlobalPrefix('api/v1', { + exclude: ['metrics', 'health'], + }); + + const port = process.env.PORT || 3024; + await app.listen(port); + + logger.log(`SkillTree API is running on: http://localhost:${port}`); + logger.log(`Health check: http://localhost:${port}/health`); +} + +bootstrap(); diff --git a/apps/skilltree/apps/backend/src/metrics/index.ts b/apps/skilltree/apps/backend/src/metrics/index.ts new file mode 100644 index 000000000..860cd0cdf --- /dev/null +++ b/apps/skilltree/apps/backend/src/metrics/index.ts @@ -0,0 +1,3 @@ +export * from './metrics.module'; +export * from './metrics.service'; +export * from './metrics.controller'; diff --git a/apps/skilltree/apps/backend/src/metrics/metrics.controller.ts b/apps/skilltree/apps/backend/src/metrics/metrics.controller.ts new file mode 100644 index 000000000..4ee665772 --- /dev/null +++ b/apps/skilltree/apps/backend/src/metrics/metrics.controller.ts @@ -0,0 +1,13 @@ +import { Controller, Get, Header } from '@nestjs/common'; +import { MetricsService } from './metrics.service'; + +@Controller('metrics') +export class MetricsController { + constructor(private metricsService: MetricsService) {} + + @Get() + @Header('Content-Type', 'text/plain') + async getMetrics(): Promise { + return this.metricsService.getMetrics(); + } +} diff --git a/apps/skilltree/apps/backend/src/metrics/metrics.module.ts b/apps/skilltree/apps/backend/src/metrics/metrics.module.ts new file mode 100644 index 000000000..6c262956a --- /dev/null +++ b/apps/skilltree/apps/backend/src/metrics/metrics.module.ts @@ -0,0 +1,11 @@ +import { Module, Global } from '@nestjs/common'; +import { MetricsController } from './metrics.controller'; +import { MetricsService } from './metrics.service'; + +@Global() +@Module({ + controllers: [MetricsController], + providers: [MetricsService], + exports: [MetricsService], +}) +export class MetricsModule {} diff --git a/apps/skilltree/apps/backend/src/metrics/metrics.service.ts b/apps/skilltree/apps/backend/src/metrics/metrics.service.ts new file mode 100644 index 000000000..0b9d56083 --- /dev/null +++ b/apps/skilltree/apps/backend/src/metrics/metrics.service.ts @@ -0,0 +1,37 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { collectDefaultMetrics, Counter, Histogram, Registry } from 'prom-client'; + +@Injectable() +export class MetricsService implements OnModuleInit { + private registry: Registry; + + public httpRequestsTotal: Counter; + public httpRequestDuration: Histogram; + + constructor() { + this.registry = new Registry(); + + this.httpRequestsTotal = new Counter({ + name: 'http_requests_total', + help: 'Total number of HTTP requests', + labelNames: ['method', 'route', 'status'], + registers: [this.registry], + }); + + this.httpRequestDuration = new Histogram({ + name: 'http_request_duration_seconds', + help: 'Duration of HTTP requests in seconds', + labelNames: ['method', 'route', 'status'], + buckets: [0.01, 0.05, 0.1, 0.5, 1, 2, 5], + registers: [this.registry], + }); + } + + onModuleInit() { + collectDefaultMetrics({ register: this.registry }); + } + + async getMetrics(): Promise { + return this.registry.metrics(); + } +} diff --git a/apps/skilltree/apps/backend/src/skill/dto/add-xp.dto.ts b/apps/skilltree/apps/backend/src/skill/dto/add-xp.dto.ts new file mode 100644 index 000000000..e3dd9167a --- /dev/null +++ b/apps/skilltree/apps/backend/src/skill/dto/add-xp.dto.ts @@ -0,0 +1,17 @@ +import { IsString, IsNumber, IsOptional, Min, Max, MaxLength } from 'class-validator'; + +export class AddXpDto { + @IsNumber() + @Min(1) + @Max(10000) + xp: number; + + @IsString() + @MaxLength(500) + description: string; + + @IsOptional() + @IsNumber() + @Min(1) + duration?: number; // in minutes +} diff --git a/apps/skilltree/apps/backend/src/skill/dto/create-skill.dto.ts b/apps/skilltree/apps/backend/src/skill/dto/create-skill.dto.ts new file mode 100644 index 000000000..baf677575 --- /dev/null +++ b/apps/skilltree/apps/backend/src/skill/dto/create-skill.dto.ts @@ -0,0 +1,32 @@ +import { IsString, IsOptional, IsIn, MaxLength, IsUUID } from 'class-validator'; +import type { SkillBranch } from '../../db/schema'; + +const BRANCHES = ['intellect', 'body', 'creativity', 'social', 'practical', 'mindset', 'custom'] as const; + +export class CreateSkillDto { + @IsString() + @MaxLength(200) + name: string; + + @IsOptional() + @IsString() + @MaxLength(1000) + description?: string; + + @IsIn(BRANCHES) + branch: SkillBranch; + + @IsOptional() + @IsUUID() + parentId?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + icon?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + color?: string; +} diff --git a/apps/skilltree/apps/backend/src/skill/dto/index.ts b/apps/skilltree/apps/backend/src/skill/dto/index.ts new file mode 100644 index 000000000..c173f123c --- /dev/null +++ b/apps/skilltree/apps/backend/src/skill/dto/index.ts @@ -0,0 +1,3 @@ +export * from './create-skill.dto'; +export * from './update-skill.dto'; +export * from './add-xp.dto'; diff --git a/apps/skilltree/apps/backend/src/skill/dto/update-skill.dto.ts b/apps/skilltree/apps/backend/src/skill/dto/update-skill.dto.ts new file mode 100644 index 000000000..d7e858dad --- /dev/null +++ b/apps/skilltree/apps/backend/src/skill/dto/update-skill.dto.ts @@ -0,0 +1,34 @@ +import { IsString, IsOptional, IsIn, MaxLength, IsUUID } from 'class-validator'; +import type { SkillBranch } from '../../db/schema'; + +const BRANCHES = ['intellect', 'body', 'creativity', 'social', 'practical', 'mindset', 'custom'] as const; + +export class UpdateSkillDto { + @IsOptional() + @IsString() + @MaxLength(200) + name?: string; + + @IsOptional() + @IsString() + @MaxLength(1000) + description?: string; + + @IsOptional() + @IsIn(BRANCHES) + branch?: SkillBranch; + + @IsOptional() + @IsUUID() + parentId?: string | null; + + @IsOptional() + @IsString() + @MaxLength(50) + icon?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + color?: string; +} diff --git a/apps/skilltree/apps/backend/src/skill/skill.controller.ts b/apps/skilltree/apps/backend/src/skill/skill.controller.ts new file mode 100644 index 000000000..32f3fd01b --- /dev/null +++ b/apps/skilltree/apps/backend/src/skill/skill.controller.ts @@ -0,0 +1,64 @@ +import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; +import { SkillService } from './skill.service'; +import { CreateSkillDto, UpdateSkillDto, AddXpDto } from './dto'; + +@Controller('skills') +@UseGuards(JwtAuthGuard) +export class SkillController { + constructor(private readonly skillService: SkillService) {} + + @Get() + async findAll(@CurrentUser() user: CurrentUserData, @Query('branch') branch?: string) { + if (branch) { + const skills = await this.skillService.findByBranch(user.userId, branch); + return { skills }; + } + const skills = await this.skillService.findAll(user.userId); + return { skills }; + } + + @Get('stats') + async getStats(@CurrentUser() user: CurrentUserData) { + const stats = await this.skillService.getUserStats(user.userId); + return { stats }; + } + + @Get(':id') + async findOne(@CurrentUser() user: CurrentUserData, @Param('id') id: string) { + const skill = await this.skillService.findByIdOrThrow(id, user.userId); + return { skill }; + } + + @Post() + async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateSkillDto) { + const skill = await this.skillService.create(user.userId, dto); + return { skill }; + } + + @Put(':id') + async update( + @CurrentUser() user: CurrentUserData, + @Param('id') id: string, + @Body() dto: UpdateSkillDto + ) { + const skill = await this.skillService.update(id, user.userId, dto); + return { skill }; + } + + @Delete(':id') + async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) { + await this.skillService.delete(id, user.userId); + return { success: true }; + } + + @Post(':id/xp') + async addXp( + @CurrentUser() user: CurrentUserData, + @Param('id') id: string, + @Body() dto: AddXpDto + ) { + const result = await this.skillService.addXp(id, user.userId, dto); + return result; + } +} diff --git a/apps/skilltree/apps/backend/src/skill/skill.module.ts b/apps/skilltree/apps/backend/src/skill/skill.module.ts new file mode 100644 index 000000000..191b57fe7 --- /dev/null +++ b/apps/skilltree/apps/backend/src/skill/skill.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { SkillController } from './skill.controller'; +import { SkillService } from './skill.service'; + +@Module({ + controllers: [SkillController], + providers: [SkillService], + exports: [SkillService], +}) +export class SkillModule {} diff --git a/apps/skilltree/apps/backend/src/skill/skill.service.ts b/apps/skilltree/apps/backend/src/skill/skill.service.ts new file mode 100644 index 000000000..649ebc146 --- /dev/null +++ b/apps/skilltree/apps/backend/src/skill/skill.service.ts @@ -0,0 +1,238 @@ +import { Injectable, Inject, NotFoundException } from '@nestjs/common'; +import { eq, and, desc } from 'drizzle-orm'; +import { DATABASE_TOKEN } from '../db/database.module'; +import { Database } from '../db/connection'; +import { skills, activities, userStats, Skill, NewSkill } from '../db/schema'; +import { CreateSkillDto, UpdateSkillDto, AddXpDto } from './dto'; + +// Level thresholds +const LEVEL_THRESHOLDS = [0, 100, 500, 1500, 4000, 10000]; + +function calculateLevel(xp: number): number { + for (let i = LEVEL_THRESHOLDS.length - 1; i >= 0; i--) { + if (xp >= LEVEL_THRESHOLDS[i]) { + return i; + } + } + return 0; +} + +@Injectable() +export class SkillService { + constructor(@Inject(DATABASE_TOKEN) private db: Database) {} + + async findAll(userId: string): Promise { + return this.db.select().from(skills).where(eq(skills.userId, userId)).orderBy(desc(skills.totalXp)); + } + + async findByBranch(userId: string, branch: string): Promise { + return this.db + .select() + .from(skills) + .where(and(eq(skills.userId, userId), eq(skills.branch, branch as any))) + .orderBy(desc(skills.totalXp)); + } + + async findById(id: string, userId: string): Promise { + const [skill] = await this.db + .select() + .from(skills) + .where(and(eq(skills.id, id), eq(skills.userId, userId))); + return skill ?? null; + } + + async findByIdOrThrow(id: string, userId: string): Promise { + const skill = await this.findById(id, userId); + if (!skill) { + throw new NotFoundException(`Skill with id ${id} not found`); + } + return skill; + } + + async create(userId: string, dto: CreateSkillDto): Promise { + const newSkill: NewSkill = { + userId, + name: dto.name, + description: dto.description, + branch: dto.branch, + parentId: dto.parentId, + icon: dto.icon ?? 'star', + color: dto.color, + currentXp: 0, + totalXp: 0, + level: 0, + }; + + const [skill] = await this.db.insert(skills).values(newSkill).returning(); + + // Update user stats + await this.updateUserStats(userId); + + return skill; + } + + async update(id: string, userId: string, dto: UpdateSkillDto): Promise { + await this.findByIdOrThrow(id, userId); + + const [updated] = await this.db + .update(skills) + .set({ + ...dto, + updatedAt: new Date(), + }) + .where(and(eq(skills.id, id), eq(skills.userId, userId))) + .returning(); + + return updated; + } + + async delete(id: string, userId: string): Promise { + await this.findByIdOrThrow(id, userId); + + await this.db.delete(skills).where(and(eq(skills.id, id), eq(skills.userId, userId))); + + // Update user stats + await this.updateUserStats(userId); + } + + async addXp( + id: string, + userId: string, + dto: AddXpDto + ): Promise<{ skill: Skill; leveledUp: boolean; newLevel: number }> { + const skill = await this.findByIdOrThrow(id, userId); + + const newTotalXp = skill.totalXp + dto.xp; + const newCurrentXp = skill.currentXp + dto.xp; + const newLevel = calculateLevel(newTotalXp); + const leveledUp = newLevel > skill.level; + + // Update skill + const [updated] = await this.db + .update(skills) + .set({ + totalXp: newTotalXp, + currentXp: newCurrentXp, + level: newLevel, + updatedAt: new Date(), + }) + .where(and(eq(skills.id, id), eq(skills.userId, userId))) + .returning(); + + // Create activity + await this.db.insert(activities).values({ + userId, + skillId: id, + xpEarned: dto.xp, + description: dto.description, + duration: dto.duration, + }); + + // Update user stats + await this.updateUserStats(userId); + + return { skill: updated, leveledUp, newLevel }; + } + + private async updateUserStats(userId: string): Promise { + // Get aggregated stats + const userSkills = await this.db.select().from(skills).where(eq(skills.userId, userId)); + + const totalXp = userSkills.reduce((sum, s) => sum + s.totalXp, 0); + const totalSkills = userSkills.length; + const highestLevel = userSkills.reduce((max, s) => Math.max(max, s.level), 0); + + // Get last activity date + const [lastActivity] = await this.db + .select() + .from(activities) + .where(eq(activities.userId, userId)) + .orderBy(desc(activities.timestamp)) + .limit(1); + + const lastActivityDate = lastActivity?.timestamp + ? lastActivity.timestamp.toISOString().split('T')[0] + : null; + + // Calculate streak + const streakDays = await this.calculateStreak(userId); + + // Upsert user stats + await this.db + .insert(userStats) + .values({ + userId, + totalXp, + totalSkills, + highestLevel, + streakDays, + lastActivityDate, + }) + .onConflictDoUpdate({ + target: userStats.userId, + set: { + totalXp, + totalSkills, + highestLevel, + streakDays, + lastActivityDate, + updatedAt: new Date(), + }, + }); + } + + private async calculateStreak(userId: string): Promise { + const allActivities = await this.db + .select() + .from(activities) + .where(eq(activities.userId, userId)) + .orderBy(desc(activities.timestamp)); + + if (allActivities.length === 0) return 0; + + const today = new Date(); + today.setHours(0, 0, 0, 0); + + // Get unique dates + const uniqueDates = [ + ...new Set( + allActivities.map((a) => { + const d = new Date(a.timestamp); + d.setHours(0, 0, 0, 0); + return d.getTime(); + }) + ), + ].sort((a, b) => b - a); // Newest first + + let streak = 0; + let expectedDate = today.getTime(); + + for (const date of uniqueDates) { + if (date === expectedDate || date === expectedDate - 86400000) { + streak++; + expectedDate = date - 86400000; + } else if (date < expectedDate - 86400000) { + break; + } + } + + return streak; + } + + async getUserStats(userId: string) { + const [stats] = await this.db.select().from(userStats).where(eq(userStats.userId, userId)); + + if (!stats) { + // Return default stats + return { + totalXp: 0, + totalSkills: 0, + highestLevel: 0, + streakDays: 0, + lastActivityDate: null, + }; + } + + return stats; + } +} diff --git a/apps/skilltree/apps/backend/tsconfig.json b/apps/skilltree/apps/backend/tsconfig.json new file mode 100644 index 000000000..b1459ec35 --- /dev/null +++ b/apps/skilltree/apps/backend/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": true, + "noImplicitAny": false, + "strictBindCallApply": false, + "forceConsistentCasingInFileNames": false, + "noFallthroughCasesInSwitch": false, + "esModuleInterop": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/apps/skilltree/apps/web/Dockerfile b/apps/skilltree/apps/web/Dockerfile new file mode 100644 index 000000000..d906736d0 --- /dev/null +++ b/apps/skilltree/apps/web/Dockerfile @@ -0,0 +1,66 @@ +# Build stage +FROM node:20-alpine AS builder + +# Build arguments for SvelteKit static env vars +ARG PUBLIC_BACKEND_URL=http://skilltree-backend:3024 +ARG PUBLIC_MANA_CORE_AUTH_URL=http://mana-core-auth:3001 + +# Set as environment variables for build +ENV PUBLIC_BACKEND_URL=$PUBLIC_BACKEND_URL +ENV PUBLIC_MANA_CORE_AUTH_URL=$PUBLIC_MANA_CORE_AUTH_URL + +# Install pnpm +RUN corepack enable && corepack prepare pnpm@9.15.0 --activate + +WORKDIR /app + +# Copy root workspace files +COPY pnpm-workspace.yaml ./ +COPY package.json ./ +COPY pnpm-lock.yaml ./ + +# Copy shared packages needed by skilltree web +COPY packages/shared-tailwind ./packages/shared-tailwind +COPY packages/shared-theme ./packages/shared-theme +COPY packages/shared-utils ./packages/shared-utils + +# Copy skilltree web +COPY apps/skilltree/apps/web ./apps/skilltree/apps/web + +# Install dependencies +RUN pnpm install --frozen-lockfile + +# Build the web app +WORKDIR /app/apps/skilltree/apps/web +RUN pnpm exec svelte-kit sync +RUN pnpm build + +# Production stage +FROM node:20-alpine AS production + +WORKDIR /app/apps/skilltree/apps/web + +# Copy the pnpm store that symlinks point to +COPY --from=builder /app/node_modules/.pnpm /app/node_modules/.pnpm + +# Copy the app's node_modules +COPY --from=builder /app/apps/skilltree/apps/web/node_modules ./node_modules + +# Copy built application +COPY --from=builder /app/apps/skilltree/apps/web/build ./build +COPY --from=builder /app/apps/skilltree/apps/web/package.json ./ + +# Expose port +EXPOSE 5195 + +# Set environment variables +ENV NODE_ENV=production +ENV PORT=5195 +ENV HOST=0.0.0.0 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:5195/health || exit 1 + +# Run the app +CMD ["node", "build"] diff --git a/docker-compose.macmini.yml b/docker-compose.macmini.yml index 20ec32283..0b56d3c74 100644 --- a/docker-compose.macmini.yml +++ b/docker-compose.macmini.yml @@ -89,7 +89,7 @@ services: SMTP_USER: ${SMTP_USER:-94cde5002@smtp-brevo.com} SMTP_PASSWORD: ${SMTP_PASSWORD} SMTP_FROM: ManaCore - CORS_ORIGINS: https://mana.how,https://chat.mana.how,https://todo.mana.how,https://calendar.mana.how,https://clock.mana.how,https://contacts.mana.how,https://storage.mana.how,https://presi.mana.how,https://nutriphi.mana.how + CORS_ORIGINS: https://mana.how,https://chat.mana.how,https://todo.mana.how,https://calendar.mana.how,https://clock.mana.how,https://contacts.mana.how,https://storage.mana.how,https://presi.mana.how,https://nutriphi.mana.how,https://skilltree.mana.how # DuckDB Analytics (Business Metrics) DUCKDB_PATH: /data/analytics/metrics.duckdb volumes: @@ -589,6 +589,60 @@ services: retries: 3 start_period: 40s + # ============================================ + # SkillTree App (Gamified Skill Tracking) + # ============================================ + + skilltree-backend: + image: ghcr.io/memo-2023/skilltree-backend:latest + container_name: skilltree-backend + restart: always + depends_on: + mana-core-auth: + condition: service_healthy + postgres: + condition: service_healthy + environment: + NODE_ENV: production + PORT: 3024 + DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:-manacore123}@postgres:5432/skilltree + DB_HOST: postgres + DB_PORT: 5432 + DB_USER: postgres + MANA_CORE_AUTH_URL: http://mana-core-auth:3001 + CORS_ORIGINS: https://skilltree.mana.how,https://mana.how + ports: + - "3024:3024" + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3024/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + + skilltree-web: + image: ghcr.io/memo-2023/skilltree-web:latest + container_name: skilltree-web + restart: always + depends_on: + skilltree-backend: + condition: service_healthy + environment: + NODE_ENV: production + PORT: 5195 + PUBLIC_BACKEND_URL: http://skilltree-backend:3024 + PUBLIC_MANA_CORE_AUTH_URL: http://mana-core-auth:3001 + PUBLIC_BACKEND_URL_CLIENT: https://skilltree-api.mana.how + PUBLIC_MANA_CORE_AUTH_URL_CLIENT: https://auth.mana.how + ports: + - "5195:5195" + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:5195/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + # ============================================ # Monitoring Stack # ============================================ From 076e5518cccd463e6892307acb0118e04fc208d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Jan 2026 11:05:32 +0000 Subject: [PATCH 4/4] feat(skilltree): connect web app to backend API - Add API client for backend communication - Add skills and activities API modules - Add auth store with Mana Core Auth integration - Update skills store to use API when authenticated - Keep IndexedDB as offline fallback - Add hooks.server.ts for runtime env injection https://claude.ai/code/session_015XCsTDS9aLZ64Zin4HU6ex --- .../apps/web/.env.production.example | 12 + apps/skilltree/apps/web/package.json | 1 + apps/skilltree/apps/web/src/hooks.server.ts | 24 ++ .../apps/web/src/lib/api/activities.ts | 23 ++ apps/skilltree/apps/web/src/lib/api/client.ts | 98 +++++++ apps/skilltree/apps/web/src/lib/api/skills.ts | 80 ++++++ .../apps/web/src/lib/stores/auth.svelte.ts | 212 +++++++++++++++ .../apps/web/src/lib/stores/skills.svelte.ts | 245 +++++++++++++----- 8 files changed, 636 insertions(+), 59 deletions(-) create mode 100644 apps/skilltree/apps/web/.env.production.example create mode 100644 apps/skilltree/apps/web/src/hooks.server.ts create mode 100644 apps/skilltree/apps/web/src/lib/api/activities.ts create mode 100644 apps/skilltree/apps/web/src/lib/api/client.ts create mode 100644 apps/skilltree/apps/web/src/lib/api/skills.ts create mode 100644 apps/skilltree/apps/web/src/lib/stores/auth.svelte.ts diff --git a/apps/skilltree/apps/web/.env.production.example b/apps/skilltree/apps/web/.env.production.example new file mode 100644 index 000000000..313246818 --- /dev/null +++ b/apps/skilltree/apps/web/.env.production.example @@ -0,0 +1,12 @@ +# SkillTree Web App - Production Environment Variables +# Copy this file to .env.production and fill in the values + +# ============================================================================= +# REQUIRED +# ============================================================================= + +# Backend API URL +PUBLIC_BACKEND_URL=https://skilltree-api.mana.how + +# Mana Core Auth URL for authentication +PUBLIC_MANA_CORE_AUTH_URL=https://auth.mana.how diff --git a/apps/skilltree/apps/web/package.json b/apps/skilltree/apps/web/package.json index 66774ff58..669146ff4 100644 --- a/apps/skilltree/apps/web/package.json +++ b/apps/skilltree/apps/web/package.json @@ -25,6 +25,7 @@ "vite": "^6.0.0" }, "dependencies": { + "@manacore/shared-auth": "workspace:*", "@manacore/shared-tailwind": "workspace:*", "@manacore/shared-theme": "workspace:*", "@manacore/shared-utils": "workspace:*", diff --git a/apps/skilltree/apps/web/src/hooks.server.ts b/apps/skilltree/apps/web/src/hooks.server.ts new file mode 100644 index 000000000..a450239b2 --- /dev/null +++ b/apps/skilltree/apps/web/src/hooks.server.ts @@ -0,0 +1,24 @@ +/** + * Server Hooks for SvelteKit + * - Injects runtime environment variables for client-side use + * - Auth is handled client-side via Mana Core Auth + */ + +import type { Handle } from '@sveltejs/kit'; + +const PUBLIC_MANA_CORE_AUTH_URL_CLIENT = + process.env.PUBLIC_MANA_CORE_AUTH_URL_CLIENT || process.env.PUBLIC_MANA_CORE_AUTH_URL || ''; +const PUBLIC_BACKEND_URL_CLIENT = + process.env.PUBLIC_BACKEND_URL_CLIENT || process.env.PUBLIC_BACKEND_URL || ''; + +export const handle: Handle = async ({ event, resolve }) => { + return resolve(event, { + transformPageChunk: ({ html }) => { + const envScript = ``; + return html.replace('', `${envScript}`); + }, + }); +}; diff --git a/apps/skilltree/apps/web/src/lib/api/activities.ts b/apps/skilltree/apps/web/src/lib/api/activities.ts new file mode 100644 index 000000000..d2cca8c5f --- /dev/null +++ b/apps/skilltree/apps/web/src/lib/api/activities.ts @@ -0,0 +1,23 @@ +import { apiClient } from './client'; +import type { Activity } from '$lib/types'; + +interface ActivitiesResponse { + activities: Activity[]; +} + +export async function getActivities(skillId?: string, limit?: number): Promise { + const params = new URLSearchParams(); + if (skillId) params.append('skillId', skillId); + if (limit) params.append('limit', String(limit)); + const queryString = params.toString() ? `?${params.toString()}` : ''; + const response = await apiClient.get(`/api/v1/activities${queryString}`); + return response.activities; +} + +export async function getRecentActivities(limit = 10): Promise { + return getActivities(undefined, limit); +} + +export async function getSkillActivities(skillId: string): Promise { + return getActivities(skillId); +} diff --git a/apps/skilltree/apps/web/src/lib/api/client.ts b/apps/skilltree/apps/web/src/lib/api/client.ts new file mode 100644 index 000000000..29bcbb7f4 --- /dev/null +++ b/apps/skilltree/apps/web/src/lib/api/client.ts @@ -0,0 +1,98 @@ +import { browser } from '$app/environment'; +import { PUBLIC_BACKEND_URL } from '$env/static/public'; + +interface ApiOptions { + method?: string; + body?: unknown; + headers?: Record; +} + +interface ApiError { + message: string; + statusCode: number; +} + +/** + * Get the backend URL, preferring runtime-injected value in browser + * This allows Docker to inject PUBLIC_BACKEND_URL_CLIENT at runtime + */ +function getBackendUrl(): string { + if (browser && typeof window !== 'undefined') { + const runtimeUrl = (window as Window & { __PUBLIC_BACKEND_URL__?: string }) + .__PUBLIC_BACKEND_URL__; + if (runtimeUrl) { + return runtimeUrl; + } + } + return PUBLIC_BACKEND_URL || 'http://localhost:3024'; +} + +class ApiClient { + private accessToken: string | null = null; + + private get baseUrl(): string { + return getBackendUrl(); + } + + setAccessToken(token: string | null) { + this.accessToken = token; + } + + getAccessToken(): string | null { + return this.accessToken; + } + + async fetch(endpoint: string, options: ApiOptions = {}): Promise { + const { method = 'GET', body, headers = {} } = options; + + const requestHeaders: Record = { + 'Content-Type': 'application/json', + ...headers, + }; + + if (this.accessToken) { + requestHeaders['Authorization'] = `Bearer ${this.accessToken}`; + } + + const response = await fetch(`${this.baseUrl}${endpoint}`, { + method, + headers: requestHeaders, + body: body ? JSON.stringify(body) : undefined, + }); + + if (!response.ok) { + let errorMessage = 'An error occurred'; + try { + const errorData = (await response.json()) as ApiError; + errorMessage = errorData.message || errorMessage; + } catch { + errorMessage = response.statusText || errorMessage; + } + throw new Error(errorMessage); + } + + if (response.status === 204) { + return {} as T; + } + + return response.json() as Promise; + } + + get(endpoint: string, headers?: Record): Promise { + return this.fetch(endpoint, { method: 'GET', headers }); + } + + post(endpoint: string, body?: unknown, headers?: Record): Promise { + return this.fetch(endpoint, { method: 'POST', body, headers }); + } + + put(endpoint: string, body?: unknown, headers?: Record): Promise { + return this.fetch(endpoint, { method: 'PUT', body, headers }); + } + + delete(endpoint: string, headers?: Record): Promise { + return this.fetch(endpoint, { method: 'DELETE', headers }); + } +} + +export const apiClient = new ApiClient(); diff --git a/apps/skilltree/apps/web/src/lib/api/skills.ts b/apps/skilltree/apps/web/src/lib/api/skills.ts new file mode 100644 index 000000000..6324a51cf --- /dev/null +++ b/apps/skilltree/apps/web/src/lib/api/skills.ts @@ -0,0 +1,80 @@ +import { apiClient } from './client'; +import type { Skill, Activity, UserStats, SkillBranch } from '$lib/types'; + +interface CreateSkillDto { + name: string; + description?: string; + branch: SkillBranch; + parentId?: string; + icon?: string; + color?: string; +} + +interface UpdateSkillDto { + name?: string; + description?: string; + branch?: SkillBranch; + parentId?: string | null; + icon?: string; + color?: string | null; +} + +interface AddXpDto { + xp: number; + description: string; + duration?: number; +} + +interface AddXpResponse { + skill: Skill; + activity: Activity; + leveledUp: boolean; + previousLevel: number; + newLevel: number; +} + +interface SkillsResponse { + skills: Skill[]; +} + +interface SkillResponse { + skill: Skill; +} + +interface StatsResponse { + stats: UserStats; +} + +export async function getSkills(branch?: SkillBranch): Promise { + const queryString = branch ? `?branch=${branch}` : ''; + const response = await apiClient.get(`/api/v1/skills${queryString}`); + return response.skills; +} + +export async function getSkill(id: string): Promise { + const response = await apiClient.get(`/api/v1/skills/${id}`); + return response.skill; +} + +export async function createSkill(data: CreateSkillDto): Promise { + const response = await apiClient.post('/api/v1/skills', data); + return response.skill; +} + +export async function updateSkill(id: string, data: UpdateSkillDto): Promise { + const response = await apiClient.put(`/api/v1/skills/${id}`, data); + return response.skill; +} + +export async function deleteSkill(id: string): Promise { + await apiClient.delete(`/api/v1/skills/${id}`); +} + +export async function addXp(skillId: string, data: AddXpDto): Promise { + return await apiClient.post(`/api/v1/skills/${skillId}/xp`, data); +} + +export async function getStats(): Promise { + const response = await apiClient.get('/api/v1/skills/stats'); + return response.stats; +} diff --git a/apps/skilltree/apps/web/src/lib/stores/auth.svelte.ts b/apps/skilltree/apps/web/src/lib/stores/auth.svelte.ts new file mode 100644 index 000000000..c922cd75a --- /dev/null +++ b/apps/skilltree/apps/web/src/lib/stores/auth.svelte.ts @@ -0,0 +1,212 @@ +/** + * Auth Store - Manages authentication state using Svelte 5 runes + * Uses Mana Core Auth + */ + +import { browser } from '$app/environment'; +import { initializeWebAuth, type UserData } from '@manacore/shared-auth'; +import { apiClient } from '$lib/api/client'; + +const DEV_AUTH_URL = 'http://localhost:3001'; +const DEV_BACKEND_URL = 'http://localhost:3024'; + +function getAuthUrl(): string { + if (browser && typeof window !== 'undefined') { + const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string }) + .__PUBLIC_MANA_CORE_AUTH_URL__; + if (injectedUrl) return injectedUrl; + return import.meta.env.DEV ? DEV_AUTH_URL : ''; + } + return process.env.PUBLIC_MANA_CORE_AUTH_URL || DEV_AUTH_URL; +} + +function getBackendUrl(): string { + if (browser && typeof window !== 'undefined') { + const injectedUrl = (window as unknown as { __PUBLIC_BACKEND_URL__?: string }) + .__PUBLIC_BACKEND_URL__; + if (injectedUrl) return injectedUrl; + return import.meta.env.DEV ? DEV_BACKEND_URL : ''; + } + return process.env.PUBLIC_BACKEND_URL || DEV_BACKEND_URL; +} + +let _authService: ReturnType['authService'] | null = null; +let _tokenManager: ReturnType['tokenManager'] | null = null; + +function getAuthService() { + if (!browser) return null; + if (!_authService) { + const auth = initializeWebAuth({ + baseUrl: getAuthUrl(), + backendUrl: getBackendUrl(), + }); + _authService = auth.authService; + _tokenManager = auth.tokenManager; + } + return _authService; +} + +function getTokenManager() { + if (!browser) return null; + getAuthService(); + return _tokenManager; +} + +let user = $state(null); +let loading = $state(true); +let initialized = $state(false); + +export const authStore = { + get user() { + return user; + }, + get loading() { + return loading; + }, + get isAuthenticated() { + return !!user; + }, + get initialized() { + return initialized; + }, + + async initialize() { + if (initialized) return; + + const authService = getAuthService(); + if (!authService) { + initialized = true; + loading = false; + return; + } + + loading = true; + try { + const authenticated = await authService.isAuthenticated(); + if (authenticated) { + const userData = await authService.getUserFromToken(); + user = userData; + + const token = await authService.getAppToken(); + if (token) { + apiClient.setAccessToken(token); + } + } + initialized = true; + } catch (error) { + console.error('Failed to initialize auth:', error); + user = null; + } finally { + loading = false; + } + }, + + async signIn(email: string, password: string) { + const authService = getAuthService(); + if (!authService) { + return { success: false, error: 'Auth not available on server' }; + } + + try { + const result = await authService.signIn(email, password); + + if (!result.success) { + return { success: false, error: result.error || 'Login failed' }; + } + + const userData = await authService.getUserFromToken(); + user = userData; + + const token = await authService.getAppToken(); + if (token) { + apiClient.setAccessToken(token); + } + + return { success: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + return { success: false, error: errorMessage }; + } + }, + + async signUp(email: string, password: string) { + const authService = getAuthService(); + if (!authService) { + return { success: false, error: 'Auth not available on server', needsVerification: false }; + } + + try { + const sourceAppUrl = browser ? window.location.origin : undefined; + const result = await authService.signUp(email, password, undefined, sourceAppUrl); + + if (!result.success) { + return { success: false, error: result.error || 'Signup failed', needsVerification: false }; + } + + if (result.needsVerification) { + return { success: true, needsVerification: true }; + } + + const signInResult = await this.signIn(email, password); + return { ...signInResult, needsVerification: false }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + return { success: false, error: errorMessage, needsVerification: false }; + } + }, + + async signOut() { + const authService = getAuthService(); + if (!authService) { + user = null; + apiClient.setAccessToken(null); + return; + } + + try { + await authService.signOut(); + user = null; + apiClient.setAccessToken(null); + } catch (error) { + console.error('Sign out error:', error); + user = null; + apiClient.setAccessToken(null); + } + }, + + async resetPassword(email: string) { + const authService = getAuthService(); + if (!authService) { + return { success: false, error: 'Auth not available on server' }; + } + + try { + const result = await authService.forgotPassword(email); + + if (!result.success) { + return { success: false, error: result.error || 'Password reset failed' }; + } + + return { success: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + return { success: false, error: errorMessage }; + } + }, + + async getAccessToken() { + const authService = getAuthService(); + if (!authService) { + return null; + } + return await authService.getAppToken(); + }, + + async getValidToken(): Promise { + const tokenManager = getTokenManager(); + if (!tokenManager) { + return null; + } + return await tokenManager.getValidToken(); + }, +}; diff --git a/apps/skilltree/apps/web/src/lib/stores/skills.svelte.ts b/apps/skilltree/apps/web/src/lib/stores/skills.svelte.ts index c5a5c0e96..cc79b6acb 100644 --- a/apps/skilltree/apps/web/src/lib/stores/skills.svelte.ts +++ b/apps/skilltree/apps/web/src/lib/stores/skills.svelte.ts @@ -1,11 +1,9 @@ import type { Skill, Activity, UserStats, SkillBranch } from '$lib/types'; -import { - calculateLevel, - createDefaultSkill, - createActivity, - BRANCH_INFO, -} from '$lib/types'; +import { calculateLevel, createDefaultSkill, createActivity, BRANCH_INFO } from '$lib/types'; import * as storage from '$lib/services/storage'; +import * as skillsApi from '$lib/api/skills'; +import * as activitiesApi from '$lib/api/activities'; +import { authStore } from './auth.svelte'; // Reactive state using Svelte 5 runes let skills = $state([]); @@ -19,6 +17,7 @@ let userStats = $state({ }); let isLoading = $state(true); let initialized = $state(false); +let useApi = $state(false); // Derived values const skillsByBranch = $derived(() => { @@ -42,13 +41,14 @@ const topSkills = $derived(() => { }); const recentActivities = $derived(() => { - return [...activities].sort((a, b) => - new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime() - ).slice(0, 10); + 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; + const stats: Record = + {} as Record; for (const branch of Object.keys(BRANCH_INFO) as SkillBranch[]) { const branchSkills = skills.filter((s) => s.branch === branch); stats[branch] = { @@ -69,81 +69,171 @@ async function initialize() { isLoading = true; try { - const [loadedSkills, loadedActivities, loadedStats] = await Promise.all([ - storage.getAllSkills(), - storage.getAllActivities(), - storage.getUserStats(), - ]); - skills = loadedSkills; - activities = loadedActivities; - userStats = loadedStats; + // Check if user is authenticated + if (authStore.isAuthenticated) { + useApi = true; + const [loadedSkills, loadedActivities, loadedStats] = await Promise.all([ + skillsApi.getSkills(), + activitiesApi.getRecentActivities(50), + skillsApi.getStats(), + ]); + skills = loadedSkills; + activities = loadedActivities; + userStats = loadedStats; + } else { + // Fallback to IndexedDB for offline/unauthenticated use + useApi = false; + const [loadedSkills, loadedActivities, loadedStats] = await Promise.all([ + storage.getAllSkills(), + storage.getAllActivities(), + storage.getUserStats(), + ]); + skills = loadedSkills; + activities = loadedActivities; + userStats = loadedStats; + } initialized = true; } catch (error) { console.error('Failed to initialize skills store:', error); + // On error, try IndexedDB as fallback + if (useApi) { + try { + useApi = false; + const [loadedSkills, loadedActivities, loadedStats] = await Promise.all([ + storage.getAllSkills(), + storage.getAllActivities(), + storage.getUserStats(), + ]); + skills = loadedSkills; + activities = loadedActivities; + userStats = loadedStats; + } catch (fallbackError) { + console.error('Fallback to IndexedDB also failed:', fallbackError); + } + } } finally { isLoading = false; } } async function addSkill(data: Partial): Promise { - const skill = createDefaultSkill(data); - await storage.saveSkill(skill); - skills = [...skills, skill]; - await updateStats(); - return skill; + if (useApi && authStore.isAuthenticated) { + const skill = await skillsApi.createSkill({ + name: data.name || '', + description: data.description, + branch: data.branch || 'custom', + parentId: data.parentId ?? undefined, + icon: data.icon, + color: data.color ?? undefined, + }); + skills = [...skills, skill]; + await updateStats(); + return skill; + } else { + 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)]; + if (useApi && authStore.isAuthenticated) { + const skill = await skillsApi.updateSkill(id, { + name: updates.name, + description: updates.description, + branch: updates.branch, + parentId: updates.parentId, + icon: updates.icon, + color: updates.color, + }); + skills = [...skills.slice(0, index), skill, ...skills.slice(index + 1)]; + } else { + const updatedSkill = { ...skills[index], ...updates, updatedAt: new Date().toISOString() }; + await storage.saveSkill(updatedSkill); + skills = [...skills.slice(0, index), updatedSkill, ...skills.slice(index + 1)]; + } await updateStats(); } async function deleteSkill(id: string): Promise { - await storage.deleteSkill(id); + if (useApi && authStore.isAuthenticated) { + await skillsApi.deleteSkill(id); + } else { + 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 }> { +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; + if (useApi && authStore.isAuthenticated) { + const result = await skillsApi.addXp(skillId, { xp, description, duration }); + skills = [...skills.slice(0, index), result.skill, ...skills.slice(index + 1)]; + activities = [...activities, result.activity]; + await updateStats(); + return { leveledUp: result.leveledUp, newLevel: result.newLevel }; + } else { + const skill = skills[index]; + const newTotalXp = skill.totalXp + xp; + const newCurrentXp = skill.currentXp + xp; + const newLevel = calculateLevel(newTotalXp); + const leveledUp = newLevel > skill.level; - const updatedSkill: Skill = { - ...skill, - totalXp: newTotalXp, - currentXp: newCurrentXp, - level: newLevel, - updatedAt: new Date().toISOString(), - }; + const updatedSkill: Skill = { + ...skill, + totalXp: newTotalXp, + currentXp: newCurrentXp, + level: newLevel, + updatedAt: new Date().toISOString(), + }; - const activity = createActivity(skillId, xp, description, duration); + const activity = createActivity(skillId, xp, description, duration); - await Promise.all([ - storage.saveSkill(updatedSkill), - storage.saveActivity(activity), - ]); + await Promise.all([storage.saveSkill(updatedSkill), storage.saveActivity(activity)]); - skills = [...skills.slice(0, index), updatedSkill, ...skills.slice(index + 1)]; - activities = [...activities, activity]; - await updateStats(); + skills = [...skills.slice(0, index), updatedSkill, ...skills.slice(index + 1)]; + activities = [...activities, activity]; + await updateStats(); - return { leveledUp, newLevel }; + return { leveledUp, newLevel }; + } } async function updateStats(): Promise { - userStats = await storage.recalculateStats(); + if (useApi && authStore.isAuthenticated) { + try { + userStats = await skillsApi.getStats(); + } catch { + // Calculate locally as fallback + userStats = calculateLocalStats(); + } + } else { + userStats = await storage.recalculateStats(); + } +} + +function calculateLocalStats(): UserStats { + 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: 0, + lastActivityDate: activities.length > 0 ? activities[activities.length - 1].timestamp : null, + }; } function getSkill(id: string): Skill | undefined { @@ -154,19 +244,56 @@ function getSkillActivities(skillId: string): Activity[] { return activities.filter((a) => a.skillId === skillId); } +// Reinitialize when auth state changes +async function reinitialize() { + initialized = false; + skills = []; + activities = []; + userStats = { + totalXp: 0, + totalSkills: 0, + highestLevel: 0, + streakDays: 0, + lastActivityDate: null, + }; + await initialize(); +} + // 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; }, + 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; + }, + get useApi() { + return useApi; + }, initialize, + reinitialize, addSkill, updateSkill, deleteSkill,