mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:41:09 +02:00
feat(skilltree): add achievement system with 26 achievements + monetization report
Full-stack achievement system for SkillTree with backend (NestJS) and frontend (SvelteKit): - 26 achievements across 7 categories (XP, Skills, Levels, Activities, Streak, Branches, Special) - 5 rarity tiers (Common → Legendary) with distinct styling - Auto-unlock after XP gain, skill creation, and activity logging - Celebration animation on unlock with sparkle effects - Achievements page with category filters and progress tracking - IndexedDB offline support with local condition evaluation - Backend seeds achievements on startup, checks conditions after mutations - Stats overview extended with achievement counter - i18n translations (DE + EN) Also adds docs/MONETIZATION_REPORT.md with ranked analysis of all apps. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
000b74af9f
commit
02215dfb12
30 changed files with 2266 additions and 38 deletions
|
|
@ -34,7 +34,7 @@
|
|||
</script>
|
||||
|
||||
<div
|
||||
class="grid grid-cols-12 gap-4"
|
||||
class="grid auto-rows-fr grid-cols-12 gap-5"
|
||||
use:dndzone={{
|
||||
items,
|
||||
flipDurationMs,
|
||||
|
|
|
|||
|
|
@ -77,8 +77,7 @@
|
|||
const WidgetComponent = $derived(widgetComponents[widget.type]);
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<Card class="relative h-full">
|
||||
<Card class="relative h-full">
|
||||
<!-- Edit Mode Overlay -->
|
||||
{#if dashboardStore.isEditing}
|
||||
<div
|
||||
|
|
@ -140,7 +139,7 @@
|
|||
{/if}
|
||||
|
||||
<!-- Widget Content -->
|
||||
<div class="p-4" class:opacity-0={dashboardStore.isEditing}>
|
||||
<div class="min-h-[10rem] p-4" class:opacity-0={dashboardStore.isEditing}>
|
||||
{#if WidgetComponent}
|
||||
<WidgetComponent />
|
||||
{:else}
|
||||
|
|
@ -148,4 +147,3 @@
|
|||
{/if}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
},
|
||||
];
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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<void> {
|
||||
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<AchievementWithStatus[]> {
|
||||
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<Achievement[]> {
|
||||
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<number>`count(*)` })
|
||||
.from(achievements);
|
||||
|
||||
const [unlockedResult] = await this.db
|
||||
.select({ count: sql<number>`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<AchievementUnlockResult[]> {
|
||||
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<UserData> {
|
||||
const userSkills = await this.db.select().from(skills).where(eq(skills.userId, userId));
|
||||
const [activityCount] = await this.db
|
||||
.select({ count: sql<number>`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<string, number>();
|
||||
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<Map<string, number>> {
|
||||
const userData = await this.getUserData(userId);
|
||||
const allAchievements = await this.db.select().from(achievements);
|
||||
const progressMap = new Map<string, number>();
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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<AchievementCategory>(),
|
||||
rarity: varchar('rarity', { length: 20 }).notNull().$type<AchievementRarity>(),
|
||||
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;
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
export * from './skills.schema';
|
||||
export * from './activities.schema';
|
||||
export * from './user-stats.schema';
|
||||
export * from './achievements.schema';
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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<typeof createMockDb>;
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Skill[]> {
|
||||
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<Skill> {
|
||||
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<Skill> {
|
||||
|
|
@ -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<void> {
|
||||
|
|
|
|||
29
apps/skilltree/apps/web/src/lib/api/achievements.ts
Normal file
29
apps/skilltree/apps/web/src/lib/api/achievements.ts
Normal file
|
|
@ -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<AchievementWithStatus[]> {
|
||||
const response = await apiClient.get<AchievementsResponse>('/api/v1/achievements');
|
||||
return response.achievements;
|
||||
}
|
||||
|
||||
export async function getUnlockedAchievements(): Promise<Achievement[]> {
|
||||
const response = await apiClient.get<UnlockedResponse>('/api/v1/achievements/unlocked');
|
||||
return response.achievements;
|
||||
}
|
||||
|
||||
export async function getAchievementStats(): Promise<{ total: number; unlocked: number }> {
|
||||
const response = await apiClient.get<AchievementStatsResponse>('/api/v1/achievements/stats');
|
||||
return response.stats;
|
||||
}
|
||||
|
|
@ -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<Skill> {
|
|||
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 createSkill(data: CreateSkillDto): Promise<CreateSkillResponse> {
|
||||
return await apiClient.post<CreateSkillResponse>('/api/v1/skills', data);
|
||||
}
|
||||
|
||||
export async function updateSkill(id: string, data: UpdateSkillDto): Promise<Skill> {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,89 @@
|
|||
<script lang="ts">
|
||||
import type { AchievementWithStatus } from '$lib/types';
|
||||
import { RARITY_INFO } from '$lib/types';
|
||||
import { Trophy, Lock, Star } from '@manacore/shared-icons';
|
||||
|
||||
interface Props {
|
||||
achievement: AchievementWithStatus;
|
||||
}
|
||||
|
||||
let { achievement }: Props = $props();
|
||||
|
||||
const rarity = $derived(RARITY_INFO[achievement.rarity]);
|
||||
const progressPercent = $derived(
|
||||
achievement.unlocked
|
||||
? 100
|
||||
: Math.round((achievement.progress / achievement.condition.threshold) * 100)
|
||||
);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="relative rounded-xl border p-4 transition-all duration-200 {achievement.unlocked
|
||||
? `${rarity.bgColor} ${rarity.borderColor}`
|
||||
: 'border-gray-700/50 bg-gray-800/30'} {achievement.unlocked
|
||||
? 'hover:-translate-y-0.5 hover:shadow-lg'
|
||||
: 'opacity-70'}"
|
||||
>
|
||||
<!-- Rarity indicator -->
|
||||
<div class="absolute right-3 top-3">
|
||||
<span class="rounded-full px-2 py-0.5 text-xs font-medium {rarity.color} {rarity.bgColor}">
|
||||
{rarity.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start gap-3">
|
||||
<!-- Icon -->
|
||||
<div
|
||||
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-full {achievement.unlocked
|
||||
? 'bg-yellow-500/20'
|
||||
: 'bg-gray-700/50'}"
|
||||
>
|
||||
{#if achievement.unlocked}
|
||||
<Trophy class="h-6 w-6 text-yellow-400" />
|
||||
{:else}
|
||||
<Lock class="h-6 w-6 text-gray-500" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<!-- Name -->
|
||||
<h3 class="font-semibold {achievement.unlocked ? 'text-white' : 'text-gray-400'}">
|
||||
{achievement.name}
|
||||
</h3>
|
||||
|
||||
<!-- Description -->
|
||||
<p class="mt-0.5 text-sm {achievement.unlocked ? 'text-gray-300' : 'text-gray-500'}">
|
||||
{achievement.description}
|
||||
</p>
|
||||
|
||||
<!-- Progress bar (if not unlocked) -->
|
||||
{#if !achievement.unlocked}
|
||||
<div class="mt-2">
|
||||
<div class="flex items-center justify-between text-xs text-gray-500">
|
||||
<span>{achievement.progress} / {achievement.condition.threshold}</span>
|
||||
<span>{progressPercent}%</span>
|
||||
</div>
|
||||
<div class="mt-1 h-1.5 overflow-hidden rounded-full bg-gray-700">
|
||||
<div
|
||||
class="h-full rounded-full bg-gradient-to-r from-gray-500 to-gray-400 transition-all duration-300"
|
||||
style="width: {progressPercent}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- XP reward + unlock date -->
|
||||
<div class="mt-2 flex items-center gap-3 text-xs">
|
||||
<span class="flex items-center gap-1 {achievement.unlocked ? 'text-yellow-400' : 'text-gray-500'}">
|
||||
<Star class="h-3 w-3" />
|
||||
+{achievement.xpReward} XP
|
||||
</span>
|
||||
{#if achievement.unlocked && achievement.unlockedAt}
|
||||
<span class="text-gray-500">
|
||||
{new Date(achievement.unlockedAt).toLocaleDateString('de-DE')}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
<script lang="ts">
|
||||
import type { AchievementUnlockResult } from '$lib/types';
|
||||
import { RARITY_INFO } from '$lib/types';
|
||||
import { Trophy, Sparkle, Star } from '@manacore/shared-icons';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
result: AchievementUnlockResult;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { result, onClose }: Props = $props();
|
||||
|
||||
const rarity = RARITY_INFO[result.achievement.rarity];
|
||||
|
||||
function getRarityGradient(r: string): string {
|
||||
const gradients: Record<string, string> = {
|
||||
common: 'from-gray-500 to-gray-600',
|
||||
uncommon: 'from-green-500 to-green-600',
|
||||
rare: 'from-blue-500 to-blue-600',
|
||||
epic: 'from-purple-500 to-purple-600',
|
||||
legendary: 'from-yellow-400 to-yellow-500',
|
||||
};
|
||||
return gradients[r] ?? gradients.common;
|
||||
}
|
||||
|
||||
// Auto-close after 3.5 seconds
|
||||
onMount(() => {
|
||||
const timer = setTimeout(onClose, 3500);
|
||||
return () => clearTimeout(timer);
|
||||
});
|
||||
</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(10) as _, i}
|
||||
<div class="sparkle" style="--delay: {i * 0.08}s; --angle: {i * 36}deg">
|
||||
<Sparkle class="h-5 w-5 text-yellow-400" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Main content -->
|
||||
<div class="relative z-10">
|
||||
<!-- Trophy icon -->
|
||||
<div
|
||||
class="mx-auto mb-4 flex h-20 w-20 items-center justify-center rounded-full bg-gradient-to-br {getRarityGradient(
|
||||
result.achievement.rarity
|
||||
)} achievement-bounce shadow-lg shadow-yellow-500/20"
|
||||
>
|
||||
<Trophy class="h-10 w-10 text-white" />
|
||||
</div>
|
||||
|
||||
<!-- Achievement unlocked text -->
|
||||
<h2 class="mb-1 text-2xl font-bold text-yellow-400 achievement-text">Achievement freigeschaltet!</h2>
|
||||
|
||||
<!-- Achievement name -->
|
||||
<p class="mb-2 text-xl font-semibold text-white">{result.achievement.name}</p>
|
||||
|
||||
<!-- Description -->
|
||||
<p class="mb-4 text-gray-400">{result.achievement.description}</p>
|
||||
|
||||
<!-- Rarity + XP reward -->
|
||||
<div class="inline-flex items-center gap-3">
|
||||
<span class="rounded-full px-3 py-1 text-sm font-medium {rarity.color} {rarity.bgColor}">
|
||||
{rarity.name}
|
||||
</span>
|
||||
<span class="flex items-center gap-1 text-yellow-400">
|
||||
<Star class="h-4 w-4" />
|
||||
+{result.xpReward} XP
|
||||
</span>
|
||||
</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 0.8s 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(-120px);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.achievement-bounce {
|
||||
animation: ach-bounce 0.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes ach-bounce {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
}
|
||||
60% {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.achievement-text {
|
||||
animation: ach-glow 1s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes ach-glow {
|
||||
from {
|
||||
text-shadow: 0 0 8px rgba(251, 191, 36, 0.4);
|
||||
}
|
||||
to {
|
||||
text-shadow:
|
||||
0 0 20px rgba(251, 191, 36, 0.6),
|
||||
0 0 40px rgba(251, 191, 36, 0.3);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
<script lang="ts">
|
||||
import { skillStore } from '$lib/stores/skills.svelte';
|
||||
import { Trophy, Lightning, Target, Fire } from '@manacore/shared-icons';
|
||||
import { achievementStore } from '$lib/stores/achievements.svelte';
|
||||
import { Trophy, Lightning, Target, Fire, Medal } from '@manacore/shared-icons';
|
||||
</script>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<!-- Total XP -->
|
||||
<div class="rounded-xl border border-gray-700 bg-gray-800/50 p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
|
|
@ -63,4 +64,19 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Achievements -->
|
||||
<a href="/achievements" class="rounded-xl border border-gray-700 bg-gray-800/50 p-4 transition-colors hover:border-yellow-600/50 hover:bg-yellow-900/10">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-yellow-500/20">
|
||||
<Medal class="h-6 w-6 text-yellow-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-400">Achievements</p>
|
||||
<p class="text-2xl font-bold text-white">
|
||||
{achievementStore.stats().unlocked}<span class="text-sm font-normal text-gray-500">/{achievementStore.stats().total}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<IDBPDatabase<SkillTreeDB>> | null = null;
|
||||
|
||||
|
|
@ -53,6 +61,13 @@ function getDB(): Promise<IDBPDatabase<SkillTreeDB>> {
|
|||
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<AchievementWithStatus[]> {
|
||||
const db = await getDB();
|
||||
return db.getAll('achievements');
|
||||
}
|
||||
|
||||
export async function saveAchievement(achievement: AchievementWithStatus): Promise<void> {
|
||||
const db = await getDB();
|
||||
await db.put('achievements', achievement);
|
||||
}
|
||||
|
||||
export async function saveAllAchievements(achievementsList: AchievementWithStatus[]): Promise<void> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
299
apps/skilltree/apps/web/src/lib/stores/achievements.svelte.ts
Normal file
299
apps/skilltree/apps/web/src/lib/stores/achievements.svelte.ts
Normal file
|
|
@ -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<AchievementWithStatus[]>([]);
|
||||
let isLoading = $state(true);
|
||||
let initialized = $state(false);
|
||||
let useApi = $state(false);
|
||||
|
||||
// Queue of recently unlocked achievements to show celebrations
|
||||
let unlockQueue = $state<AchievementUnlockResult[]>([]);
|
||||
|
||||
// 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<AchievementCategory, AchievementWithStatus[]> = {
|
||||
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<AchievementUnlockResult[]> {
|
||||
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<string, number>();
|
||||
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,
|
||||
};
|
||||
|
|
@ -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<Skill[]>([]);
|
||||
|
|
@ -118,7 +119,7 @@ async function initialize() {
|
|||
|
||||
async function addSkill(data: Partial<Skill>): Promise<Skill> {
|
||||
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<Skill>): Promise<Skill> {
|
|||
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];
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
<script lang="ts">
|
||||
import { skillStore } from '$lib/stores/skills.svelte';
|
||||
import { achievementStore } from '$lib/stores/achievements.svelte';
|
||||
import { BRANCH_INFO } from '$lib/types';
|
||||
import type { Skill, SkillBranch } from '$lib/types';
|
||||
import type { Skill, SkillBranch, AchievementUnlockResult } 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 AchievementCelebration from '$lib/components/AchievementCelebration.svelte';
|
||||
import StatsOverview from '$lib/components/StatsOverview.svelte';
|
||||
import SkillTemplates from '$lib/components/SkillTemplates.svelte';
|
||||
import {
|
||||
|
|
@ -17,6 +19,7 @@
|
|||
UploadSimple,
|
||||
Sparkle,
|
||||
Graph,
|
||||
Trophy,
|
||||
} from '@manacore/shared-icons';
|
||||
|
||||
// Modal states
|
||||
|
|
@ -32,6 +35,10 @@
|
|||
let levelUpSkillName = $state('');
|
||||
let levelUpNewLevel = $state(0);
|
||||
|
||||
// Achievement celebration
|
||||
let showAchievementCelebration = $state(false);
|
||||
let currentAchievementUnlock = $state<AchievementUnlockResult | null>(null);
|
||||
|
||||
const filteredSkills = $derived(() => {
|
||||
if (selectedBranch === 'all') return skillStore.skills;
|
||||
return skillStore.skills.filter((s) => s.branch === selectedBranch);
|
||||
|
|
@ -59,6 +66,29 @@
|
|||
showLevelUp = true;
|
||||
}
|
||||
|
||||
function showNextAchievement() {
|
||||
const next = achievementStore.popUnlockQueue();
|
||||
if (next) {
|
||||
currentAchievementUnlock = next;
|
||||
showAchievementCelebration = true;
|
||||
} else {
|
||||
showAchievementCelebration = false;
|
||||
currentAchievementUnlock = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkAchievementsLocal(lastActivityXp?: number) {
|
||||
if (!achievementStore.useApi) {
|
||||
await achievementStore.checkLocal({
|
||||
skills: skillStore.skills,
|
||||
activities: skillStore.activities,
|
||||
userStats: skillStore.userStats,
|
||||
lastActivityXp,
|
||||
});
|
||||
showNextAchievement();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddXp(xp: number, description: string, duration?: number) {
|
||||
if (!selectedSkill) return;
|
||||
|
||||
|
|
@ -70,6 +100,9 @@
|
|||
if (result.leveledUp) {
|
||||
triggerLevelUp(skillName, result.newLevel);
|
||||
}
|
||||
|
||||
// Check achievements (offline mode triggers local check)
|
||||
await checkAchievementsLocal(xp);
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
|
|
@ -118,6 +151,19 @@
|
|||
<h1 class="text-2xl font-bold text-white">SkillTree</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Achievements -->
|
||||
<a
|
||||
href="/achievements"
|
||||
class="relative rounded-lg p-2 text-gray-400 transition-colors hover:bg-gray-800 hover:text-yellow-400"
|
||||
title="Achievements"
|
||||
>
|
||||
<Trophy class="h-5 w-5" />
|
||||
{#if achievementStore.stats().unlocked > 0}
|
||||
<span class="absolute -right-0.5 -top-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-yellow-500 text-[10px] font-bold text-gray-900">
|
||||
{achievementStore.stats().unlocked}
|
||||
</span>
|
||||
{/if}
|
||||
</a>
|
||||
<!-- Tree View -->
|
||||
<a
|
||||
href="/tree"
|
||||
|
|
@ -268,6 +314,7 @@
|
|||
onSave={async (skill) => {
|
||||
await skillStore.addSkill(skill);
|
||||
showAddSkillModal = false;
|
||||
await checkAchievementsLocal();
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
|
@ -306,6 +353,14 @@
|
|||
onClose={() => (showTemplatesModal = false)}
|
||||
onAddSkill={async (skill) => {
|
||||
await skillStore.addSkill(skill);
|
||||
await checkAchievementsLocal();
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if showAchievementCelebration && currentAchievementUnlock}
|
||||
<AchievementCelebration
|
||||
result={currentAchievementUnlock}
|
||||
onClose={showNextAchievement}
|
||||
/>
|
||||
{/if}
|
||||
|
|
|
|||
142
apps/skilltree/apps/web/src/routes/achievements/+page.svelte
Normal file
142
apps/skilltree/apps/web/src/routes/achievements/+page.svelte
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
<script lang="ts">
|
||||
import { achievementStore } from '$lib/stores/achievements.svelte';
|
||||
import { ACHIEVEMENT_CATEGORY_INFO, RARITY_INFO } from '$lib/types';
|
||||
import type { AchievementCategory } from '$lib/types';
|
||||
import AchievementCard from '$lib/components/AchievementCard.svelte';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Trophy,
|
||||
Star,
|
||||
} from '@manacore/shared-icons';
|
||||
|
||||
let selectedCategory = $state<AchievementCategory | 'all'>('all');
|
||||
let showOnlyUnlocked = $state(false);
|
||||
|
||||
const filteredAchievements = $derived(() => {
|
||||
let list = achievementStore.achievements;
|
||||
if (selectedCategory !== 'all') {
|
||||
list = list.filter((a) => a.category === selectedCategory);
|
||||
}
|
||||
if (showOnlyUnlocked) {
|
||||
list = list.filter((a) => a.unlocked);
|
||||
}
|
||||
return list.sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
});
|
||||
|
||||
const categoryEntries = Object.entries(ACHIEVEMENT_CATEGORY_INFO) as [AchievementCategory, { name: string; icon: string }][];
|
||||
</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">
|
||||
<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" />
|
||||
</a>
|
||||
<Trophy class="h-7 w-7 text-yellow-400" />
|
||||
<h1 class="text-2xl font-bold text-white">Achievements</h1>
|
||||
</div>
|
||||
|
||||
<!-- Stats badge -->
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-2 rounded-full bg-yellow-500/10 px-4 py-2">
|
||||
<Trophy class="h-4 w-4 text-yellow-400" />
|
||||
<span class="font-semibold text-yellow-400">
|
||||
{achievementStore.stats().unlocked} / {achievementStore.stats().total}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="mx-auto max-w-7xl px-4 py-8">
|
||||
<!-- Progress overview -->
|
||||
<div class="mb-8 rounded-xl border border-gray-700 bg-gray-800/50 p-6">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="text-lg font-semibold text-white">Fortschritt</h2>
|
||||
<span class="text-2xl font-bold text-yellow-400">{achievementStore.completionPercentage()}%</span>
|
||||
</div>
|
||||
<div class="h-3 overflow-hidden rounded-full bg-gray-700">
|
||||
<div
|
||||
class="h-full rounded-full bg-gradient-to-r from-yellow-500 to-yellow-400 transition-all duration-500"
|
||||
style="width: {achievementStore.completionPercentage()}%"
|
||||
></div>
|
||||
</div>
|
||||
<div class="mt-3 flex flex-wrap gap-4 text-sm">
|
||||
{#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}
|
||||
<span class="flex items-center gap-1.5 {info.color}">
|
||||
<Star class="h-3 w-3" />
|
||||
{info.name}: {count}/{total}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="mb-6 flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
onclick={() => (selectedCategory = 'all')}
|
||||
class="rounded-full px-4 py-2 text-sm font-medium transition-colors {selectedCategory ===
|
||||
'all'
|
||||
? 'bg-yellow-500 text-gray-900'
|
||||
: 'bg-gray-800 text-gray-300 hover:bg-gray-700'}"
|
||||
>
|
||||
Alle ({achievementStore.achievements.length})
|
||||
</button>
|
||||
{#each categoryEntries as [category, info]}
|
||||
{@const count = achievementStore.achievements.filter((a) => a.category === category).length}
|
||||
<button
|
||||
onclick={() => (selectedCategory = category)}
|
||||
class="rounded-full px-4 py-2 text-sm font-medium transition-colors {selectedCategory ===
|
||||
category
|
||||
? 'bg-yellow-500 text-gray-900'
|
||||
: 'bg-gray-800 text-gray-300 hover:bg-gray-700'}"
|
||||
>
|
||||
{info.name} ({count})
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
<div class="ml-auto">
|
||||
<button
|
||||
onclick={() => (showOnlyUnlocked = !showOnlyUnlocked)}
|
||||
class="rounded-full px-4 py-2 text-sm font-medium transition-colors {showOnlyUnlocked
|
||||
? 'bg-yellow-500/20 text-yellow-400'
|
||||
: 'bg-gray-800 text-gray-300 hover:bg-gray-700'}"
|
||||
>
|
||||
{showOnlyUnlocked ? 'Nur freigeschaltete' : 'Alle zeigen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Achievement grid -->
|
||||
{#if filteredAchievements().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"
|
||||
>
|
||||
<Trophy class="h-12 w-12 text-gray-600" />
|
||||
</div>
|
||||
<h2 class="mb-2 text-xl font-semibold text-gray-300">Keine Achievements gefunden</h2>
|
||||
<p class="text-gray-500">
|
||||
{showOnlyUnlocked
|
||||
? 'Du hast in dieser Kategorie noch keine Achievements freigeschaltet.'
|
||||
: 'Keine Achievements in dieser Kategorie.'}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each filteredAchievements() as achievement (achievement.id)}
|
||||
<AchievementCard {achievement} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
147
docs/MONETIZATION_REPORT.md
Normal file
147
docs/MONETIZATION_REPORT.md
Normal file
|
|
@ -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.
|
||||
95
scripts/mac-mini/tune-tcp.sh
Executable file
95
scripts/mac-mini/tune-tcp.sh
Executable file
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue