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
This commit is contained in:
Claude 2026-01-28 20:32:05 +00:00
parent bea066c7f8
commit 12ad8e83d5
No known key found for this signature in database
19 changed files with 1655 additions and 0 deletions

104
apps/skilltree/CLAUDE.md Normal file
View file

@ -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 |

View file

@ -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"
}

View file

@ -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;
}

13
apps/skilltree/apps/web/src/app.d.ts vendored Normal file
View file

@ -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 {};

View file

@ -0,0 +1,27 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- Favicon -->
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<!-- PWA Manifest -->
<link rel="manifest" href="/manifest.json" />
<!-- Theme Color -->
<meta name="theme-color" content="#10b981" />
<meta name="msapplication-TileColor" content="#10b981" />
<!-- PWA -->
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="SkillTree" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View file

@ -0,0 +1,132 @@
<script lang="ts">
import type { Skill, SkillBranch } from '$lib/types';
import { BRANCH_INFO } from '$lib/types';
import { X } from 'lucide-svelte';
interface Props {
onClose: () => void;
onSave: (skill: Partial<Skill>) => Promise<void>;
}
let { onClose, onSave }: Props = $props();
let name = $state('');
let description = $state('');
let branch = $state<SkillBranch>('intellect');
let saving = $state(false);
async function handleSubmit(e: Event) {
e.preventDefault();
if (!name.trim()) return;
saving = true;
try {
await onSave({
name: name.trim(),
description: description.trim(),
branch,
});
} finally {
saving = false;
}
}
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
onClose();
}
}
</script>
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
onclick={handleBackdropClick}
role="dialog"
aria-modal="true"
>
<div class="mx-4 w-full max-w-md rounded-2xl border border-gray-700 bg-gray-800 p-6 shadow-xl">
<!-- Header -->
<div class="mb-6 flex items-center justify-between">
<h2 class="text-xl font-bold text-white">Neuer Skill</h2>
<button
onclick={onClose}
class="rounded-lg p-2 text-gray-400 transition-colors hover:bg-gray-700 hover:text-white"
>
<X class="h-5 w-5" />
</button>
</div>
<form onsubmit={handleSubmit} class="space-y-4">
<!-- Name -->
<div>
<label for="name" class="mb-1 block text-sm font-medium text-gray-300">
Name *
</label>
<input
id="name"
type="text"
bind:value={name}
placeholder="z.B. TypeScript"
class="w-full rounded-lg border border-gray-600 bg-gray-700 px-4 py-2 text-white placeholder-gray-400 focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
required
/>
</div>
<!-- Description -->
<div>
<label for="description" class="mb-1 block text-sm font-medium text-gray-300">
Beschreibung
</label>
<textarea
id="description"
bind:value={description}
placeholder="Worum geht es bei diesem Skill?"
rows="3"
class="w-full rounded-lg border border-gray-600 bg-gray-700 px-4 py-2 text-white placeholder-gray-400 focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
></textarea>
</div>
<!-- Branch -->
<div>
<label for="branch" class="mb-2 block text-sm font-medium text-gray-300">
Kategorie
</label>
<div class="grid grid-cols-2 gap-2">
{#each Object.entries(BRANCH_INFO) as [key, info]}
<button
type="button"
onclick={() => (branch = key as SkillBranch)}
class="flex items-center gap-2 rounded-lg border px-3 py-2 text-left text-sm transition-colors {branch === key
? 'border-emerald-500 bg-emerald-500/20 text-white'
: 'border-gray-600 bg-gray-700/50 text-gray-300 hover:border-gray-500'}"
>
<span
class="h-3 w-3 rounded-full"
style="background-color: {info.color}"
></span>
{info.name}
</button>
{/each}
</div>
</div>
<!-- Actions -->
<div class="flex gap-3 pt-4">
<button
type="button"
onclick={onClose}
class="flex-1 rounded-lg border border-gray-600 bg-transparent px-4 py-2 font-medium text-gray-300 transition-colors hover:bg-gray-700"
>
Abbrechen
</button>
<button
type="submit"
disabled={!name.trim() || saving}
class="flex-1 rounded-lg bg-emerald-600 px-4 py-2 font-medium text-white transition-colors hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-50"
>
{saving ? 'Speichern...' : 'Erstellen'}
</button>
</div>
</form>
</div>
</div>

View file

@ -0,0 +1,172 @@
<script lang="ts">
import type { Skill } from '$lib/types';
import { LEVEL_NAMES } from '$lib/types';
import { X, Zap, Clock, Star } from 'lucide-svelte';
interface Props {
skill: Skill;
onClose: () => void;
onSave: (xp: number, description: string, duration?: number) => Promise<void>;
}
let { skill, onClose, onSave }: Props = $props();
let xp = $state(10);
let description = $state('');
let duration = $state<number | undefined>(undefined);
let saving = $state(false);
// Quick XP presets
const xpPresets = [
{ label: '+5', value: 5, desc: 'Kurz geübt' },
{ label: '+10', value: 10, desc: 'Normale Session' },
{ label: '+25', value: 25, desc: 'Intensive Session' },
{ label: '+50', value: 50, desc: 'Großer Fortschritt' },
{ label: '+100', value: 100, desc: 'Meilenstein erreicht' },
];
async function handleSubmit(e: Event) {
e.preventDefault();
if (xp <= 0) return;
saving = true;
try {
await onSave(xp, description || `+${xp} XP`, duration);
} finally {
saving = false;
}
}
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
onClose();
}
}
function selectPreset(preset: { value: number; desc: string }) {
xp = preset.value;
if (!description) {
description = preset.desc;
}
}
</script>
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
onclick={handleBackdropClick}
role="dialog"
aria-modal="true"
>
<div class="mx-4 w-full max-w-md rounded-2xl border border-gray-700 bg-gray-800 p-6 shadow-xl">
<!-- Header -->
<div class="mb-6 flex items-center justify-between">
<div>
<h2 class="text-xl font-bold text-white">XP hinzufügen</h2>
<p class="text-sm text-gray-400">{skill.name} (Lvl {skill.level})</p>
</div>
<button
onclick={onClose}
class="rounded-lg p-2 text-gray-400 transition-colors hover:bg-gray-700 hover:text-white"
>
<X class="h-5 w-5" />
</button>
</div>
<form onsubmit={handleSubmit} class="space-y-4">
<!-- Quick XP Presets -->
<div>
<label class="mb-2 block text-sm font-medium text-gray-300">
Schnellauswahl
</label>
<div class="flex flex-wrap gap-2">
{#each xpPresets as preset}
<button
type="button"
onclick={() => selectPreset(preset)}
class="rounded-lg border px-3 py-1.5 text-sm font-medium transition-colors {xp === preset.value
? 'border-emerald-500 bg-emerald-500/20 text-emerald-400'
: 'border-gray-600 bg-gray-700/50 text-gray-300 hover:border-gray-500'}"
>
{preset.label}
</button>
{/each}
</div>
</div>
<!-- Custom XP -->
<div>
<label for="xp" class="mb-1 block text-sm font-medium text-gray-300">
<Zap class="mr-1 inline h-4 w-4 text-yellow-500" />
XP Menge
</label>
<input
id="xp"
type="number"
bind:value={xp}
min="1"
max="1000"
class="w-full rounded-lg border border-gray-600 bg-gray-700 px-4 py-2 text-white placeholder-gray-400 focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
/>
</div>
<!-- Description -->
<div>
<label for="description" class="mb-1 block text-sm font-medium text-gray-300">
Was hast du gemacht?
</label>
<input
id="description"
type="text"
bind:value={description}
placeholder="z.B. Tutorial abgeschlossen"
class="w-full rounded-lg border border-gray-600 bg-gray-700 px-4 py-2 text-white placeholder-gray-400 focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
/>
</div>
<!-- Duration (optional) -->
<div>
<label for="duration" class="mb-1 block text-sm font-medium text-gray-300">
<Clock class="mr-1 inline h-4 w-4 text-gray-400" />
Dauer (optional, Minuten)
</label>
<input
id="duration"
type="number"
bind:value={duration}
min="1"
placeholder="z.B. 30"
class="w-full rounded-lg border border-gray-600 bg-gray-700 px-4 py-2 text-white placeholder-gray-400 focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
/>
</div>
<!-- Preview -->
<div class="rounded-lg bg-gray-700/50 p-3">
<div class="flex items-center justify-between text-sm">
<span class="text-gray-400">Vorschau</span>
<span class="font-medium text-emerald-400">+{xp} XP</span>
</div>
<div class="mt-1 text-xs text-gray-500">
Neuer Stand: {(skill.totalXp + xp).toLocaleString()} XP
</div>
</div>
<!-- Actions -->
<div class="flex gap-3 pt-2">
<button
type="button"
onclick={onClose}
class="flex-1 rounded-lg border border-gray-600 bg-transparent px-4 py-2 font-medium text-gray-300 transition-colors hover:bg-gray-700"
>
Abbrechen
</button>
<button
type="submit"
disabled={xp <= 0 || saving}
class="flex-1 rounded-lg bg-emerald-600 px-4 py-2 font-medium text-white transition-colors hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-50"
>
{saving ? 'Speichern...' : 'XP vergeben'}
</button>
</div>
</form>
</div>
</div>

View file

@ -0,0 +1,102 @@
<script lang="ts">
import type { Skill } from '$lib/types';
import { BRANCH_INFO, LEVEL_NAMES, xpProgress, xpForNextLevel } from '$lib/types';
import { Plus, Trash2, Edit, Star } from 'lucide-svelte';
interface Props {
skill: Skill;
onAddXp: () => void;
onEdit: () => void;
onDelete: () => void;
}
let { skill, onAddXp, onEdit, onDelete }: Props = $props();
const branchInfo = $derived(BRANCH_INFO[skill.branch]);
const levelName = $derived(LEVEL_NAMES[skill.level]);
const progress = $derived(xpProgress(skill.totalXp, skill.level));
const nextLevelXp = $derived(xpForNextLevel(skill.level));
const isMaxLevel = $derived(skill.level >= LEVEL_NAMES.length - 1);
function getLevelColor(level: number): string {
const colors = [
'text-gray-400',
'text-blue-400',
'text-purple-400',
'text-pink-400',
'text-orange-400',
'text-yellow-400',
];
return colors[level] ?? colors[0];
}
</script>
<div class="skill-card group relative overflow-hidden rounded-xl border border-gray-700 bg-gray-800/50 p-4">
<!-- Branch Indicator -->
<div
class="branch-indicator absolute left-0 top-0 h-full"
style="background-color: {branchInfo.color}"
></div>
<!-- Header -->
<div class="mb-3 flex items-start justify-between pl-3">
<div>
<h3 class="text-lg font-semibold text-white">{skill.name}</h3>
<p class="text-sm text-gray-400">{branchInfo.name}</p>
</div>
<div class="flex items-center gap-1">
{#each Array(skill.level) as _, i}
<Star class="h-4 w-4 fill-yellow-500 text-yellow-500" />
{/each}
{#each Array(5 - skill.level) as _, i}
<Star class="h-4 w-4 text-gray-600" />
{/each}
</div>
</div>
<!-- Level Badge -->
<div class="mb-3 pl-3">
<span class="inline-flex items-center gap-1 rounded-full bg-gray-700/50 px-3 py-1 text-sm {getLevelColor(skill.level)}">
Lvl {skill.level} - {levelName}
</span>
</div>
<!-- XP Progress -->
<div class="mb-4 pl-3">
<div class="mb-1 flex justify-between text-sm">
<span class="text-gray-400">XP</span>
<span class="text-gray-300">
{skill.totalXp.toLocaleString()}
{#if !isMaxLevel}
/ {nextLevelXp.toLocaleString()}
{/if}
</span>
</div>
<div class="xp-bar-container h-2 rounded-full">
<div class="xp-bar h-full rounded-full" style="width: {progress}%"></div>
</div>
</div>
<!-- Description -->
{#if skill.description}
<p class="mb-4 pl-3 text-sm text-gray-400 line-clamp-2">{skill.description}</p>
{/if}
<!-- Actions -->
<div class="flex items-center gap-2 pl-3">
<button
onclick={onAddXp}
class="pulse-xp flex flex-1 items-center justify-center gap-2 rounded-lg bg-emerald-600/20 px-3 py-2 text-sm font-medium text-emerald-400 transition-colors hover:bg-emerald-600/30"
>
<Plus class="h-4 w-4" />
XP hinzufügen
</button>
<button
onclick={onDelete}
class="rounded-lg bg-red-600/20 p-2 text-red-400 opacity-0 transition-all hover:bg-red-600/30 group-hover:opacity-100"
title="Löschen"
>
<Trash2 class="h-4 w-4" />
</button>
</div>
</div>

View file

@ -0,0 +1,66 @@
<script lang="ts">
import { skillStore } from '$lib/stores/skills.svelte';
import { Trophy, Zap, Target, Flame } from 'lucide-svelte';
</script>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<!-- Total XP -->
<div class="rounded-xl border border-gray-700 bg-gray-800/50 p-4">
<div class="flex items-center gap-3">
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-yellow-500/20">
<Zap class="h-6 w-6 text-yellow-500" />
</div>
<div>
<p class="text-sm text-gray-400">Gesamt-XP</p>
<p class="text-2xl font-bold text-white">
{skillStore.userStats.totalXp.toLocaleString()}
</p>
</div>
</div>
</div>
<!-- Total Skills -->
<div class="rounded-xl border border-gray-700 bg-gray-800/50 p-4">
<div class="flex items-center gap-3">
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-emerald-500/20">
<Target class="h-6 w-6 text-emerald-500" />
</div>
<div>
<p class="text-sm text-gray-400">Skills</p>
<p class="text-2xl font-bold text-white">
{skillStore.userStats.totalSkills}
</p>
</div>
</div>
</div>
<!-- Highest Level -->
<div class="rounded-xl border border-gray-700 bg-gray-800/50 p-4">
<div class="flex items-center gap-3">
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-purple-500/20">
<Trophy class="h-6 w-6 text-purple-500" />
</div>
<div>
<p class="text-sm text-gray-400">Höchstes Level</p>
<p class="text-2xl font-bold text-white">
{skillStore.userStats.highestLevel}
</p>
</div>
</div>
</div>
<!-- Streak -->
<div class="rounded-xl border border-gray-700 bg-gray-800/50 p-4">
<div class="flex items-center gap-3">
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-orange-500/20">
<Flame class="h-6 w-6 text-orange-500" />
</div>
<div>
<p class="text-sm text-gray-400">Streak</p>
<p class="text-2xl font-bold text-white">
{skillStore.userStats.streakDays} Tage
</p>
</div>
</div>
</div>
</div>

View file

@ -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<IDBPDatabase<SkillTreeDB>> | null = null;
function getDB(): Promise<IDBPDatabase<SkillTreeDB>> {
if (!dbPromise) {
dbPromise = openDB<SkillTreeDB>(DB_NAME, DB_VERSION, {
upgrade(db) {
// Skills store
if (!db.objectStoreNames.contains('skills')) {
const skillStore = db.createObjectStore('skills', { keyPath: 'id' });
skillStore.createIndex('by-branch', 'branch');
skillStore.createIndex('by-parent', 'parentId');
skillStore.createIndex('by-level', 'level');
}
// Activities store
if (!db.objectStoreNames.contains('activities')) {
const activityStore = db.createObjectStore('activities', { keyPath: 'id' });
activityStore.createIndex('by-skill', 'skillId');
activityStore.createIndex('by-timestamp', 'timestamp');
}
// Stats store
if (!db.objectStoreNames.contains('stats')) {
db.createObjectStore('stats');
}
},
});
}
return dbPromise;
}
// Skills CRUD
export async function getAllSkills(): Promise<Skill[]> {
const db = await getDB();
return db.getAll('skills');
}
export async function getSkillById(id: string): Promise<Skill | undefined> {
const db = await getDB();
return db.get('skills', id);
}
export async function getSkillsByBranch(branch: string): Promise<Skill[]> {
const db = await getDB();
return db.getAllFromIndex('skills', 'by-branch', branch);
}
export async function getChildSkills(parentId: string): Promise<Skill[]> {
const db = await getDB();
return db.getAllFromIndex('skills', 'by-parent', parentId);
}
export async function saveSkill(skill: Skill): Promise<void> {
const db = await getDB();
skill.updatedAt = new Date().toISOString();
await db.put('skills', skill);
}
export async function deleteSkill(id: string): Promise<void> {
const db = await getDB();
// Delete skill and all its activities
const activities = await db.getAllFromIndex('activities', 'by-skill', id);
const tx = db.transaction(['skills', 'activities'], 'readwrite');
await Promise.all([
tx.objectStore('skills').delete(id),
...activities.map((a) => tx.objectStore('activities').delete(a.id)),
]);
await tx.done;
}
// Activities CRUD
export async function getAllActivities(): Promise<Activity[]> {
const db = await getDB();
return db.getAll('activities');
}
export async function getActivitiesBySkill(skillId: string): Promise<Activity[]> {
const db = await getDB();
return db.getAllFromIndex('activities', 'by-skill', skillId);
}
export async function getRecentActivities(limit = 10): Promise<Activity[]> {
const db = await getDB();
const all = await db.getAllFromIndex('activities', 'by-timestamp');
return all.reverse().slice(0, limit);
}
export async function saveActivity(activity: Activity): Promise<void> {
const db = await getDB();
await db.put('activities', activity);
}
export async function deleteActivity(id: string): Promise<void> {
const db = await getDB();
await db.delete('activities', id);
}
// User Stats
export async function getUserStats(): Promise<UserStats> {
const db = await getDB();
const stats = await db.get('stats', 'user-stats');
return (
stats ?? {
totalXp: 0,
totalSkills: 0,
highestLevel: 0,
streakDays: 0,
lastActivityDate: null,
}
);
}
export async function saveUserStats(stats: UserStats): Promise<void> {
const db = await getDB();
await db.put('stats', stats, 'user-stats');
}
// Utility: Recalculate stats from all skills
export async function recalculateStats(): Promise<UserStats> {
const skills = await getAllSkills();
const activities = await getAllActivities();
const stats: UserStats = {
totalXp: skills.reduce((sum, s) => sum + s.totalXp, 0),
totalSkills: skills.length,
highestLevel: skills.reduce((max, s) => Math.max(max, s.level), 0),
streakDays: calculateStreak(activities),
lastActivityDate: activities.length > 0 ? activities[activities.length - 1].timestamp : null,
};
await saveUserStats(stats);
return stats;
}
function calculateStreak(activities: Activity[]): number {
if (activities.length === 0) return 0;
const today = new Date();
today.setHours(0, 0, 0, 0);
const sortedDates = activities
.map((a) => {
const d = new Date(a.timestamp);
d.setHours(0, 0, 0, 0);
return d.getTime();
})
.filter((v, i, a) => a.indexOf(v) === i) // unique dates
.sort((a, b) => b - a); // newest first
let streak = 0;
let expectedDate = today.getTime();
for (const date of sortedDates) {
if (date === expectedDate || date === expectedDate - 86400000) {
streak++;
expectedDate = date - 86400000;
} else if (date < expectedDate - 86400000) {
break;
}
}
return streak;
}
// Export all data (for backup)
export async function exportData(): Promise<{
skills: Skill[];
activities: Activity[];
stats: UserStats;
}> {
const [skills, activities, stats] = await Promise.all([
getAllSkills(),
getAllActivities(),
getUserStats(),
]);
return { skills, activities, stats };
}
// Import data (restore backup)
export async function importData(data: {
skills: Skill[];
activities: Activity[];
stats: UserStats;
}): Promise<void> {
const db = await getDB();
const tx = db.transaction(['skills', 'activities', 'stats'], 'readwrite');
// Clear existing data
await tx.objectStore('skills').clear();
await tx.objectStore('activities').clear();
// Import new data
for (const skill of data.skills) {
await tx.objectStore('skills').put(skill);
}
for (const activity of data.activities) {
await tx.objectStore('activities').put(activity);
}
await tx.objectStore('stats').put(data.stats, 'user-stats');
await tx.done;
}

View file

@ -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<Skill[]>([]);
let activities = $state<Activity[]>([]);
let userStats = $state<UserStats>({
totalXp: 0,
totalSkills: 0,
highestLevel: 0,
streakDays: 0,
lastActivityDate: null,
});
let isLoading = $state(true);
let initialized = $state(false);
// Derived values
const skillsByBranch = $derived(() => {
const grouped: Record<SkillBranch, Skill[]> = {
intellect: [],
body: [],
creativity: [],
social: [],
practical: [],
mindset: [],
custom: [],
};
for (const skill of skills) {
grouped[skill.branch].push(skill);
}
return grouped;
});
const topSkills = $derived(() => {
return [...skills].sort((a, b) => b.totalXp - a.totalXp).slice(0, 5);
});
const recentActivities = $derived(() => {
return [...activities].sort((a, b) =>
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
).slice(0, 10);
});
const branchStats = $derived(() => {
const stats: Record<SkillBranch, { count: number; totalXp: number; avgLevel: number }> = {} as 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<Skill>): Promise<Skill> {
const skill = createDefaultSkill(data);
await storage.saveSkill(skill);
skills = [...skills, skill];
await updateStats();
return skill;
}
async function updateSkill(id: string, updates: Partial<Skill>): Promise<void> {
const index = skills.findIndex((s) => s.id === id);
if (index === -1) return;
const 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<void> {
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<void> {
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,
};

View file

@ -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> = {}): 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(),
};
}

View file

@ -0,0 +1,32 @@
<script lang="ts">
import '../app.css';
import { onMount } from 'svelte';
import { skillStore } from '$lib/stores/skills.svelte';
let { children } = $props();
let loading = $state(true);
onMount(async () => {
await skillStore.initialize();
loading = false;
});
</script>
<svelte:head>
<title>SkillTree - Level Up Your Life</title>
<meta name="description" content="Track your skills like a game. Level up in real life." />
</svelte:head>
{#if loading}
<div class="flex min-h-screen items-center justify-center bg-gray-900">
<div class="text-center">
<div class="mb-4 text-6xl">🌳</div>
<div class="text-xl text-gray-300">Loading SkillTree...</div>
</div>
</div>
{:else}
<div class="min-h-screen bg-gray-900 text-gray-100">
{@render children()}
</div>
{/if}

View file

@ -0,0 +1,178 @@
<script lang="ts">
import { skillStore } from '$lib/stores/skills.svelte';
import { BRANCH_INFO, LEVEL_NAMES, xpProgress, xpForNextLevel } from '$lib/types';
import type { Skill, SkillBranch } from '$lib/types';
import SkillCard from '$lib/components/SkillCard.svelte';
import AddSkillModal from '$lib/components/AddSkillModal.svelte';
import AddXpModal from '$lib/components/AddXpModal.svelte';
import StatsOverview from '$lib/components/StatsOverview.svelte';
import {
Plus,
TreeDeciduous,
Trophy,
Zap,
TrendingUp,
} from 'lucide-svelte';
let showAddSkillModal = $state(false);
let showAddXpModal = $state(false);
let selectedSkillForXp = $state<Skill | null>(null);
let selectedBranch = $state<SkillBranch | 'all'>('all');
const filteredSkills = $derived(() => {
if (selectedBranch === 'all') return skillStore.skills;
return skillStore.skills.filter((s) => s.branch === selectedBranch);
});
function openAddXpModal(skill: Skill) {
selectedSkillForXp = skill;
showAddXpModal = true;
}
function closeAddXpModal() {
showAddXpModal = false;
selectedSkillForXp = null;
}
</script>
<div class="min-h-screen">
<!-- Header -->
<header class="border-b border-gray-800 bg-gray-900/80 backdrop-blur-sm sticky top-0 z-40">
<div class="mx-auto max-w-7xl px-4 py-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<TreeDeciduous class="h-8 w-8 text-emerald-500" />
<h1 class="text-2xl font-bold text-white">SkillTree</h1>
</div>
<button
onclick={() => (showAddSkillModal = true)}
class="flex items-center gap-2 rounded-lg bg-emerald-600 px-4 py-2 font-medium text-white transition-colors hover:bg-emerald-500"
>
<Plus class="h-5 w-5" />
Skill hinzufügen
</button>
</div>
</div>
</header>
<main class="mx-auto max-w-7xl px-4 py-8">
<!-- Stats Overview -->
<StatsOverview />
<!-- Branch Filter -->
<div class="mb-6 mt-8">
<div class="flex flex-wrap gap-2">
<button
onclick={() => (selectedBranch = 'all')}
class="rounded-full px-4 py-2 text-sm font-medium transition-colors {selectedBranch === 'all'
? 'bg-emerald-600 text-white'
: 'bg-gray-800 text-gray-300 hover:bg-gray-700'}"
>
Alle ({skillStore.skills.length})
</button>
{#each Object.entries(BRANCH_INFO) as [branch, info]}
{@const count = skillStore.skills.filter((s) => s.branch === branch).length}
{#if count > 0 || branch !== 'custom'}
<button
onclick={() => (selectedBranch = branch as SkillBranch)}
class="rounded-full px-4 py-2 text-sm font-medium transition-colors {selectedBranch === branch
? 'bg-emerald-600 text-white'
: 'bg-gray-800 text-gray-300 hover:bg-gray-700'}"
>
{info.name} ({count})
</button>
{/if}
{/each}
</div>
</div>
<!-- Skills Grid -->
{#if filteredSkills().length === 0}
<div class="mt-16 text-center">
<div class="mx-auto mb-6 flex h-24 w-24 items-center justify-center rounded-full bg-gray-800">
<TreeDeciduous class="h-12 w-12 text-gray-600" />
</div>
<h2 class="mb-2 text-xl font-semibold text-gray-300">Noch keine Skills</h2>
<p class="mb-6 text-gray-500">
Füge deinen ersten Skill hinzu und beginne dein Abenteuer!
</p>
<button
onclick={() => (showAddSkillModal = true)}
class="inline-flex items-center gap-2 rounded-lg bg-emerald-600 px-6 py-3 font-medium text-white transition-colors hover:bg-emerald-500"
>
<Plus class="h-5 w-5" />
Ersten Skill erstellen
</button>
</div>
{:else}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each filteredSkills() as skill (skill.id)}
<SkillCard
{skill}
onAddXp={() => openAddXpModal(skill)}
onEdit={() => {}}
onDelete={() => skillStore.deleteSkill(skill.id)}
/>
{/each}
</div>
{/if}
<!-- Recent Activity -->
{#if skillStore.recentActivities().length > 0}
<div class="mt-12">
<h2 class="mb-4 flex items-center gap-2 text-lg font-semibold text-white">
<Zap class="h-5 w-5 text-yellow-500" />
Letzte Aktivitäten
</h2>
<div class="space-y-2">
{#each skillStore.recentActivities().slice(0, 5) as activity}
{@const skill = skillStore.getSkill(activity.skillId)}
{#if skill}
<div class="flex items-center justify-between rounded-lg bg-gray-800/50 px-4 py-3">
<div class="flex items-center gap-3">
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-emerald-900/50 text-emerald-400">
+{activity.xpEarned}
</div>
<div>
<span class="font-medium text-white">{skill.name}</span>
<span class="text-gray-400"> - {activity.description}</span>
</div>
</div>
<span class="text-sm text-gray-500">
{new Date(activity.timestamp).toLocaleDateString('de-DE')}
</span>
</div>
{/if}
{/each}
</div>
</div>
{/if}
</main>
</div>
<!-- Modals -->
{#if showAddSkillModal}
<AddSkillModal
onClose={() => (showAddSkillModal = false)}
onSave={async (skill) => {
await skillStore.addSkill(skill);
showAddSkillModal = false;
}}
/>
{/if}
{#if showAddXpModal && selectedSkillForXp}
<AddXpModal
skill={selectedSkillForXp}
onClose={closeAddXpModal}
onSave={async (xp, description, duration) => {
if (selectedSkillForXp) {
const result = await skillStore.addXp(selectedSkillForXp.id, xp, description, duration);
if (result.leveledUp) {
// Could show a level-up celebration here
}
}
closeAddXpModal();
}}
/>
{/if}

View file

@ -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"
}
]
}

View file

@ -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;

View file

@ -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"
}
}

View file

@ -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'],
},
});

View file

@ -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"
}