Merge pull request #28 from Memo-2023/claude/skill-tree-app-planning-CO4xH

Add SkillTree app with backend API and web frontend
This commit is contained in:
Till JS 2026-01-29 12:13:20 +01:00 committed by GitHub
commit 5e92a52b8e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
62 changed files with 4446 additions and 1 deletions

View file

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

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,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"]

View file

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

View file

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

View file

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

View file

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

View file

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

View file

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

View file

@ -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<Activity[]> {
return this.db
.select()
.from(activities)
.where(eq(activities.userId, userId))
.orderBy(desc(activities.timestamp))
.limit(limit);
}
async findBySkill(userId: string, skillId: string): Promise<Activity[]> {
return this.db
.select()
.from(activities)
.where(eq(activities.skillId, skillId))
.orderBy(desc(activities.timestamp));
}
async getRecent(userId: string, limit = 10): Promise<Activity[]> {
return this.db
.select()
.from(activities)
.where(eq(activities.userId, userId))
.orderBy(desc(activities.timestamp))
.limit(limit);
}
}

View file

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

View file

@ -0,0 +1,38 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema';
let db: ReturnType<typeof drizzle<typeof schema>> | null = null;
let connection: ReturnType<typeof postgres> | 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<typeof getDb>;

View file

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

View file

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

View file

@ -0,0 +1,3 @@
export * from './skills.schema';
export * from './activities.schema';
export * from './user-stats.schema';

View file

@ -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<SkillBranch>(),
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;

View file

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

View file

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

View file

@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
@Module({
controllers: [HealthController],
})
export class HealthModule {}

View file

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

View file

@ -0,0 +1,3 @@
export * from './metrics.module';
export * from './metrics.service';
export * from './metrics.controller';

View file

@ -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<string> {
return this.metricsService.getMetrics();
}
}

View file

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

View file

@ -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<string> {
return this.registry.metrics();
}
}

View file

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

View file

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

View file

@ -0,0 +1,3 @@
export * from './create-skill.dto';
export * from './update-skill.dto';
export * from './add-xp.dto';

View file

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

View file

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

View file

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

View file

@ -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<Skill[]> {
return this.db.select().from(skills).where(eq(skills.userId, userId)).orderBy(desc(skills.totalXp));
}
async findByBranch(userId: string, branch: string): Promise<Skill[]> {
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<Skill | null> {
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<Skill> {
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<Skill> {
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<Skill> {
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<void> {
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<void> {
// 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<number> {
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;
}
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,37 @@
{
"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-auth": "workspace:*",
"@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,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 = `<script>
window.__PUBLIC_MANA_CORE_AUTH_URL__ = "${PUBLIC_MANA_CORE_AUTH_URL_CLIENT}";
window.__PUBLIC_BACKEND_URL__ = "${PUBLIC_BACKEND_URL_CLIENT}";
</script>`;
return html.replace('<head>', `<head>${envScript}`);
},
});
};

View file

@ -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<Activity[]> {
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<ActivitiesResponse>(`/api/v1/activities${queryString}`);
return response.activities;
}
export async function getRecentActivities(limit = 10): Promise<Activity[]> {
return getActivities(undefined, limit);
}
export async function getSkillActivities(skillId: string): Promise<Activity[]> {
return getActivities(skillId);
}

View file

@ -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<string, string>;
}
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<T>(endpoint: string, options: ApiOptions = {}): Promise<T> {
const { method = 'GET', body, headers = {} } = options;
const requestHeaders: Record<string, string> = {
'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<T>;
}
get<T>(endpoint: string, headers?: Record<string, string>): Promise<T> {
return this.fetch<T>(endpoint, { method: 'GET', headers });
}
post<T>(endpoint: string, body?: unknown, headers?: Record<string, string>): Promise<T> {
return this.fetch<T>(endpoint, { method: 'POST', body, headers });
}
put<T>(endpoint: string, body?: unknown, headers?: Record<string, string>): Promise<T> {
return this.fetch<T>(endpoint, { method: 'PUT', body, headers });
}
delete<T>(endpoint: string, headers?: Record<string, string>): Promise<T> {
return this.fetch<T>(endpoint, { method: 'DELETE', headers });
}
}
export const apiClient = new ApiClient();

View file

@ -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<Skill[]> {
const queryString = branch ? `?branch=${branch}` : '';
const response = await apiClient.get<SkillsResponse>(`/api/v1/skills${queryString}`);
return response.skills;
}
export async function getSkill(id: string): Promise<Skill> {
const response = await apiClient.get<SkillResponse>(`/api/v1/skills/${id}`);
return response.skill;
}
export async function createSkill(data: CreateSkillDto): Promise<Skill> {
const response = await apiClient.post<SkillResponse>('/api/v1/skills', data);
return response.skill;
}
export async function updateSkill(id: string, data: UpdateSkillDto): Promise<Skill> {
const response = await apiClient.put<SkillResponse>(`/api/v1/skills/${id}`, data);
return response.skill;
}
export async function deleteSkill(id: string): Promise<void> {
await apiClient.delete(`/api/v1/skills/${id}`);
}
export async function addXp(skillId: string, data: AddXpDto): Promise<AddXpResponse> {
return await apiClient.post<AddXpResponse>(`/api/v1/skills/${skillId}/xp`, data);
}
export async function getStats(): Promise<UserStats> {
const response = await apiClient.get<StatsResponse>('/api/v1/skills/stats');
return response.stats;
}

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,196 @@
<script lang="ts">
import type { Skill, SkillBranch } from '$lib/types';
import { BRANCH_INFO } from '$lib/types';
import { X, Trash2 } from 'lucide-svelte';
interface Props {
skill: Skill;
onClose: () => void;
onSave: (updates: Partial<Skill>) => Promise<void>;
onDelete: () => void;
}
let { skill, onClose, onSave, onDelete }: Props = $props();
let name = $state(skill.name);
let description = $state(skill.description);
let branch = $state<SkillBranch>(skill.branch);
let saving = $state(false);
let showDeleteConfirm = $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,
});
onClose();
} finally {
saving = false;
}
}
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
onClose();
}
}
function confirmDelete() {
onDelete();
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">Skill bearbeiten</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>
{#if showDeleteConfirm}
<!-- Delete Confirmation -->
<div class="text-center">
<div class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-red-500/20">
<Trash2 class="h-8 w-8 text-red-500" />
</div>
<h3 class="mb-2 text-lg font-semibold text-white">Skill löschen?</h3>
<p class="mb-6 text-gray-400">
"{skill.name}" und alle zugehörigen Aktivitäten werden unwiderruflich gelöscht.
</p>
<div class="flex gap-3">
<button
onclick={() => (showDeleteConfirm = false)}
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
onclick={confirmDelete}
class="flex-1 rounded-lg bg-red-600 px-4 py-2 font-medium text-white transition-colors hover:bg-red-500"
>
Löschen
</button>
</div>
</div>
{:else}
<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 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>
<!-- Stats (read-only) -->
<div class="rounded-lg bg-gray-700/50 p-3">
<div class="grid grid-cols-3 gap-4 text-center text-sm">
<div>
<div class="text-gray-400">Level</div>
<div class="font-semibold text-white">{skill.level}</div>
</div>
<div>
<div class="text-gray-400">Total XP</div>
<div class="font-semibold text-white">{skill.totalXp.toLocaleString()}</div>
</div>
<div>
<div class="text-gray-400">Erstellt</div>
<div class="font-semibold text-white">
{new Date(skill.createdAt).toLocaleDateString('de-DE')}
</div>
</div>
</div>
</div>
<!-- Actions -->
<div class="flex gap-3 pt-4">
<button
type="button"
onclick={() => (showDeleteConfirm = true)}
class="rounded-lg bg-red-600/20 p-2 text-red-400 transition-colors hover:bg-red-600/30"
title="Löschen"
>
<Trash2 class="h-5 w-5" />
</button>
<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...' : 'Speichern'}
</button>
</div>
</form>
{/if}
</div>
</div>

View file

@ -0,0 +1,171 @@
<script lang="ts">
import { LEVEL_NAMES } from '$lib/types';
import { Star, Trophy, Sparkles } from 'lucide-svelte';
import { onMount } from 'svelte';
interface Props {
skillName: string;
newLevel: number;
onClose: () => void;
}
let { skillName, newLevel, onClose }: Props = $props();
const levelName = LEVEL_NAMES[newLevel] ?? 'Unbekannt';
// Auto-close after 4 seconds
onMount(() => {
const timer = setTimeout(onClose, 4000);
return () => clearTimeout(timer);
});
function getLevelColor(level: number): string {
const colors = [
'from-gray-500 to-gray-600',
'from-blue-500 to-blue-600',
'from-purple-500 to-purple-600',
'from-pink-500 to-pink-600',
'from-orange-500 to-orange-600',
'from-yellow-400 to-yellow-500',
];
return colors[level] ?? colors[0];
}
</script>
<div
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-sm"
onclick={onClose}
role="dialog"
aria-modal="true"
>
<div class="celebration-container text-center">
<!-- Sparkle effects -->
<div class="sparkles">
{#each Array(12) as _, i}
<div
class="sparkle"
style="--delay: {i * 0.1}s; --angle: {i * 30}deg"
>
<Sparkles class="h-6 w-6 text-yellow-400" />
</div>
{/each}
</div>
<!-- Main content -->
<div class="relative z-10">
<!-- Trophy icon -->
<div class="mx-auto mb-4 flex h-24 w-24 items-center justify-center rounded-full bg-gradient-to-br {getLevelColor(newLevel)} level-up-bounce shadow-lg shadow-yellow-500/30">
<Trophy class="h-12 w-12 text-white" />
</div>
<!-- Level up text -->
<h2 class="mb-2 text-3xl font-bold text-white level-up-text">
LEVEL UP!
</h2>
<!-- Skill name -->
<p class="mb-4 text-xl text-gray-300">{skillName}</p>
<!-- New level badge -->
<div class="inline-flex items-center gap-2 rounded-full bg-gradient-to-r {getLevelColor(newLevel)} px-6 py-3 text-lg font-bold text-white shadow-lg">
<Star class="h-5 w-5 fill-current" />
Level {newLevel} - {levelName}
<Star class="h-5 w-5 fill-current" />
</div>
<!-- Stars -->
<div class="mt-6 flex justify-center gap-2">
{#each Array(newLevel) as _, i}
<Star
class="h-8 w-8 fill-yellow-400 text-yellow-400 star-pop"
style="animation-delay: {0.5 + i * 0.1}s"
/>
{/each}
</div>
<!-- Click to close -->
<p class="mt-6 text-sm text-gray-500">Klicken zum Schließen</p>
</div>
</div>
</div>
<style>
.celebration-container {
position: relative;
padding: 2rem;
}
.sparkles {
position: absolute;
inset: 0;
pointer-events: none;
}
.sparkle {
position: absolute;
top: 50%;
left: 50%;
animation: sparkle-fly 1s ease-out forwards;
animation-delay: var(--delay);
opacity: 0;
}
@keyframes sparkle-fly {
0% {
transform: translate(-50%, -50%) rotate(var(--angle)) translateY(0);
opacity: 1;
}
100% {
transform: translate(-50%, -50%) rotate(var(--angle)) translateY(-150px);
opacity: 0;
}
}
.level-up-bounce {
animation: level-bounce 0.6s ease-out;
}
@keyframes level-bounce {
0% {
transform: scale(0);
}
50% {
transform: scale(1.2);
}
70% {
transform: scale(0.9);
}
100% {
transform: scale(1);
}
}
.level-up-text {
animation: text-glow 1s ease-in-out infinite alternate;
}
@keyframes text-glow {
from {
text-shadow: 0 0 10px rgba(251, 191, 36, 0.5);
}
to {
text-shadow: 0 0 30px rgba(251, 191, 36, 0.8), 0 0 60px rgba(251, 191, 36, 0.4);
}
}
:global(.star-pop) {
opacity: 0;
animation: star-pop 0.4s ease-out forwards;
}
@keyframes star-pop {
0% {
transform: scale(0) rotate(-180deg);
opacity: 0;
}
100% {
transform: scale(1) rotate(0deg);
opacity: 1;
}
}
</style>

View file

@ -0,0 +1,109 @@
<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={onEdit}
class="rounded-lg bg-gray-600/20 p-2 text-gray-400 opacity-0 transition-all hover:bg-gray-600/30 hover:text-white group-hover:opacity-100"
title="Bearbeiten"
>
<Edit class="h-4 w-4" />
</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,211 @@
<script lang="ts">
import type { Skill, SkillBranch } from '$lib/types';
import { BRANCH_INFO } from '$lib/types';
import { X, Plus, Sparkles } from 'lucide-svelte';
interface Props {
onClose: () => void;
onAddSkill: (skill: Partial<Skill>) => Promise<void>;
}
let { onClose, onAddSkill }: Props = $props();
interface SkillTemplate {
name: string;
description: string;
branch: SkillBranch;
}
const templates: Record<string, SkillTemplate[]> = {
'Web Developer': [
{ name: 'HTML & CSS', description: 'Grundlagen der Webentwicklung', branch: 'intellect' },
{ name: 'JavaScript', description: 'Die Sprache des Webs', branch: 'intellect' },
{ name: 'TypeScript', description: 'Typsicheres JavaScript', branch: 'intellect' },
{ name: 'React', description: 'UI-Bibliothek für moderne Apps', branch: 'intellect' },
{ name: 'Node.js', description: 'Backend mit JavaScript', branch: 'intellect' },
{ name: 'Git', description: 'Versionskontrolle', branch: 'practical' },
],
'Fitness & Gesundheit': [
{ name: 'Krafttraining', description: 'Muskelaufbau und Stärke', branch: 'body' },
{ name: 'Ausdauer', description: 'Cardio und Kondition', branch: 'body' },
{ name: 'Yoga', description: 'Flexibilität und Balance', branch: 'body' },
{ name: 'Ernährung', description: 'Gesunde Essgewohnheiten', branch: 'body' },
{ name: 'Schlaf', description: 'Erholsamer Schlaf', branch: 'mindset' },
{ name: 'Stressmanagement', description: 'Umgang mit Stress', branch: 'mindset' },
],
'Kreative Künste': [
{ name: 'Zeichnen', description: 'Grundlagen des Zeichnens', branch: 'creativity' },
{ name: 'Malen', description: 'Farben und Techniken', branch: 'creativity' },
{ name: 'Fotografie', description: 'Bilder einfangen', branch: 'creativity' },
{ name: 'Musik', description: 'Instrument spielen', branch: 'creativity' },
{ name: 'Schreiben', description: 'Kreatives Schreiben', branch: 'creativity' },
{ name: 'Design', description: 'Visuelles Design', branch: 'creativity' },
],
'Sprachen': [
{ name: 'Englisch', description: 'Die Weltsprache', branch: 'intellect' },
{ name: 'Spanisch', description: 'Spanisch sprechen', branch: 'intellect' },
{ name: 'Französisch', description: 'La langue française', branch: 'intellect' },
{ name: 'Japanisch', description: '日本語', branch: 'intellect' },
{ name: 'Deutsch', description: 'Deutsche Sprache', branch: 'intellect' },
],
'Produktivität': [
{ name: 'Zeitmanagement', description: 'Zeit effektiv nutzen', branch: 'mindset' },
{ name: 'Fokus', description: 'Konzentration verbessern', branch: 'mindset' },
{ name: 'Organisation', description: 'Ordnung und Struktur', branch: 'practical' },
{ name: 'Kommunikation', description: 'Klar kommunizieren', branch: 'social' },
{ name: 'Problemlösung', description: 'Analytisches Denken', branch: 'intellect' },
],
'Kochen & Haushalt': [
{ name: 'Kochen', description: 'Leckere Gerichte zubereiten', branch: 'practical' },
{ name: 'Backen', description: 'Süßes und Brot', branch: 'practical' },
{ name: 'Haushaltsführung', description: 'Sauberkeit und Ordnung', branch: 'practical' },
{ name: 'Gartenarbeit', description: 'Grüner Daumen', branch: 'practical' },
{ name: 'Heimwerken', description: 'Reparaturen selbst machen', branch: 'practical' },
],
};
let selectedTemplate = $state<string | null>(null);
let addedSkills = $state<Set<string>>(new Set());
let adding = $state(false);
async function addSkill(template: SkillTemplate) {
if (addedSkills.has(template.name)) return;
adding = true;
try {
await onAddSkill(template);
addedSkills = new Set([...addedSkills, template.name]);
} finally {
adding = false;
}
}
async function addAllFromTemplate(templateName: string) {
const skills = templates[templateName];
if (!skills) return;
adding = true;
try {
for (const skill of skills) {
if (!addedSkills.has(skill.name)) {
await onAddSkill(skill);
addedSkills = new Set([...addedSkills, skill.name]);
}
}
} finally {
adding = false;
}
}
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
onClose();
}
}
</script>
<div
class="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto bg-black/60 backdrop-blur-sm p-4"
onclick={handleBackdropClick}
role="dialog"
aria-modal="true"
>
<div class="w-full max-w-2xl rounded-2xl border border-gray-700 bg-gray-800 p-6 shadow-xl my-8">
<!-- Header -->
<div class="mb-6 flex items-center justify-between">
<div class="flex items-center gap-2">
<Sparkles class="h-6 w-6 text-yellow-500" />
<h2 class="text-xl font-bold text-white">Skill-Vorlagen</h2>
</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>
<p class="mb-6 text-gray-400">
Starte schnell mit vorgefertigten Skill-Sets. Wähle eine Vorlage und füge einzelne Skills oder alle auf einmal hinzu.
</p>
<!-- Template List -->
<div class="space-y-4 max-h-[60vh] overflow-y-auto pr-2">
{#each Object.entries(templates) as [name, skills]}
<div class="rounded-xl border border-gray-700 bg-gray-900/50 overflow-hidden">
<!-- Template Header -->
<button
onclick={() => (selectedTemplate = selectedTemplate === name ? null : name)}
class="w-full flex items-center justify-between p-4 text-left hover:bg-gray-800/50 transition-colors"
>
<div>
<h3 class="font-semibold text-white">{name}</h3>
<p class="text-sm text-gray-400">{skills.length} Skills</p>
</div>
<div class="flex items-center gap-2">
<button
onclick={(e) => {
e.stopPropagation();
addAllFromTemplate(name);
}}
disabled={adding}
class="rounded-lg bg-emerald-600/20 px-3 py-1.5 text-sm font-medium text-emerald-400 transition-colors hover:bg-emerald-600/30 disabled:opacity-50"
>
Alle hinzufügen
</button>
<span class="text-gray-500 text-xl">
{selectedTemplate === name ? '' : '+'}
</span>
</div>
</button>
<!-- Expanded Skills -->
{#if selectedTemplate === name}
<div class="border-t border-gray-700 p-4 space-y-2">
{#each skills as skill}
{@const isAdded = addedSkills.has(skill.name)}
<div class="flex items-center justify-between rounded-lg bg-gray-800/50 px-3 py-2">
<div class="flex items-center gap-3">
<span
class="h-3 w-3 rounded-full"
style="background-color: {BRANCH_INFO[skill.branch].color}"
></span>
<div>
<span class="font-medium text-white">{skill.name}</span>
<span class="text-gray-400 text-sm"> - {skill.description}</span>
</div>
</div>
<button
onclick={() => addSkill(skill)}
disabled={isAdded || adding}
class="rounded-lg p-1.5 transition-colors {isAdded
? 'bg-emerald-600/20 text-emerald-400'
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'}"
>
{#if isAdded}
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
{:else}
<Plus class="h-4 w-4" />
{/if}
</button>
</div>
{/each}
</div>
{/if}
</div>
{/each}
</div>
<!-- Footer -->
<div class="mt-6 flex justify-end">
<button
onclick={onClose}
class="rounded-lg bg-gray-700 px-4 py-2 font-medium text-white transition-colors hover:bg-gray-600"
>
Fertig
</button>
</div>
</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,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<typeof initializeWebAuth>['authService'] | null = null;
let _tokenManager: ReturnType<typeof initializeWebAuth>['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<UserData | null>(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<string | null> {
const tokenManager = getTokenManager();
if (!tokenManager) {
return null;
}
return await tokenManager.getValidToken();
},
};

View file

@ -0,0 +1,303 @@
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';
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<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);
let useApi = $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 Record<SkillBranch, { count: number; totalXp: number; avgLevel: number }>;
for (const branch of Object.keys(BRANCH_INFO) as SkillBranch[]) {
const branchSkills = skills.filter((s) => s.branch === branch);
stats[branch] = {
count: branchSkills.length,
totalXp: branchSkills.reduce((sum, s) => sum + s.totalXp, 0),
avgLevel:
branchSkills.length > 0
? branchSkills.reduce((sum, s) => sum + s.level, 0) / branchSkills.length
: 0,
};
}
return stats;
});
// Actions
async function initialize() {
if (initialized) return;
isLoading = true;
try {
// 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<Skill>): Promise<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<Skill>): Promise<void> {
const index = skills.findIndex((s) => s.id === id);
if (index === -1) return;
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<void> {
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 }> {
const index = skills.findIndex((s) => s.id === skillId);
if (index === -1) return { leveledUp: false, newLevel: 0 };
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 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> {
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 {
return skills.find((s) => s.id === id);
}
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 useApi() {
return useApi;
},
initialize,
reinitialize,
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,311 @@
<script lang="ts">
import { skillStore } from '$lib/stores/skills.svelte';
import { BRANCH_INFO } 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 EditSkillModal from '$lib/components/EditSkillModal.svelte';
import LevelUpCelebration from '$lib/components/LevelUpCelebration.svelte';
import StatsOverview from '$lib/components/StatsOverview.svelte';
import SkillTemplates from '$lib/components/SkillTemplates.svelte';
import {
Plus,
TreeDeciduous,
Zap,
Download,
Upload,
Sparkles,
Network,
} from 'lucide-svelte';
// Modal states
let showAddSkillModal = $state(false);
let showAddXpModal = $state(false);
let showEditSkillModal = $state(false);
let showTemplatesModal = $state(false);
let selectedSkill = $state<Skill | null>(null);
let selectedBranch = $state<SkillBranch | 'all'>('all');
// Level up celebration
let showLevelUp = $state(false);
let levelUpSkillName = $state('');
let levelUpNewLevel = $state(0);
const filteredSkills = $derived(() => {
if (selectedBranch === 'all') return skillStore.skills;
return skillStore.skills.filter((s) => s.branch === selectedBranch);
});
function openAddXpModal(skill: Skill) {
selectedSkill = skill;
showAddXpModal = true;
}
function openEditModal(skill: Skill) {
selectedSkill = skill;
showEditSkillModal = true;
}
function closeModals() {
showAddXpModal = false;
showEditSkillModal = false;
selectedSkill = null;
}
function triggerLevelUp(skillName: string, newLevel: number) {
levelUpSkillName = skillName;
levelUpNewLevel = newLevel;
showLevelUp = true;
}
async function handleAddXp(xp: number, description: string, duration?: number) {
if (!selectedSkill) return;
const skillName = selectedSkill.name;
const result = await skillStore.addXp(selectedSkill.id, xp, description, duration);
closeModals();
if (result.leveledUp) {
triggerLevelUp(skillName, result.newLevel);
}
}
async function handleExport() {
const { exportData } = await import('$lib/services/storage');
const data = await exportData();
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `skilltree-backup-${new Date().toISOString().split('T')[0]}.json`;
a.click();
URL.revokeObjectURL(url);
}
async function handleImport() {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
try {
const text = await file.text();
const data = JSON.parse(text);
const { importData } = await import('$lib/services/storage');
await importData(data);
// Reload the store
window.location.reload();
} catch (error) {
console.error('Import failed:', error);
alert('Import fehlgeschlagen. Bitte überprüfe die Datei.');
}
};
input.click();
}
</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>
<div class="flex items-center gap-2">
<!-- Tree View -->
<a
href="/tree"
class="rounded-lg p-2 text-gray-400 transition-colors hover:bg-gray-800 hover:text-emerald-400"
title="Skill-Tree Ansicht"
>
<Network class="h-5 w-5" />
</a>
<!-- Templates -->
<button
onclick={() => (showTemplatesModal = true)}
class="rounded-lg p-2 text-gray-400 transition-colors hover:bg-gray-800 hover:text-yellow-500"
title="Skill-Vorlagen"
>
<Sparkles class="h-5 w-5" />
</button>
<!-- Export/Import -->
<button
onclick={handleExport}
class="rounded-lg p-2 text-gray-400 transition-colors hover:bg-gray-800 hover:text-white"
title="Daten exportieren"
>
<Download class="h-5 w-5" />
</button>
<button
onclick={handleImport}
class="rounded-lg p-2 text-gray-400 transition-colors hover:bg-gray-800 hover:text-white"
title="Daten importieren"
>
<Upload class="h-5 w-5" />
</button>
<!-- Add Skill -->
<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" />
<span class="hidden sm:inline">Skill hinzufügen</span>
</button>
</div>
</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={() => openEditModal(skill)}
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-sm font-medium 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 && selectedSkill}
<AddXpModal
skill={selectedSkill}
onClose={closeModals}
onSave={handleAddXp}
/>
{/if}
{#if showEditSkillModal && selectedSkill}
<EditSkillModal
skill={selectedSkill}
onClose={closeModals}
onSave={async (updates) => {
if (selectedSkill) {
await skillStore.updateSkill(selectedSkill.id, updates);
}
}}
onDelete={() => {
if (selectedSkill) {
skillStore.deleteSkill(selectedSkill.id);
}
}}
/>
{/if}
{#if showLevelUp}
<LevelUpCelebration
skillName={levelUpSkillName}
newLevel={levelUpNewLevel}
onClose={() => (showLevelUp = false)}
/>
{/if}
{#if showTemplatesModal}
<SkillTemplates
onClose={() => (showTemplatesModal = false)}
onAddSkill={async (skill) => {
await skillStore.addSkill(skill);
}}
/>
{/if}

View file

@ -0,0 +1,241 @@
<script lang="ts">
import { skillStore } from '$lib/stores/skills.svelte';
import { BRANCH_INFO, LEVEL_NAMES } from '$lib/types';
import type { SkillBranch } from '$lib/types';
import { ArrowLeft, Star } from 'lucide-svelte';
// Group skills by branch for radial layout
const branches = Object.keys(BRANCH_INFO) as SkillBranch[];
// Calculate position for each branch (radial layout)
function getBranchPosition(branchIndex: number, total: number) {
const angle = (branchIndex / total) * 2 * Math.PI - Math.PI / 2;
const radius = 280;
return {
x: 400 + Math.cos(angle) * radius,
y: 400 + Math.sin(angle) * radius,
angle: (angle * 180) / Math.PI,
};
}
// Calculate skill position within a branch
function getSkillPosition(
branchIndex: number,
skillIndex: number,
skillCount: number,
total: number
) {
const branchAngle = (branchIndex / total) * 2 * Math.PI - Math.PI / 2;
const spreadAngle = 0.3; // How much skills spread within a branch
const baseRadius = 180;
const radiusStep = 60;
// Spread skills in a small arc
const skillAngle = branchAngle + (skillIndex - (skillCount - 1) / 2) * (spreadAngle / Math.max(skillCount - 1, 1));
const radius = baseRadius + skillIndex * radiusStep * 0.3;
return {
x: 400 + Math.cos(skillAngle) * radius,
y: 400 + Math.sin(skillAngle) * radius,
};
}
function getLevelColor(level: number): string {
const colors = ['#6b7280', '#3b82f6', '#8b5cf6', '#ec4899', '#f97316', '#fbbf24'];
return colors[level] ?? colors[0];
}
function getNodeSize(level: number): number {
return 24 + level * 6;
}
</script>
<svelte:head>
<title>Skill Tree View - SkillTree</title>
</svelte:head>
<div class="min-h-screen bg-gray-900 text-white">
<!-- 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 gap-4">
<a
href="/"
class="flex items-center gap-2 rounded-lg px-3 py-2 text-gray-400 transition-colors hover:bg-gray-800 hover:text-white"
>
<ArrowLeft class="h-5 w-5" />
Zurück
</a>
<h1 class="text-xl font-bold">Skill Tree Visualisierung</h1>
</div>
</div>
</header>
<main class="p-4">
{#if skillStore.skills.length === 0}
<div class="mt-16 text-center">
<p class="text-gray-400">Noch keine Skills vorhanden. Erstelle zuerst einige Skills!</p>
<a
href="/"
class="mt-4 inline-block rounded-lg bg-emerald-600 px-4 py-2 font-medium text-white hover:bg-emerald-500"
>
Skills erstellen
</a>
</div>
{:else}
<!-- Legend -->
<div class="mb-6 flex flex-wrap justify-center gap-4">
{#each Object.entries(BRANCH_INFO) as [branch, info]}
{@const count = skillStore.skills.filter((s) => s.branch === branch).length}
{#if count > 0}
<div class="flex items-center gap-2 rounded-full bg-gray-800 px-3 py-1.5 text-sm">
<span class="h-3 w-3 rounded-full" style="background-color: {info.color}"></span>
{info.name} ({count})
</div>
{/if}
{/each}
</div>
<!-- Tree SVG -->
<div class="flex justify-center overflow-auto">
<svg
viewBox="0 0 800 800"
class="max-w-full"
style="min-width: 600px; height: auto; max-height: 80vh;"
>
<!-- Background circles -->
<circle cx="400" cy="400" r="120" fill="none" stroke="#374151" stroke-width="1" stroke-dasharray="4" />
<circle cx="400" cy="400" r="200" fill="none" stroke="#374151" stroke-width="1" stroke-dasharray="4" />
<circle cx="400" cy="400" r="280" fill="none" stroke="#374151" stroke-width="1" stroke-dasharray="4" />
<!-- Center node -->
<circle cx="400" cy="400" r="50" fill="#10b981" opacity="0.2" />
<circle cx="400" cy="400" r="40" fill="#10b981" opacity="0.4" />
<circle cx="400" cy="400" r="30" fill="#10b981" />
<text x="400" y="405" text-anchor="middle" fill="white" font-size="12" font-weight="bold">
YOU
</text>
<!-- Branch lines and labels -->
{#each branches as branch, i}
{@const pos = getBranchPosition(i, branches.length)}
{@const branchSkills = skillStore.skills.filter((s) => s.branch === branch)}
{#if branchSkills.length > 0}
<!-- Line from center to branch -->
<line
x1="400"
y1="400"
x2={pos.x}
y2={pos.y}
stroke={BRANCH_INFO[branch].color}
stroke-width="2"
opacity="0.3"
/>
<!-- Branch label -->
<text
x={pos.x}
y={pos.y}
text-anchor="middle"
fill={BRANCH_INFO[branch].color}
font-size="14"
font-weight="bold"
dy="-20"
>
{BRANCH_INFO[branch].name}
</text>
<!-- Skills in this branch -->
{#each branchSkills as skill, j}
{@const skillPos = getSkillPosition(i, j, branchSkills.length, branches.length)}
{@const size = getNodeSize(skill.level)}
<!-- Connection line -->
<line
x1="400"
y1="400"
x2={skillPos.x}
y2={skillPos.y}
stroke={BRANCH_INFO[branch].color}
stroke-width="1"
opacity="0.2"
/>
<!-- Skill node -->
<g class="tree-node cursor-pointer" transform="translate({skillPos.x}, {skillPos.y})">
<!-- Glow effect for high level -->
{#if skill.level >= 4}
<circle
r={size + 8}
fill={getLevelColor(skill.level)}
opacity="0.2"
class="animate-pulse"
/>
{/if}
<!-- Node background -->
<circle
r={size}
fill="#1f2937"
stroke={getLevelColor(skill.level)}
stroke-width="3"
/>
<!-- Level indicator -->
<text
text-anchor="middle"
dy="5"
fill={getLevelColor(skill.level)}
font-size="14"
font-weight="bold"
>
{skill.level}
</text>
<!-- Skill name (on hover/always for important skills) -->
<title>{skill.name} (Level {skill.level} - {skill.totalXp} XP)</title>
</g>
<!-- Skill label -->
<text
x={skillPos.x}
y={skillPos.y + size + 16}
text-anchor="middle"
fill="#9ca3af"
font-size="10"
class="pointer-events-none"
>
{skill.name.length > 12 ? skill.name.slice(0, 12) + '...' : skill.name}
</text>
{/each}
{/if}
{/each}
</svg>
</div>
<!-- Level Legend -->
<div class="mt-8 flex flex-wrap justify-center gap-4">
{#each LEVEL_NAMES as name, level}
<div class="flex items-center gap-2 text-sm">
<div
class="flex h-6 w-6 items-center justify-center rounded-full border-2 text-xs font-bold"
style="border-color: {getLevelColor(level)}; color: {getLevelColor(level)}"
>
{level}
</div>
<span class="text-gray-400">{name}</span>
</div>
{/each}
</div>
{/if}
</main>
</div>
<style>
.tree-node {
transition: transform 0.2s ease;
}
.tree-node:hover {
transform: scale(1.15);
}
</style>

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

View file

@ -89,7 +89,7 @@ services:
SMTP_USER: ${SMTP_USER:-94cde5002@smtp-brevo.com}
SMTP_PASSWORD: ${SMTP_PASSWORD}
SMTP_FROM: ManaCore <noreply@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
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
# ============================================