-
+
{#if WidgetComponent}
{:else}
@@ -148,4 +147,3 @@
{/if}
-
diff --git a/apps/manacore/apps/web/src/lib/config/default-dashboard.ts b/apps/manacore/apps/web/src/lib/config/default-dashboard.ts
index ed9a4cd7f..44a57bbfb 100644
--- a/apps/manacore/apps/web/src/lib/config/default-dashboard.ts
+++ b/apps/manacore/apps/web/src/lib/config/default-dashboard.ts
@@ -11,12 +11,12 @@ import type { DashboardConfig } from '$lib/types/dashboard';
*/
export const DEFAULT_DASHBOARD_CONFIG: DashboardConfig = {
widgets: [
- // Row 0: Clock and Tasks Today
+ // Row 0: Clock, Tasks Today, Calendar
{
id: 'clock-timers-1',
type: 'clock-timers',
title: 'dashboard.widgets.clock.title',
- size: 'medium',
+ size: 'small',
position: { x: 0, y: 0 },
visible: true,
},
@@ -24,17 +24,16 @@ export const DEFAULT_DASHBOARD_CONFIG: DashboardConfig = {
id: 'tasks-today-1',
type: 'tasks-today',
title: 'dashboard.widgets.tasks_today.title',
- size: 'medium',
- position: { x: 6, y: 0 },
+ size: 'small',
+ position: { x: 4, y: 0 },
visible: true,
},
- // Row 1: Calendar (full width)
{
id: 'calendar-events-1',
type: 'calendar-events',
title: 'dashboard.widgets.calendar.title',
- size: 'large',
- position: { x: 0, y: 1 },
+ size: 'small',
+ position: { x: 8, y: 0 },
visible: true,
},
],
diff --git a/apps/skilltree/apps/backend/src/achievement/achievement-definitions.ts b/apps/skilltree/apps/backend/src/achievement/achievement-definitions.ts
new file mode 100644
index 000000000..90d97a268
--- /dev/null
+++ b/apps/skilltree/apps/backend/src/achievement/achievement-definitions.ts
@@ -0,0 +1,307 @@
+import type { NewAchievement } from '../db/schema';
+
+/**
+ * All achievement definitions. These are seeded into the DB on startup.
+ * Conditions are evaluated by the AchievementService after relevant events.
+ */
+export const ACHIEVEMENT_DEFINITIONS: NewAchievement[] = [
+ // === XP Achievements ===
+ {
+ id: 'xp_100',
+ name: 'Erste Schritte',
+ description: 'Sammle 100 XP insgesamt',
+ icon: 'star',
+ category: 'xp',
+ rarity: 'common',
+ xpReward: 10,
+ sortOrder: 1,
+ condition: { type: 'total_xp', threshold: 100 },
+ },
+ {
+ id: 'xp_1000',
+ name: 'Tausender-Club',
+ description: 'Sammle 1.000 XP insgesamt',
+ icon: 'star',
+ category: 'xp',
+ rarity: 'uncommon',
+ xpReward: 25,
+ sortOrder: 2,
+ condition: { type: 'total_xp', threshold: 1000 },
+ },
+ {
+ id: 'xp_5000',
+ name: 'XP-Sammler',
+ description: 'Sammle 5.000 XP insgesamt',
+ icon: 'star',
+ category: 'xp',
+ rarity: 'rare',
+ xpReward: 50,
+ sortOrder: 3,
+ condition: { type: 'total_xp', threshold: 5000 },
+ },
+ {
+ id: 'xp_10000',
+ name: 'XP-Legende',
+ description: 'Sammle 10.000 XP insgesamt',
+ icon: 'crown',
+ category: 'xp',
+ rarity: 'epic',
+ xpReward: 100,
+ sortOrder: 4,
+ condition: { type: 'total_xp', threshold: 10000 },
+ },
+ {
+ id: 'xp_50000',
+ name: 'Grenzenlos',
+ description: 'Sammle 50.000 XP insgesamt',
+ icon: 'crown',
+ category: 'xp',
+ rarity: 'legendary',
+ xpReward: 250,
+ sortOrder: 5,
+ condition: { type: 'total_xp', threshold: 50000 },
+ },
+
+ // === Skill Achievements ===
+ {
+ id: 'skills_1',
+ name: 'Der Anfang',
+ description: 'Erstelle deinen ersten Skill',
+ icon: 'plus',
+ category: 'skills',
+ rarity: 'common',
+ xpReward: 10,
+ sortOrder: 10,
+ condition: { type: 'total_skills', threshold: 1 },
+ },
+ {
+ id: 'skills_5',
+ name: 'Vielseitig',
+ description: 'Erstelle 5 Skills',
+ icon: 'grid',
+ category: 'skills',
+ rarity: 'uncommon',
+ xpReward: 25,
+ sortOrder: 11,
+ condition: { type: 'total_skills', threshold: 5 },
+ },
+ {
+ id: 'skills_10',
+ name: 'Skill-Sammler',
+ description: 'Erstelle 10 Skills',
+ icon: 'grid',
+ category: 'skills',
+ rarity: 'rare',
+ xpReward: 50,
+ sortOrder: 12,
+ condition: { type: 'total_skills', threshold: 10 },
+ },
+ {
+ id: 'skills_20',
+ name: 'Meister aller Klassen',
+ description: 'Erstelle 20 Skills',
+ icon: 'grid',
+ category: 'skills',
+ rarity: 'epic',
+ xpReward: 100,
+ sortOrder: 13,
+ condition: { type: 'total_skills', threshold: 20 },
+ },
+
+ // === Level Achievements ===
+ {
+ id: 'level_1',
+ name: 'Anfänger',
+ description: 'Erreiche Level 1 mit einem Skill',
+ icon: 'arrow-up',
+ category: 'levels',
+ rarity: 'common',
+ xpReward: 15,
+ sortOrder: 20,
+ condition: { type: 'highest_level', threshold: 1 },
+ },
+ {
+ id: 'level_3',
+ name: 'Kompetent',
+ description: 'Erreiche Level 3 mit einem Skill',
+ icon: 'arrow-up',
+ category: 'levels',
+ rarity: 'rare',
+ xpReward: 50,
+ sortOrder: 21,
+ condition: { type: 'highest_level', threshold: 3 },
+ },
+ {
+ id: 'level_5',
+ name: 'Meister',
+ description: 'Erreiche Level 5 mit einem Skill',
+ icon: 'crown',
+ category: 'levels',
+ rarity: 'legendary',
+ xpReward: 200,
+ sortOrder: 22,
+ condition: { type: 'highest_level', threshold: 5 },
+ },
+
+ // === Activity Achievements ===
+ {
+ id: 'activities_1',
+ name: 'Erste Aktion',
+ description: 'Logge deine erste Aktivität',
+ icon: 'lightning',
+ category: 'activities',
+ rarity: 'common',
+ xpReward: 5,
+ sortOrder: 30,
+ condition: { type: 'total_activities', threshold: 1 },
+ },
+ {
+ id: 'activities_10',
+ name: 'Dranbleiber',
+ description: 'Logge 10 Aktivitäten',
+ icon: 'lightning',
+ category: 'activities',
+ rarity: 'uncommon',
+ xpReward: 20,
+ sortOrder: 31,
+ condition: { type: 'total_activities', threshold: 10 },
+ },
+ {
+ id: 'activities_50',
+ name: 'Fleißig',
+ description: 'Logge 50 Aktivitäten',
+ icon: 'lightning',
+ category: 'activities',
+ rarity: 'rare',
+ xpReward: 50,
+ sortOrder: 32,
+ condition: { type: 'total_activities', threshold: 50 },
+ },
+ {
+ id: 'activities_100',
+ name: 'Unaufhaltsam',
+ description: 'Logge 100 Aktivitäten',
+ icon: 'fire',
+ category: 'activities',
+ rarity: 'epic',
+ xpReward: 100,
+ sortOrder: 33,
+ condition: { type: 'total_activities', threshold: 100 },
+ },
+ {
+ id: 'activities_500',
+ name: 'Maschine',
+ description: 'Logge 500 Aktivitäten',
+ icon: 'fire',
+ category: 'activities',
+ rarity: 'legendary',
+ xpReward: 250,
+ sortOrder: 34,
+ condition: { type: 'total_activities', threshold: 500 },
+ },
+
+ // === Streak Achievements ===
+ {
+ id: 'streak_3',
+ name: '3-Tage-Streak',
+ description: 'Halte einen 3-Tage-Streak',
+ icon: 'flame',
+ category: 'streak',
+ rarity: 'common',
+ xpReward: 15,
+ sortOrder: 40,
+ condition: { type: 'streak_days', threshold: 3 },
+ },
+ {
+ id: 'streak_7',
+ name: 'Wochenkrieger',
+ description: 'Halte einen 7-Tage-Streak',
+ icon: 'flame',
+ category: 'streak',
+ rarity: 'uncommon',
+ xpReward: 30,
+ sortOrder: 41,
+ condition: { type: 'streak_days', threshold: 7 },
+ },
+ {
+ id: 'streak_14',
+ name: 'Zwei-Wochen-Held',
+ description: 'Halte einen 14-Tage-Streak',
+ icon: 'flame',
+ category: 'streak',
+ rarity: 'rare',
+ xpReward: 75,
+ sortOrder: 42,
+ condition: { type: 'streak_days', threshold: 14 },
+ },
+ {
+ id: 'streak_30',
+ name: 'Monatsmeister',
+ description: 'Halte einen 30-Tage-Streak',
+ icon: 'flame',
+ category: 'streak',
+ rarity: 'epic',
+ xpReward: 150,
+ sortOrder: 43,
+ condition: { type: 'streak_days', threshold: 30 },
+ },
+ {
+ id: 'streak_100',
+ name: 'Hundert Tage',
+ description: 'Halte einen 100-Tage-Streak',
+ icon: 'flame',
+ category: 'streak',
+ rarity: 'legendary',
+ xpReward: 500,
+ sortOrder: 44,
+ condition: { type: 'streak_days', threshold: 100 },
+ },
+
+ // === Branch Achievements ===
+ {
+ id: 'branches_3',
+ name: 'Entdecker',
+ description: 'Habe Skills in 3 verschiedenen Branches',
+ icon: 'compass',
+ category: 'branches',
+ rarity: 'uncommon',
+ xpReward: 25,
+ sortOrder: 50,
+ condition: { type: 'unique_branches', threshold: 3 },
+ },
+ {
+ id: 'branches_all',
+ name: 'Universalgelehrter',
+ description: 'Habe Skills in allen 6 Branches',
+ icon: 'compass',
+ category: 'branches',
+ rarity: 'epic',
+ xpReward: 100,
+ sortOrder: 51,
+ condition: { type: 'unique_branches', threshold: 6 },
+ },
+
+ // === Special Achievements ===
+ {
+ id: 'single_xp_100',
+ name: 'Mammut-Session',
+ description: 'Verdiene 100+ XP in einer einzelnen Aktivität',
+ icon: 'zap',
+ category: 'special',
+ rarity: 'rare',
+ xpReward: 25,
+ sortOrder: 60,
+ condition: { type: 'single_activity_xp', threshold: 100 },
+ },
+ {
+ id: 'all_branches_level_1',
+ name: 'Allrounder',
+ description: 'Erreiche Level 1 in allen 6 Branches',
+ icon: 'shield',
+ category: 'special',
+ rarity: 'epic',
+ xpReward: 150,
+ sortOrder: 61,
+ condition: { type: 'all_branches_min_level', threshold: 1 },
+ },
+];
diff --git a/apps/skilltree/apps/backend/src/achievement/achievement.controller.ts b/apps/skilltree/apps/backend/src/achievement/achievement.controller.ts
new file mode 100644
index 000000000..83ef37fa8
--- /dev/null
+++ b/apps/skilltree/apps/backend/src/achievement/achievement.controller.ts
@@ -0,0 +1,27 @@
+import { Controller, Get, UseGuards } from '@nestjs/common';
+import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
+import { AchievementService } from './achievement.service';
+
+@Controller('achievements')
+@UseGuards(JwtAuthGuard)
+export class AchievementController {
+ constructor(private readonly achievementService: AchievementService) {}
+
+ @Get()
+ async findAll(@CurrentUser() user: CurrentUserData) {
+ const achievements = await this.achievementService.getAllForUser(user.userId);
+ return { achievements };
+ }
+
+ @Get('unlocked')
+ async findUnlocked(@CurrentUser() user: CurrentUserData) {
+ const achievements = await this.achievementService.getUnlockedForUser(user.userId);
+ return { achievements };
+ }
+
+ @Get('stats')
+ async getStats(@CurrentUser() user: CurrentUserData) {
+ const stats = await this.achievementService.getStats(user.userId);
+ return { stats };
+ }
+}
diff --git a/apps/skilltree/apps/backend/src/achievement/achievement.module.ts b/apps/skilltree/apps/backend/src/achievement/achievement.module.ts
new file mode 100644
index 000000000..41fe1c1bd
--- /dev/null
+++ b/apps/skilltree/apps/backend/src/achievement/achievement.module.ts
@@ -0,0 +1,10 @@
+import { Module } from '@nestjs/common';
+import { AchievementController } from './achievement.controller';
+import { AchievementService } from './achievement.service';
+
+@Module({
+ controllers: [AchievementController],
+ providers: [AchievementService],
+ exports: [AchievementService],
+})
+export class AchievementModule {}
diff --git a/apps/skilltree/apps/backend/src/achievement/achievement.service.ts b/apps/skilltree/apps/backend/src/achievement/achievement.service.ts
new file mode 100644
index 000000000..3e9f4836a
--- /dev/null
+++ b/apps/skilltree/apps/backend/src/achievement/achievement.service.ts
@@ -0,0 +1,264 @@
+import { Injectable, Inject, OnModuleInit, Logger } from '@nestjs/common';
+import { eq, and, sql } from 'drizzle-orm';
+import { DATABASE_TOKEN } from '../db/database.module';
+import { Database } from '../db/connection';
+import {
+ achievements,
+ userAchievements,
+ skills,
+ activities,
+ userStats,
+ Achievement,
+ UserAchievement,
+} from '../db/schema';
+import { ACHIEVEMENT_DEFINITIONS } from './achievement-definitions';
+
+export interface AchievementWithStatus extends Achievement {
+ unlocked: boolean;
+ unlockedAt: Date | null;
+ progress: number;
+}
+
+export interface AchievementUnlockResult {
+ achievement: Achievement;
+ xpReward: number;
+}
+
+@Injectable()
+export class AchievementService implements OnModuleInit {
+ private readonly logger = new Logger(AchievementService.name);
+
+ constructor(@Inject(DATABASE_TOKEN) private db: Database) {}
+
+ async onModuleInit() {
+ await this.seedAchievements();
+ }
+
+ private async seedAchievements(): Promise
{
+ for (const def of ACHIEVEMENT_DEFINITIONS) {
+ await this.db
+ .insert(achievements)
+ .values(def)
+ .onConflictDoUpdate({
+ target: achievements.id,
+ set: {
+ name: def.name,
+ description: def.description,
+ icon: def.icon,
+ category: def.category,
+ rarity: def.rarity,
+ xpReward: def.xpReward,
+ sortOrder: def.sortOrder,
+ condition: def.condition,
+ },
+ });
+ }
+ this.logger.log(`Seeded ${ACHIEVEMENT_DEFINITIONS.length} achievements`);
+ }
+
+ async getAllForUser(userId: string): Promise {
+ const allAchievements = await this.db
+ .select()
+ .from(achievements)
+ .orderBy(achievements.sortOrder);
+
+ const unlocked = await this.db
+ .select()
+ .from(userAchievements)
+ .where(eq(userAchievements.userId, userId));
+
+ const unlockedMap = new Map(unlocked.map((u) => [u.achievementId, u]));
+
+ // Calculate current progress for each achievement
+ const progressMap = await this.calculateProgress(userId);
+
+ return allAchievements.map((a) => {
+ const userAch = unlockedMap.get(a.id);
+ return {
+ ...a,
+ unlocked: !!userAch,
+ unlockedAt: userAch?.unlockedAt ?? null,
+ progress: userAch ? (a.condition as any).threshold : (progressMap.get(a.id) ?? 0),
+ };
+ });
+ }
+
+ async getUnlockedForUser(userId: string): Promise {
+ const rows = await this.db
+ .select({ achievement: achievements })
+ .from(userAchievements)
+ .innerJoin(achievements, eq(userAchievements.achievementId, achievements.id))
+ .where(eq(userAchievements.userId, userId));
+
+ return rows.map((r) => r.achievement);
+ }
+
+ async getStats(userId: string): Promise<{ total: number; unlocked: number }> {
+ const [totalResult] = await this.db
+ .select({ count: sql`count(*)` })
+ .from(achievements);
+
+ const [unlockedResult] = await this.db
+ .select({ count: sql`count(*)` })
+ .from(userAchievements)
+ .where(eq(userAchievements.userId, userId));
+
+ return {
+ total: Number(totalResult.count),
+ unlocked: Number(unlockedResult.count),
+ };
+ }
+
+ /**
+ * Check all achievements for a user and unlock any newly earned ones.
+ * Called after XP gain, skill creation, activity logging, etc.
+ */
+ async checkAndUnlock(userId: string, context?: { activityXp?: number }): Promise {
+ const allAchievements = await this.db.select().from(achievements);
+ const unlocked = await this.db
+ .select()
+ .from(userAchievements)
+ .where(eq(userAchievements.userId, userId));
+ const unlockedIds = new Set(unlocked.map((u) => u.achievementId));
+
+ // Get user data for condition evaluation
+ const userData = await this.getUserData(userId);
+ if (context?.activityXp) {
+ userData.lastActivityXp = context.activityXp;
+ }
+
+ const newlyUnlocked: AchievementUnlockResult[] = [];
+
+ for (const achievement of allAchievements) {
+ if (unlockedIds.has(achievement.id)) continue;
+
+ const condition = achievement.condition as { type: string; threshold: number };
+ if (this.evaluateCondition(condition, userData)) {
+ await this.db.insert(userAchievements).values({
+ userId,
+ achievementId: achievement.id,
+ progress: condition.threshold,
+ });
+ newlyUnlocked.push({
+ achievement,
+ xpReward: achievement.xpReward,
+ });
+ }
+ }
+
+ return newlyUnlocked;
+ }
+
+ private async getUserData(userId: string): Promise {
+ const userSkills = await this.db.select().from(skills).where(eq(skills.userId, userId));
+ const [activityCount] = await this.db
+ .select({ count: sql`count(*)` })
+ .from(activities)
+ .where(eq(activities.userId, userId));
+ const [stats] = await this.db.select().from(userStats).where(eq(userStats.userId, userId));
+
+ const uniqueBranches = new Set(userSkills.map((s) => s.branch).filter((b) => b !== 'custom'));
+
+ // Check min level per branch (for all_branches_min_level)
+ const branchMinLevels = new Map();
+ const mainBranches = ['intellect', 'body', 'creativity', 'social', 'practical', 'mindset'];
+ for (const branch of mainBranches) {
+ const branchSkills = userSkills.filter((s) => s.branch === branch);
+ if (branchSkills.length > 0) {
+ branchMinLevels.set(branch, Math.max(...branchSkills.map((s) => s.level)));
+ }
+ }
+ const allBranchesMinLevel =
+ branchMinLevels.size === 6 ? Math.min(...branchMinLevels.values()) : 0;
+
+ return {
+ totalXp: stats?.totalXp ?? 0,
+ totalSkills: userSkills.length,
+ highestLevel: stats?.highestLevel ?? 0,
+ totalActivities: Number(activityCount.count),
+ streakDays: stats?.streakDays ?? 0,
+ uniqueBranches: uniqueBranches.size,
+ allBranchesMinLevel,
+ lastActivityXp: 0,
+ };
+ }
+
+ private evaluateCondition(
+ condition: { type: string; threshold: number },
+ data: UserData
+ ): boolean {
+ switch (condition.type) {
+ case 'total_xp':
+ return data.totalXp >= condition.threshold;
+ case 'total_skills':
+ return data.totalSkills >= condition.threshold;
+ case 'highest_level':
+ return data.highestLevel >= condition.threshold;
+ case 'total_activities':
+ return data.totalActivities >= condition.threshold;
+ case 'streak_days':
+ return data.streakDays >= condition.threshold;
+ case 'unique_branches':
+ return data.uniqueBranches >= condition.threshold;
+ case 'single_activity_xp':
+ return data.lastActivityXp >= condition.threshold;
+ case 'all_branches_min_level':
+ return data.allBranchesMinLevel >= condition.threshold;
+ default:
+ return false;
+ }
+ }
+
+ private async calculateProgress(userId: string): Promise