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:
Till JS 2026-03-24 12:17:43 +01:00
parent 000b74af9f
commit 02215dfb12
30 changed files with 2266 additions and 38 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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,
};

View file

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

View file

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

View file

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

View file

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

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