diff --git a/apps/manacore/apps/web/src/lib/components/dashboard/DashboardGrid.svelte b/apps/manacore/apps/web/src/lib/components/dashboard/DashboardGrid.svelte index af0bb9809..497862079 100644 --- a/apps/manacore/apps/web/src/lib/components/dashboard/DashboardGrid.svelte +++ b/apps/manacore/apps/web/src/lib/components/dashboard/DashboardGrid.svelte @@ -34,7 +34,7 @@
-
- + {#if dashboardStore.isEditing}
-
+
{#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> { + const userData = await this.getUserData(userId); + const allAchievements = await this.db.select().from(achievements); + const progressMap = new Map(); + + for (const achievement of allAchievements) { + const condition = achievement.condition as { type: string; threshold: number }; + let current = 0; + + switch (condition.type) { + case 'total_xp': + current = userData.totalXp; + break; + case 'total_skills': + current = userData.totalSkills; + break; + case 'highest_level': + current = userData.highestLevel; + break; + case 'total_activities': + current = userData.totalActivities; + break; + case 'streak_days': + current = userData.streakDays; + break; + case 'unique_branches': + current = userData.uniqueBranches; + break; + case 'single_activity_xp': + current = 0; // Can't track historical max + break; + case 'all_branches_min_level': + current = userData.allBranchesMinLevel; + break; + } + + progressMap.set(achievement.id, Math.min(current, condition.threshold)); + } + + return progressMap; + } +} + +interface UserData { + totalXp: number; + totalSkills: number; + highestLevel: number; + totalActivities: number; + streakDays: number; + uniqueBranches: number; + allBranchesMinLevel: number; + lastActivityXp: number; +} diff --git a/apps/skilltree/apps/backend/src/app.module.ts b/apps/skilltree/apps/backend/src/app.module.ts index a603f32cb..461dd1b22 100644 --- a/apps/skilltree/apps/backend/src/app.module.ts +++ b/apps/skilltree/apps/backend/src/app.module.ts @@ -5,6 +5,7 @@ import { DatabaseModule } from './db/database.module'; import { HealthModule } from '@manacore/shared-nestjs-health'; import { SkillModule } from './skill/skill.module'; import { ActivityModule } from './activity/activity.module'; +import { AchievementModule } from './achievement/achievement.module'; @Module({ imports: [ @@ -20,6 +21,7 @@ import { ActivityModule } from './activity/activity.module'; HealthModule.forRoot({ serviceName: 'skilltree-backend' }), SkillModule, ActivityModule, + AchievementModule, ], }) export class AppModule {} diff --git a/apps/skilltree/apps/backend/src/db/schema/achievements.schema.ts b/apps/skilltree/apps/backend/src/db/schema/achievements.schema.ts new file mode 100644 index 000000000..def324ca8 --- /dev/null +++ b/apps/skilltree/apps/backend/src/db/schema/achievements.schema.ts @@ -0,0 +1,62 @@ +import { + pgTable, + uuid, + timestamp, + varchar, + text, + integer, + boolean, + index, + jsonb, +} from 'drizzle-orm/pg-core'; + +export type AchievementCategory = + | 'xp' + | 'skills' + | 'levels' + | 'activities' + | 'streak' + | 'branches' + | 'special'; + +export type AchievementRarity = 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'; + +export const achievements = pgTable('achievements', { + id: varchar('id', { length: 50 }).primaryKey(), // e.g. 'first_activity', 'streak_7' + name: varchar('name', { length: 200 }).notNull(), + description: text('description').notNull(), + icon: varchar('icon', { length: 50 }).notNull().default('trophy'), + category: varchar('category', { length: 20 }).notNull().$type(), + rarity: varchar('rarity', { length: 20 }).notNull().$type(), + xpReward: integer('xp_reward').default(0).notNull(), + sortOrder: integer('sort_order').default(0).notNull(), + condition: jsonb('condition').notNull(), // { type: 'total_xp', threshold: 1000 } + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), +}); + +export type Achievement = typeof achievements.$inferSelect; +export type NewAchievement = typeof achievements.$inferInsert; + +export const userAchievements = pgTable( + 'user_achievements', + { + id: uuid('id').primaryKey().defaultRandom(), + userId: text('user_id').notNull(), + achievementId: varchar('achievement_id', { length: 50 }) + .references(() => achievements.id, { onDelete: 'cascade' }) + .notNull(), + unlockedAt: timestamp('unlocked_at', { withTimezone: true }).defaultNow().notNull(), + progress: integer('progress').default(0).notNull(), // current progress toward threshold + }, + (table) => ({ + userIdx: index('user_achievements_user_idx').on(table.userId), + achievementIdx: index('user_achievements_achievement_idx').on(table.achievementId), + uniqueUserAchievement: index('user_achievements_unique_idx').on( + table.userId, + table.achievementId + ), + }) +); + +export type UserAchievement = typeof userAchievements.$inferSelect; +export type NewUserAchievement = typeof userAchievements.$inferInsert; diff --git a/apps/skilltree/apps/backend/src/db/schema/index.ts b/apps/skilltree/apps/backend/src/db/schema/index.ts index f37fa5cb3..e84ef7a56 100644 --- a/apps/skilltree/apps/backend/src/db/schema/index.ts +++ b/apps/skilltree/apps/backend/src/db/schema/index.ts @@ -1,3 +1,4 @@ export * from './skills.schema'; export * from './activities.schema'; export * from './user-stats.schema'; +export * from './achievements.schema'; diff --git a/apps/skilltree/apps/backend/src/skill/skill.controller.ts b/apps/skilltree/apps/backend/src/skill/skill.controller.ts index 32f3fd01b..d36808592 100644 --- a/apps/skilltree/apps/backend/src/skill/skill.controller.ts +++ b/apps/skilltree/apps/backend/src/skill/skill.controller.ts @@ -32,8 +32,8 @@ export class SkillController { @Post() async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateSkillDto) { - const skill = await this.skillService.create(user.userId, dto); - return { skill }; + const result = await this.skillService.create(user.userId, dto); + return { skill: result.skill, newAchievements: result.newAchievements }; } @Put(':id') diff --git a/apps/skilltree/apps/backend/src/skill/skill.module.ts b/apps/skilltree/apps/backend/src/skill/skill.module.ts index 191b57fe7..e4f359ea5 100644 --- a/apps/skilltree/apps/backend/src/skill/skill.module.ts +++ b/apps/skilltree/apps/backend/src/skill/skill.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; import { SkillController } from './skill.controller'; import { SkillService } from './skill.service'; +import { AchievementModule } from '../achievement/achievement.module'; @Module({ + imports: [AchievementModule], controllers: [SkillController], providers: [SkillService], exports: [SkillService], diff --git a/apps/skilltree/apps/backend/src/skill/skill.service.spec.ts b/apps/skilltree/apps/backend/src/skill/skill.service.spec.ts index 01e9d3e9b..69b70a3ad 100644 --- a/apps/skilltree/apps/backend/src/skill/skill.service.spec.ts +++ b/apps/skilltree/apps/backend/src/skill/skill.service.spec.ts @@ -2,6 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { NotFoundException } from '@nestjs/common'; import { SkillService } from './skill.service'; import { DATABASE_TOKEN } from '../db/database.module'; +import { AchievementService } from '../achievement/achievement.service'; // Mock database operations // Uses a query builder pattern where each query chain is thenable @@ -60,6 +61,10 @@ const createMockDb = () => { return mockDb; }; +const mockAchievementService = { + checkAndUnlock: jest.fn().mockResolvedValue([]), +}; + describe('SkillService', () => { let service: SkillService; let mockDb: ReturnType; @@ -85,6 +90,7 @@ describe('SkillService', () => { beforeEach(async () => { mockDb = createMockDb(); + mockAchievementService.checkAndUnlock.mockClear(); const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -93,6 +99,10 @@ describe('SkillService', () => { provide: DATABASE_TOKEN, useValue: mockDb, }, + { + provide: AchievementService, + useValue: mockAchievementService, + }, ], }).compile(); @@ -215,9 +225,10 @@ describe('SkillService', () => { const result = await service.create(testUserId, createDto); - expect(result.name).toBe('React'); - expect(result.currentXp).toBe(0); - expect(result.level).toBe(0); + expect(result.skill.name).toBe('React'); + expect(result.skill.currentXp).toBe(0); + expect(result.skill.level).toBe(0); + expect(result.newAchievements).toEqual([]); }); it('should use default icon when not provided', async () => { @@ -243,7 +254,7 @@ describe('SkillService', () => { const result = await service.create(testUserId, dtoWithoutIcon); - expect(result.icon).toBe('star'); + expect(result.skill.icon).toBe('star'); }); }); diff --git a/apps/skilltree/apps/backend/src/skill/skill.service.ts b/apps/skilltree/apps/backend/src/skill/skill.service.ts index 649ebc146..fb19e1404 100644 --- a/apps/skilltree/apps/backend/src/skill/skill.service.ts +++ b/apps/skilltree/apps/backend/src/skill/skill.service.ts @@ -4,6 +4,7 @@ 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'; +import { AchievementService, AchievementUnlockResult } from '../achievement/achievement.service'; // Level thresholds const LEVEL_THRESHOLDS = [0, 100, 500, 1500, 4000, 10000]; @@ -19,7 +20,10 @@ function calculateLevel(xp: number): number { @Injectable() export class SkillService { - constructor(@Inject(DATABASE_TOKEN) private db: Database) {} + constructor( + @Inject(DATABASE_TOKEN) private db: Database, + private readonly achievementService: AchievementService, + ) {} async findAll(userId: string): Promise { return this.db.select().from(skills).where(eq(skills.userId, userId)).orderBy(desc(skills.totalXp)); @@ -49,7 +53,10 @@ export class SkillService { return skill; } - async create(userId: string, dto: CreateSkillDto): Promise { + async create( + userId: string, + dto: CreateSkillDto + ): Promise<{ skill: Skill; newAchievements: AchievementUnlockResult[] }> { const newSkill: NewSkill = { userId, name: dto.name, @@ -68,7 +75,10 @@ export class SkillService { // Update user stats await this.updateUserStats(userId); - return skill; + // Check achievements + const newAchievements = await this.achievementService.checkAndUnlock(userId); + + return { skill, newAchievements }; } async update(id: string, userId: string, dto: UpdateSkillDto): Promise { @@ -99,7 +109,7 @@ export class SkillService { id: string, userId: string, dto: AddXpDto - ): Promise<{ skill: Skill; leveledUp: boolean; newLevel: number }> { + ): Promise<{ skill: Skill; leveledUp: boolean; newLevel: number; newAchievements: AchievementUnlockResult[] }> { const skill = await this.findByIdOrThrow(id, userId); const newTotalXp = skill.totalXp + dto.xp; @@ -131,7 +141,12 @@ export class SkillService { // Update user stats await this.updateUserStats(userId); - return { skill: updated, leveledUp, newLevel }; + // Check achievements + const newAchievements = await this.achievementService.checkAndUnlock(userId, { + activityXp: dto.xp, + }); + + return { skill: updated, leveledUp, newLevel, newAchievements }; } private async updateUserStats(userId: string): Promise { diff --git a/apps/skilltree/apps/web/src/lib/api/achievements.ts b/apps/skilltree/apps/web/src/lib/api/achievements.ts new file mode 100644 index 000000000..991cae600 --- /dev/null +++ b/apps/skilltree/apps/web/src/lib/api/achievements.ts @@ -0,0 +1,29 @@ +import { apiClient } from './client'; +import type { AchievementWithStatus, Achievement } from '$lib/types'; + +interface AchievementsResponse { + achievements: AchievementWithStatus[]; +} + +interface UnlockedResponse { + achievements: Achievement[]; +} + +interface AchievementStatsResponse { + stats: { total: number; unlocked: number }; +} + +export async function getAchievements(): Promise { + const response = await apiClient.get('/api/v1/achievements'); + return response.achievements; +} + +export async function getUnlockedAchievements(): Promise { + const response = await apiClient.get('/api/v1/achievements/unlocked'); + return response.achievements; +} + +export async function getAchievementStats(): Promise<{ total: number; unlocked: number }> { + const response = await apiClient.get('/api/v1/achievements/stats'); + return response.stats; +} diff --git a/apps/skilltree/apps/web/src/lib/api/skills.ts b/apps/skilltree/apps/web/src/lib/api/skills.ts index 6324a51cf..889d034d7 100644 --- a/apps/skilltree/apps/web/src/lib/api/skills.ts +++ b/apps/skilltree/apps/web/src/lib/api/skills.ts @@ -1,5 +1,5 @@ import { apiClient } from './client'; -import type { Skill, Activity, UserStats, SkillBranch } from '$lib/types'; +import type { Skill, Activity, UserStats, SkillBranch, AchievementUnlockResult } from '$lib/types'; interface CreateSkillDto { name: string; @@ -31,6 +31,12 @@ interface AddXpResponse { leveledUp: boolean; previousLevel: number; newLevel: number; + newAchievements: AchievementUnlockResult[]; +} + +interface CreateSkillResponse { + skill: Skill; + newAchievements: AchievementUnlockResult[]; } interface SkillsResponse { @@ -56,9 +62,8 @@ export async function getSkill(id: string): Promise { return response.skill; } -export async function createSkill(data: CreateSkillDto): Promise { - const response = await apiClient.post('/api/v1/skills', data); - return response.skill; +export async function createSkill(data: CreateSkillDto): Promise { + return await apiClient.post('/api/v1/skills', data); } export async function updateSkill(id: string, data: UpdateSkillDto): Promise { diff --git a/apps/skilltree/apps/web/src/lib/components/AchievementCard.svelte b/apps/skilltree/apps/web/src/lib/components/AchievementCard.svelte new file mode 100644 index 000000000..7260b1044 --- /dev/null +++ b/apps/skilltree/apps/web/src/lib/components/AchievementCard.svelte @@ -0,0 +1,89 @@ + + +
+ +
+ + {rarity.name} + +
+ +
+ +
+ {#if achievement.unlocked} + + {:else} + + {/if} +
+ +
+ +

+ {achievement.name} +

+ + +

+ {achievement.description} +

+ + + {#if !achievement.unlocked} +
+
+ {achievement.progress} / {achievement.condition.threshold} + {progressPercent}% +
+
+
+
+
+ {/if} + + +
+ + + +{achievement.xpReward} XP + + {#if achievement.unlocked && achievement.unlockedAt} + + {new Date(achievement.unlockedAt).toLocaleDateString('de-DE')} + + {/if} +
+
+
+
diff --git a/apps/skilltree/apps/web/src/lib/components/AchievementCelebration.svelte b/apps/skilltree/apps/web/src/lib/components/AchievementCelebration.svelte new file mode 100644 index 000000000..f1a631729 --- /dev/null +++ b/apps/skilltree/apps/web/src/lib/components/AchievementCelebration.svelte @@ -0,0 +1,149 @@ + + + + + diff --git a/apps/skilltree/apps/web/src/lib/components/StatsOverview.svelte b/apps/skilltree/apps/web/src/lib/components/StatsOverview.svelte index 8eb83406a..4cb30b72c 100644 --- a/apps/skilltree/apps/web/src/lib/components/StatsOverview.svelte +++ b/apps/skilltree/apps/web/src/lib/components/StatsOverview.svelte @@ -1,9 +1,10 @@ - diff --git a/apps/skilltree/apps/web/src/lib/i18n/locales/de.json b/apps/skilltree/apps/web/src/lib/i18n/locales/de.json index 5634bad3c..b42723763 100644 --- a/apps/skilltree/apps/web/src/lib/i18n/locales/de.json +++ b/apps/skilltree/apps/web/src/lib/i18n/locales/de.json @@ -49,7 +49,36 @@ "totalXp": "Gesamt-XP", "totalSkills": "Skills", "highestLevel": "Höchstes Level", - "streak": "Streak" + "streak": "Streak", + "achievements": "Achievements" + }, + "achievement": { + "title": "Achievements", + "unlocked": "Freigeschaltet", + "locked": "Gesperrt", + "progress": "Fortschritt", + "celebration": "Achievement freigeschaltet!", + "clickToClose": "Klicken zum Schließen", + "noAchievements": "Keine Achievements gefunden", + "showAll": "Alle zeigen", + "showUnlocked": "Nur freigeschaltete", + "all": "Alle", + "rarity": { + "common": "Gewöhnlich", + "uncommon": "Ungewöhnlich", + "rare": "Selten", + "epic": "Episch", + "legendary": "Legendär" + }, + "category": { + "xp": "Erfahrung", + "skills": "Skills", + "levels": "Level", + "activities": "Aktivitäten", + "streak": "Streak", + "branches": "Branches", + "special": "Speziell" + } }, "auth": { "login": "Anmelden", diff --git a/apps/skilltree/apps/web/src/lib/i18n/locales/en.json b/apps/skilltree/apps/web/src/lib/i18n/locales/en.json index 2456fd5a2..3b2372476 100644 --- a/apps/skilltree/apps/web/src/lib/i18n/locales/en.json +++ b/apps/skilltree/apps/web/src/lib/i18n/locales/en.json @@ -49,7 +49,36 @@ "totalXp": "Total XP", "totalSkills": "Skills", "highestLevel": "Highest Level", - "streak": "Streak" + "streak": "Streak", + "achievements": "Achievements" + }, + "achievement": { + "title": "Achievements", + "unlocked": "Unlocked", + "locked": "Locked", + "progress": "Progress", + "celebration": "Achievement unlocked!", + "clickToClose": "Click to close", + "noAchievements": "No achievements found", + "showAll": "Show all", + "showUnlocked": "Unlocked only", + "all": "All", + "rarity": { + "common": "Common", + "uncommon": "Uncommon", + "rare": "Rare", + "epic": "Epic", + "legendary": "Legendary" + }, + "category": { + "xp": "Experience", + "skills": "Skills", + "levels": "Level", + "activities": "Activities", + "streak": "Streak", + "branches": "Branches", + "special": "Special" + } }, "auth": { "login": "Login", diff --git a/apps/skilltree/apps/web/src/lib/services/storage.ts b/apps/skilltree/apps/web/src/lib/services/storage.ts index f9051dd89..aed738d03 100644 --- a/apps/skilltree/apps/web/src/lib/services/storage.ts +++ b/apps/skilltree/apps/web/src/lib/services/storage.ts @@ -1,5 +1,5 @@ import { openDB, type IDBPDatabase } from 'idb'; -import type { Skill, Activity, UserStats } from '$lib/types'; +import type { Skill, Activity, UserStats, AchievementWithStatus } from '$lib/types'; interface SkillTreeDB { skills: { @@ -23,10 +23,18 @@ interface SkillTreeDB { key: 'user-stats'; value: UserStats; }; + achievements: { + key: string; + value: AchievementWithStatus; + indexes: { + 'by-category': string; + 'by-unlocked': number; + }; + }; } const DB_NAME = 'skilltree-db'; -const DB_VERSION = 1; +const DB_VERSION = 2; let dbPromise: Promise> | null = null; @@ -53,6 +61,13 @@ function getDB(): Promise> { if (!db.objectStoreNames.contains('stats')) { db.createObjectStore('stats'); } + + // Achievements store (added in v2) + if (!db.objectStoreNames.contains('achievements')) { + const achievementStore = db.createObjectStore('achievements', { keyPath: 'id' }); + achievementStore.createIndex('by-category', 'category'); + achievementStore.createIndex('by-unlocked', 'unlocked'); + } }, }); } @@ -230,3 +245,35 @@ export async function importData(data: { await tx.done; } + +// Achievements CRUD +export async function getAllAchievements(): Promise { + const db = await getDB(); + return db.getAll('achievements'); +} + +export async function saveAchievement(achievement: AchievementWithStatus): Promise { + const db = await getDB(); + await db.put('achievements', achievement); +} + +export async function saveAllAchievements(achievementsList: AchievementWithStatus[]): Promise { + const db = await getDB(); + const tx = db.transaction('achievements', 'readwrite'); + await tx.objectStore('achievements').clear(); + for (const a of achievementsList) { + await tx.objectStore('achievements').put(a); + } + await tx.done; +} + +export async function unlockAchievement(id: string): Promise { + const db = await getDB(); + const achievement = await db.get('achievements', id); + if (achievement && !achievement.unlocked) { + achievement.unlocked = true; + achievement.unlockedAt = new Date().toISOString(); + achievement.progress = achievement.condition.threshold; + await db.put('achievements', achievement); + } +} diff --git a/apps/skilltree/apps/web/src/lib/stores/achievements.svelte.ts b/apps/skilltree/apps/web/src/lib/stores/achievements.svelte.ts new file mode 100644 index 000000000..2ba2611e1 --- /dev/null +++ b/apps/skilltree/apps/web/src/lib/stores/achievements.svelte.ts @@ -0,0 +1,299 @@ +import type { + AchievementWithStatus, + AchievementUnlockResult, + AchievementCategory, + Skill, + Activity, + UserStats, +} from '$lib/types'; +import { ACHIEVEMENT_DEFINITIONS } from '$lib/types'; +import * as storage from '$lib/services/storage'; +import * as achievementsApi from '$lib/api/achievements'; +import { authStore } from './auth.svelte'; + +// Reactive state +let achievements = $state([]); +let isLoading = $state(true); +let initialized = $state(false); +let useApi = $state(false); + +// Queue of recently unlocked achievements to show celebrations +let unlockQueue = $state([]); + +// Derived values +const unlockedAchievements = $derived(() => { + return achievements.filter((a) => a.unlocked); +}); + +const lockedAchievements = $derived(() => { + return achievements.filter((a) => !a.unlocked); +}); + +const achievementsByCategory = $derived(() => { + const grouped: Record = { + xp: [], + skills: [], + levels: [], + activities: [], + streak: [], + branches: [], + special: [], + }; + for (const a of achievements) { + grouped[a.category].push(a); + } + return grouped; +}); + +const stats = $derived(() => { + return { + total: achievements.length, + unlocked: achievements.filter((a) => a.unlocked).length, + }; +}); + +const completionPercentage = $derived(() => { + if (achievements.length === 0) return 0; + return Math.round((achievements.filter((a) => a.unlocked).length / achievements.length) * 100); +}); + +// Actions +async function initialize() { + if (initialized) return; + + isLoading = true; + try { + if (authStore.isAuthenticated) { + useApi = true; + achievements = await achievementsApi.getAchievements(); + } else { + useApi = false; + const stored = await storage.getAllAchievements(); + if (stored.length === 0) { + // First time: seed from definitions + achievements = ACHIEVEMENT_DEFINITIONS.map((def) => ({ + ...def, + unlocked: false, + unlockedAt: null, + progress: 0, + })); + await storage.saveAllAchievements(achievements); + } else { + achievements = stored; + } + } + initialized = true; + } catch (error) { + console.error('Failed to initialize achievements store:', error); + // Fallback to local definitions + if (useApi) { + useApi = false; + const stored = await storage.getAllAchievements(); + achievements = + stored.length > 0 + ? stored + : ACHIEVEMENT_DEFINITIONS.map((def) => ({ + ...def, + unlocked: false, + unlockedAt: null, + progress: 0, + })); + } + } finally { + isLoading = false; + } +} + +async function reinitialize() { + initialized = false; + achievements = []; + unlockQueue = []; + await initialize(); +} + +/** + * Check achievements locally (offline mode). + * Called after skill/activity changes when not using API. + */ +async function checkLocal(context: { + skills: Skill[]; + activities: Activity[]; + userStats: UserStats; + lastActivityXp?: number; +}): Promise { + const { skills, activities: allActivities, userStats: stats, lastActivityXp } = context; + + const uniqueBranches = new Set(skills.map((s) => s.branch).filter((b) => b !== 'custom')); + + const mainBranches = ['intellect', 'body', 'creativity', 'social', 'practical', 'mindset']; + const branchMaxLevels = new Map(); + for (const branch of mainBranches) { + const branchSkills = skills.filter((s) => s.branch === branch); + if (branchSkills.length > 0) { + branchMaxLevels.set(branch, Math.max(...branchSkills.map((s) => s.level))); + } + } + const allBranchesMinLevel = + branchMaxLevels.size === 6 ? Math.min(...branchMaxLevels.values()) : 0; + + const userData = { + totalXp: stats.totalXp, + totalSkills: skills.length, + highestLevel: stats.highestLevel, + totalActivities: allActivities.length, + streakDays: stats.streakDays, + uniqueBranches: uniqueBranches.size, + allBranchesMinLevel, + lastActivityXp: lastActivityXp ?? 0, + }; + + const newlyUnlocked: AchievementUnlockResult[] = []; + + for (let i = 0; i < achievements.length; i++) { + const a = achievements[i]; + if (a.unlocked) continue; + + const condition = a.condition; + let current = 0; + let met = false; + + switch (condition.type) { + case 'total_xp': + current = userData.totalXp; + met = current >= condition.threshold; + break; + case 'total_skills': + current = userData.totalSkills; + met = current >= condition.threshold; + break; + case 'highest_level': + current = userData.highestLevel; + met = current >= condition.threshold; + break; + case 'total_activities': + current = userData.totalActivities; + met = current >= condition.threshold; + break; + case 'streak_days': + current = userData.streakDays; + met = current >= condition.threshold; + break; + case 'unique_branches': + current = userData.uniqueBranches; + met = current >= condition.threshold; + break; + case 'single_activity_xp': + current = userData.lastActivityXp; + met = current >= condition.threshold; + break; + case 'all_branches_min_level': + current = userData.allBranchesMinLevel; + met = current >= condition.threshold; + break; + } + + if (met) { + const unlocked: AchievementWithStatus = { + ...a, + unlocked: true, + unlockedAt: new Date().toISOString(), + progress: condition.threshold, + }; + achievements = [ + ...achievements.slice(0, i), + unlocked, + ...achievements.slice(i + 1), + ]; + await storage.saveAchievement(unlocked); + newlyUnlocked.push({ achievement: a, xpReward: a.xpReward }); + } else { + // Update progress + const updated = { ...a, progress: Math.min(current, condition.threshold) }; + if (updated.progress !== a.progress) { + achievements = [ + ...achievements.slice(0, i), + updated, + ...achievements.slice(i + 1), + ]; + await storage.saveAchievement(updated); + } + } + } + + if (newlyUnlocked.length > 0) { + unlockQueue = [...unlockQueue, ...newlyUnlocked]; + } + + return newlyUnlocked; +} + +/** + * Handle achievements returned from the API after a skill/XP action. + */ +function handleApiUnlocks(results: AchievementUnlockResult[]) { + if (results.length === 0) return; + + for (const result of results) { + const index = achievements.findIndex((a) => a.id === result.achievement.id); + if (index !== -1) { + achievements = [ + ...achievements.slice(0, index), + { + ...achievements[index], + unlocked: true, + unlockedAt: new Date().toISOString(), + progress: achievements[index].condition.threshold, + }, + ...achievements.slice(index + 1), + ]; + } + } + + unlockQueue = [...unlockQueue, ...results]; +} + +function popUnlockQueue(): AchievementUnlockResult | null { + if (unlockQueue.length === 0) return null; + const [first, ...rest] = unlockQueue; + unlockQueue = rest; + return first; +} + +export const achievementStore = { + get achievements() { + return achievements; + }, + get isLoading() { + return isLoading; + }, + get initialized() { + return initialized; + }, + get unlockedAchievements() { + return unlockedAchievements; + }, + get lockedAchievements() { + return lockedAchievements; + }, + get achievementsByCategory() { + return achievementsByCategory; + }, + get stats() { + return stats; + }, + get completionPercentage() { + return completionPercentage; + }, + get unlockQueue() { + return unlockQueue; + }, + get useApi() { + return useApi; + }, + + initialize, + reinitialize, + checkLocal, + handleApiUnlocks, + popUnlockQueue, +}; diff --git a/apps/skilltree/apps/web/src/lib/stores/skills.svelte.ts b/apps/skilltree/apps/web/src/lib/stores/skills.svelte.ts index cc79b6acb..6e16619c5 100644 --- a/apps/skilltree/apps/web/src/lib/stores/skills.svelte.ts +++ b/apps/skilltree/apps/web/src/lib/stores/skills.svelte.ts @@ -1,9 +1,10 @@ -import type { Skill, Activity, UserStats, SkillBranch } from '$lib/types'; +import type { Skill, Activity, UserStats, SkillBranch, AchievementUnlockResult } 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'; +import { achievementStore } from './achievements.svelte'; // Reactive state using Svelte 5 runes let skills = $state([]); @@ -118,7 +119,7 @@ async function initialize() { async function addSkill(data: Partial): Promise { if (useApi && authStore.isAuthenticated) { - const skill = await skillsApi.createSkill({ + const result = await skillsApi.createSkill({ name: data.name || '', description: data.description, branch: data.branch || 'custom', @@ -126,9 +127,12 @@ async function addSkill(data: Partial): Promise { icon: data.icon, color: data.color ?? undefined, }); - skills = [...skills, skill]; + skills = [...skills, result.skill]; await updateStats(); - return skill; + if (result.newAchievements?.length > 0) { + achievementStore.handleApiUnlocks(result.newAchievements); + } + return result.skill; } else { const skill = createDefaultSkill(data); await storage.saveSkill(skill); @@ -185,6 +189,9 @@ async function addXp( skills = [...skills.slice(0, index), result.skill, ...skills.slice(index + 1)]; activities = [...activities, result.activity]; await updateStats(); + if (result.newAchievements?.length > 0) { + achievementStore.handleApiUnlocks(result.newAchievements); + } return { leveledUp: result.leveledUp, newLevel: result.newLevel }; } else { const skill = skills[index]; diff --git a/apps/skilltree/apps/web/src/lib/types/index.ts b/apps/skilltree/apps/web/src/lib/types/index.ts index 43079c9ab..9e1bf724e 100644 --- a/apps/skilltree/apps/web/src/lib/types/index.ts +++ b/apps/skilltree/apps/web/src/lib/types/index.ts @@ -162,3 +162,393 @@ export function createActivity( timestamp: new Date().toISOString(), }; } + +// Achievement Types + +export type AchievementCategory = + | 'xp' + | 'skills' + | 'levels' + | 'activities' + | 'streak' + | 'branches' + | 'special'; + +export type AchievementRarity = 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'; + +export interface AchievementCondition { + type: string; + threshold: number; +} + +export interface Achievement { + id: string; + name: string; + description: string; + icon: string; + category: AchievementCategory; + rarity: AchievementRarity; + xpReward: number; + sortOrder: number; + condition: AchievementCondition; +} + +export interface AchievementWithStatus extends Achievement { + unlocked: boolean; + unlockedAt: string | null; + progress: number; +} + +export interface AchievementUnlockResult { + achievement: Achievement; + xpReward: number; +} + +export const RARITY_INFO: Record< + AchievementRarity, + { name: string; color: string; bgColor: string; borderColor: string } +> = { + common: { + name: 'Gewöhnlich', + color: 'text-gray-300', + bgColor: 'bg-gray-700/50', + borderColor: 'border-gray-600', + }, + uncommon: { + name: 'Ungewöhnlich', + color: 'text-green-400', + bgColor: 'bg-green-900/30', + borderColor: 'border-green-700', + }, + rare: { + name: 'Selten', + color: 'text-blue-400', + bgColor: 'bg-blue-900/30', + borderColor: 'border-blue-700', + }, + epic: { + name: 'Episch', + color: 'text-purple-400', + bgColor: 'bg-purple-900/30', + borderColor: 'border-purple-700', + }, + legendary: { + name: 'Legendär', + color: 'text-yellow-400', + bgColor: 'bg-yellow-900/30', + borderColor: 'border-yellow-600', + }, +}; + +export const ACHIEVEMENT_CATEGORY_INFO: Record< + AchievementCategory, + { name: string; icon: string } +> = { + xp: { name: 'Erfahrung', icon: 'star' }, + skills: { name: 'Skills', icon: 'grid' }, + levels: { name: 'Level', icon: 'arrow-up' }, + activities: { name: 'Aktivitäten', icon: 'lightning' }, + streak: { name: 'Streak', icon: 'flame' }, + branches: { name: 'Branches', icon: 'compass' }, + special: { name: 'Speziell', icon: 'trophy' }, +}; + +/** + * All achievement definitions for offline/local evaluation. + * Mirrors the backend ACHIEVEMENT_DEFINITIONS. + */ +export const ACHIEVEMENT_DEFINITIONS: Achievement[] = [ + // XP + { + 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 }, + }, + // Skills + { + 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 }, + }, + // Levels + { + 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 }, + }, + // Activities + { + 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 + { + 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 }, + }, + // Branches + { + 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 + { + 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/web/src/routes/+layout.svelte b/apps/skilltree/apps/web/src/routes/+layout.svelte index 7d24968e9..d892a793e 100644 --- a/apps/skilltree/apps/web/src/routes/+layout.svelte +++ b/apps/skilltree/apps/web/src/routes/+layout.svelte @@ -4,6 +4,7 @@ import { onMount } from 'svelte'; import { isLoading as i18nLoading, _ as t } from 'svelte-i18n'; import { skillStore } from '$lib/stores/skills.svelte'; + import { achievementStore } from '$lib/stores/achievements.svelte'; import { authStore } from '$lib/stores/auth.svelte'; import { MiniOnboardingModal } from '@manacore/shared-app-onboarding'; import { skilltreeOnboarding } from '$lib/stores/app-onboarding.svelte'; @@ -15,6 +16,7 @@ onMount(async () => { await Promise.all([authStore.initialize(), skillStore.initialize()]); + await achievementStore.initialize(); loading = false; }); diff --git a/apps/skilltree/apps/web/src/routes/+page.svelte b/apps/skilltree/apps/web/src/routes/+page.svelte index 544c6edfd..8a372fbe5 100644 --- a/apps/skilltree/apps/web/src/routes/+page.svelte +++ b/apps/skilltree/apps/web/src/routes/+page.svelte @@ -1,12 +1,14 @@ + +
+ +
+
+
+
+ + + + +

Achievements

+
+ + +
+
+ + + {achievementStore.stats().unlocked} / {achievementStore.stats().total} + +
+
+
+
+
+ +
+ +
+
+

Fortschritt

+ {achievementStore.completionPercentage()}% +
+
+
+
+
+ {#each Object.entries(RARITY_INFO) as [rarity, info]} + {@const count = achievementStore.achievements.filter((a) => a.rarity === rarity && a.unlocked).length} + {@const total = achievementStore.achievements.filter((a) => a.rarity === rarity).length} + + + {info.name}: {count}/{total} + + {/each} +
+
+ + +
+ + {#each categoryEntries as [category, info]} + {@const count = achievementStore.achievements.filter((a) => a.category === category).length} + + {/each} + +
+ +
+
+ + + {#if filteredAchievements().length === 0} +
+
+ +
+

Keine Achievements gefunden

+

+ {showOnlyUnlocked + ? 'Du hast in dieser Kategorie noch keine Achievements freigeschaltet.' + : 'Keine Achievements in dieser Kategorie.'} +

+
+ {:else} +
+ {#each filteredAchievements() as achievement (achievement.id)} + + {/each} +
+ {/if} +
+
diff --git a/docs/MONETIZATION_REPORT.md b/docs/MONETIZATION_REPORT.md new file mode 100644 index 000000000..58ea9b3e5 --- /dev/null +++ b/docs/MONETIZATION_REPORT.md @@ -0,0 +1,147 @@ +# Monetarisierungspotential — Ranking aller Apps + +> Stand: 2026-03-24 + +--- + +## Tier 1 — Hohes Potential + +### 1. Picture (AI Image Generation) + +- **Modell:** Freemium mit Credits (bereits teilweise implementiert: 3 free, dann 10 Credits/Bild) +- **Warum Top:** AI-Bildgenerierung hat bewiesene Zahlungsbereitschaft (Midjourney, DALL-E). Jede Generation kostet echtes Geld (Replicate API) → natürlicher Grund für Monetarisierung. Nutzer sehen sofort den Wert. +- **Empfehlung:** Abo-Tiers (Free: 5/Tag, Pro: 100/Monat, Unlimited) + Credit-Packs. Lokale Generation via mana-image-gen als Cost-Reducer nutzen. + +### 2. Chat (Multi-Model AI Chat) + +- **Modell:** Freemium — lokale Modelle gratis, Cloud-Modelle kostenpflichtig +- **Warum:** Jeder Cloud-API-Call (Claude, GPT-4o) kostet. Nutzer sind gewohnt für ChatGPT Plus zu zahlen. Lokale Modelle als USP/Hook, Premium für Cloud-Modelle. +- **Empfehlung:** Free Tier (lokale Modelle unlimited), Pro Tier (Cloud-Modelle mit Kontingent), Power Tier (hohe Limits + Priority). + +### 3. Context (AI Document Management) + +- **Modell:** Token-Economy bereits implementiert (Azure OpenAI + Gemini Kosten) +- **Warum:** Knowledge Worker zahlen für Produktivitätstools. AI-Dokumentgenerierung + Versionierung ist ein klarer Produktivitätsgewinn. B2B-fähig. +- **Empfehlung:** Free (begrenzte AI-Tokens/Monat), Pro (mehr Tokens + alle Modelle), Team (shared Spaces + höhere Limits). + +### 4. NutriPhi (AI Nutrition Tracking) + +- **Modell:** Credit-System bereits da (5 Credits/Foto, 2/Text, 10/Coaching) +- **Warum:** Health/Fitness-Markt hat hohe Zahlungsbereitschaft (MyFitnessPal, Yazio machen Millionen). Foto-Analyse ist ein starker Differentiator. Recurring Revenue durch tägliche Nutzung. +- **Empfehlung:** Free (3 Analysen/Tag), Premium (unlimited + AI Coaching + Wochenberichte). 4,99-9,99€/Monat realistisch. + +--- + +## Tier 2 — Gutes Potential + +### 5. Questions (AI Research Assistant) + +- **Modell:** Freemium mit Recherche-Tiefe als Gate +- **Warum:** Perplexity AI beweist den Markt. Automatische Multi-Source-Recherche (arXiv, Scholar, Wikipedia) spart echte Zeit. Studenten + Researcher als Zielgruppe. +- **Empfehlung:** Free (Quick-Recherche, 5/Tag), Pro (Deep Research unlimited, Follow-ups, Export). + +### 6. Traces (GPS Tracking + AI City Guides) + +- **Modell:** Credits bereits implementiert (5 Base + 2/POI) +- **Warum:** Reise-Apps monetarisieren gut. AI-generierte City Guides sind unique. Saisonaler Peak (Urlaubszeit), aber wiederkehrende Nutzung bei Vielreisenden. +- **Empfehlung:** Free (Tracking unlimited, 1 Guide/Stadt), Premium (unlimited Guides + Offline-Download). + +### 7. ManaDeck (Deck/Flashcard Management) + +- **Modell:** Credit-System vorhanden (10 Mana/Deck, 5/AI-Generation) +- **Warum:** Anki, Quizlet sind bewiesene Märkte. AI-Kartengeneration ist starker USP. Studenten zahlen für Lerntools (besonders vor Prüfungen). +- **Empfehlung:** Free (5 Decks, manuelle Karten), Pro (unlimited + AI-Generation + Export). Semester-Abo für Studenten. + +### 8. Planta (Plant Care + AI Identification) + +- **Modell:** Freemium mit AI-Scans als Gate +- **Warum:** PictureThis/PlantNet zeigen den Markt (PictureThis: >$100M Revenue). AI-Pflanzenidentifikation hat hohe Zahlungsbereitschaft. Wiederkehrend durch Gießerinnerungen. +- **Empfehlung:** Free (3 Scans/Monat), Premium (unlimited Scans + Care Reminders + Krankheitsdiagnose). 3,99€/Monat. + +--- + +## Tier 3 — Moderates Potential + +### 9. Calendar (Multi-User Calendar + CalDAV) + +- **Modell:** Freemium mit Sharing/Team als Pro-Feature +- **Warum:** Produktiv, aber starke Konkurrenz (Google Calendar kostenlos). Differenzierung durch Privacy + CalDAV + Team-Features. Eher B2B-Potential. +- **Empfehlung:** Free (persönliche Kalender), Pro (Team-Sharing + Integrationen). Schwieriger Markt. + +### 10. Todo (Task Management + Kanban) + +- **Modell:** Freemium mit Projekt-/Team-Limits +- **Warum:** Todoist, TickTick zeigen den Markt. Natural Language Parsing + Kanban sind gute Features. Aber extrem viel Konkurrenz. +- **Empfehlung:** Free (3 Projekte, Basic), Pro (unlimited + Kanban + Recurring + Calendar Sync). Harter Markt gegen Todoist. + +### 11. Storage (Cloud File Storage) + +- **Modell:** Storage-Tiers (klassisch) +- **Warum:** Natürliches Limit (Speicherplatz kostet). File Versioning + Sharing + Passwortschutz als Pro-Features. Aber: Google Drive 15GB free ist schwer zu schlagen. +- **Empfehlung:** Free (1GB), Pro (50GB + Versioning + geschützte Links). Nische: Privacy-fokussiert, self-hosted. + +### 12. Mukke (Music Workspace) + +- **Modell:** Freemium mit Pro-Features +- **Warum:** Nische (Beat Editor + Lyrics Sync ist unique). Musiker zahlen für Tools. BPM Detection + Lyrics Synchronisation + Multi-Format-Export als Differenzierung. +- **Empfehlung:** Free (Basic Library), Pro (Lyrics Sync Export + unlimited Projects + FLAC/WAV Support). Kleine aber zahlungsbereite Zielgruppe. + +--- + +## Tier 4 — Geringes Potential (als Standalone) + +### 13. Presi (Presentations) + +- **Modell:** Freemium mit Templates/Themes +- **Warum:** Canva, Google Slides, PowerPoint dominieren. Schwer zu differenzieren ohne massive Features. Share-Links sind nett aber nicht ausreichend. +- **Empfehlung:** Eher als Teil des Manacore-Bundles. Standalone schwierig. + +### 14. Contacts (Contact Management) + +- **Modell:** Freemium mit Import/Export/Team-Features +- **Warum:** Kontaktverwaltung ist Commodity. Google Contacts ist kostenlos. CRM-Markt (HubSpot, Salesforce) ist übermächtig. Google-Integration ist nice, aber reicht nicht. +- **Empfehlung:** Nur als Teil des Manacore-Ökosystems sinnvoll, nicht standalone. + +### 15. Photos (Photo Gallery Aggregation) + +- **Modell:** Storage-Limits +- **Warum:** Google Photos, iCloud Photos sind übermächtig und kostenlos bis zu Limits. Aggregation allein reicht nicht. +- **Empfehlung:** Wertsteigerung für andere Apps (Picture, Contacts), nicht eigenständig monetarisierbar. + +### 16. CityCorners (City Guide Konstanz) + +- **Modell:** Lokale Werbung / Sponsored Listings +- **Warum:** Hyperlokal (nur Konstanz). Zu kleine Zielgruppe für Abo. Aber: Lokale Geschäfte/Restaurants könnten für Featured Listings zahlen. +- **Empfehlung:** Sponsored Listings für lokale Businesses. Skaliert nur bei Expansion auf andere Städte. + +### 17. Matrix/Manalink (Messaging) + +- **Modell:** Kaum monetarisierbar +- **Warum:** Messaging ist commodity (WhatsApp, Signal, Telegram = kostenlos). Matrix/Decentralization ist Nische. E2E-Encryption noch nicht fertig. +- **Empfehlung:** Strategischer Wert im Ökosystem, aber kein Revenue-Driver. + +### 18. Zitare (Daily Quotes) + +- **Modell:** Micro-Abo oder Werbung +- **Warum:** Quotes-Apps monetarisieren schlecht. Geringe Zahlungsbereitschaft. Hohe Churn. +- **Empfehlung:** Maximal als In-App-Purchase (Premium-Themes, eigene Collections). 0,99€/Monat ceiling. + +### 19. Skilltree (Gamified Skill Tracking) + +- **Modell:** Schwierig — client-only, keine laufenden Kosten +- **Warum:** Kein Backend = keine laufenden Kosten = kein natürlicher Monetarisierungsgrund. Nische. Habit-Tracker-Markt ist gesättigt. +- **Empfehlung:** Premium-Themes oder Cloud-Sync als Pro-Feature, wenn Backend hinzukommt. + +--- + +## Meta-Strategie: Das Manacore-Bundle + +Das größte Monetarisierungspotential liegt vermutlich **nicht in Einzel-Apps**, sondern im **Ökosystem-Bundle**: + +| Tier | Preis | Inhalt | +|------|-------|--------| +| **Free** | 0€ | Alle Apps mit Basis-Limits | +| **Mana Pro** | 9,99€/Mo | Alle Apps unlimited + AI-Features (begrenzt) | +| **Mana Power** | 19,99€/Mo | Alle AI-Features unlimited + Priority + Team | + +**Vorteil:** Jede einzelne App ist zu klein für ein eigenes Abo. Zusammen sind sie ein überzeugender Produktivitäts-Stack mit AI als rotem Faden. Die AI-Kosten (LLM, Bildgenerierung, Recherche) geben einen natürlichen Grund für die Bezahlschranke. diff --git a/scripts/mac-mini/tune-tcp.sh b/scripts/mac-mini/tune-tcp.sh new file mode 100755 index 000000000..300fad253 --- /dev/null +++ b/scripts/mac-mini/tune-tcp.sh @@ -0,0 +1,95 @@ +#!/bin/bash +# tune-tcp.sh — Reduce TCP TIME_WAIT accumulation on macOS +# +# Problem: With 72+ Docker containers each running health checks, +# TIME_WAIT sockets accumulate and can exhaust ephemeral ports. +# +# macOS default MSL (Maximum Segment Lifetime) is 15000ms (15s), +# meaning TIME_WAIT lasts 30s (2x MSL). This script reduces MSL +# to 5000ms (5s), so TIME_WAIT drops to 10s. +# +# Usage: +# sudo ./tune-tcp.sh # Apply settings +# ./tune-tcp.sh --status # Check current values (no sudo needed) +# +# To run at boot, add to a launchd plist or call from startup.sh + +set -euo pipefail + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +show_status() { + echo "=== TCP Tuning Status ===" + echo "" + + # Current MSL value + CURRENT_MSL=$(sysctl -n net.inet.tcp.msl 2>/dev/null || echo "unknown") + echo -e "net.inet.tcp.msl: ${YELLOW}${CURRENT_MSL}${NC} ms (TIME_WAIT = 2x MSL = $((CURRENT_MSL * 2 / 1000))s)" + + # TIME_WAIT socket count + TW_COUNT=$(netstat -n -p tcp 2>/dev/null | grep -c TIME_WAIT || echo "0") + if [ "$TW_COUNT" -gt 500 ]; then + echo -e "TIME_WAIT sockets: ${RED}${TW_COUNT}${NC} (high!)" + elif [ "$TW_COUNT" -gt 100 ]; then + echo -e "TIME_WAIT sockets: ${YELLOW}${TW_COUNT}${NC} (moderate)" + else + echo -e "TIME_WAIT sockets: ${GREEN}${TW_COUNT}${NC} (healthy)" + fi + + # Ephemeral port range + FIRST=$(sysctl -n net.inet.ip.portrange.first 2>/dev/null || echo "unknown") + LAST=$(sysctl -n net.inet.ip.portrange.last 2>/dev/null || echo "unknown") + if [ "$FIRST" != "unknown" ] && [ "$LAST" != "unknown" ]; then + AVAILABLE=$((LAST - FIRST)) + echo -e "Ephemeral port range: ${FIRST}-${LAST} (${AVAILABLE} ports)" + if [ "$TW_COUNT" != "0" ] && [ "$AVAILABLE" -gt 0 ]; then + USAGE_PCT=$((TW_COUNT * 100 / AVAILABLE)) + echo -e "Port usage by TIME_WAIT: ${USAGE_PCT}%" + fi + fi + + echo "" +} + +apply_settings() { + if [ "$(id -u)" -ne 0 ]; then + echo -e "${RED}Error: Must run as root (sudo) to change sysctl values${NC}" + echo "Usage: sudo $0" + exit 1 + fi + + echo "=== Applying TCP Tuning ===" + echo "" + + # Show before + BEFORE_MSL=$(sysctl -n net.inet.tcp.msl 2>/dev/null) + echo "Before: net.inet.tcp.msl = ${BEFORE_MSL}ms (TIME_WAIT = $((BEFORE_MSL * 2 / 1000))s)" + + # Set MSL to 5000ms (TIME_WAIT will be 10s instead of 30s) + sysctl -w net.inet.tcp.msl=5000 + + # Show after + AFTER_MSL=$(sysctl -n net.inet.tcp.msl 2>/dev/null) + echo -e "After: net.inet.tcp.msl = ${GREEN}${AFTER_MSL}${NC}ms (TIME_WAIT = $((AFTER_MSL * 2 / 1000))s)" + + echo "" + echo -e "${GREEN}TCP tuning applied successfully.${NC}" + echo "Note: This setting does not persist across reboots." + echo "Add this script to startup.sh or a launchd plist for persistence." + echo "" + + show_status +} + +# Main +case "${1:-apply}" in + --status|-s|status) + show_status + ;; + *) + apply_settings + ;; +esac