✨ Major Features Added: - Complete magical theming and rebranding from LifeRPG to The Wizard's Grimoire - Production-grade React frontend with Tailwind CSS v4 and magical aesthetics - Comprehensive analytics dashboard with Recharts integration (ScryingPortal) - Push notifications system with PWA service worker support - Drag & drop functionality using @dnd-kit for habit reordering - Social features with friends system and leaderboards - Performance optimization tools and monitoring - Mobile app enhancement with PWA installation support 🏗️ Technical Infrastructure: - Advanced service worker with offline support and background sync - Zustand state management for scalable application state - Production-ready UI component system with enhanced Button, Card, Input - Progressive Web App (PWA) with manifest and app installation - FastAPI backend with comprehensive API endpoints - Docker containerization and CI/CD pipeline setup 📱 Progressive Web App Features: - Offline functionality with intelligent caching - Push notification support for habit reminders - App installation on mobile and desktop platforms - Background sync for offline data management - Performance monitoring and optimization tools 🎨 User Experience: - Magical wizard/grimoire theming throughout application - Responsive design optimized for all device sizes - Drag & drop habit management with smooth animations - Interactive analytics with multiple chart types - Social connectivity with friends and competitive features - Comprehensive notification and performance settings 🔧 Developer Experience: - Modern development stack with Vite and React - Comprehensive testing setup and CI/CD pipelines - Code quality tools with pre-commit hooks - Docker development environment - Detailed documentation and implementation guides This represents a complete transformation from prototype to production-ready application with enterprise-grade features and magical user experience.
402 lines
13 KiB
Python
402 lines
13 KiB
Python
"""
|
|
Gamification engine for LifeRPG - XP, levels, achievements, and streaks.
|
|
"""
|
|
from datetime import datetime, timedelta
|
|
from typing import List, Dict, Optional, Tuple
|
|
from sqlalchemy.orm import Session
|
|
from sqlalchemy import func
|
|
import models
|
|
import json
|
|
|
|
# XP and Level Configuration
|
|
XP_BASE = 100 # Base XP needed for level 2
|
|
XP_MULTIPLIER = 1.2 # Each level requires 20% more XP
|
|
MAX_LEVEL = 100
|
|
|
|
# Achievement Definitions
|
|
ACHIEVEMENT_DEFINITIONS = {
|
|
"first_habit": {
|
|
"name": "First Steps",
|
|
"description": "Complete your first habit",
|
|
"xp_reward": 50,
|
|
"icon": "🌱"
|
|
},
|
|
"streak_7": {
|
|
"name": "Week Warrior",
|
|
"description": "Maintain a 7-day streak",
|
|
"xp_reward": 100,
|
|
"icon": "🔥"
|
|
},
|
|
"streak_30": {
|
|
"name": "Monthly Master",
|
|
"description": "Maintain a 30-day streak",
|
|
"xp_reward": 500,
|
|
"icon": "💪"
|
|
},
|
|
"streak_100": {
|
|
"name": "Century Champion",
|
|
"description": "Maintain a 100-day streak",
|
|
"xp_reward": 2000,
|
|
"icon": "👑"
|
|
},
|
|
"habit_count_10": {
|
|
"name": "Habit Builder",
|
|
"description": "Create 10 habits",
|
|
"xp_reward": 200,
|
|
"icon": "🏗️"
|
|
},
|
|
"habit_count_50": {
|
|
"name": "Routine Master",
|
|
"description": "Create 50 habits",
|
|
"xp_reward": 1000,
|
|
"icon": "⚡"
|
|
},
|
|
"xp_1000": {
|
|
"name": "Experience Gained",
|
|
"description": "Earn 1,000 XP",
|
|
"xp_reward": 0,
|
|
"icon": "⭐"
|
|
},
|
|
"level_10": {
|
|
"name": "Rising Star",
|
|
"description": "Reach level 10",
|
|
"xp_reward": 500,
|
|
"icon": "🌟"
|
|
},
|
|
"level_25": {
|
|
"name": "Veteran Player",
|
|
"description": "Reach level 25",
|
|
"xp_reward": 1500,
|
|
"icon": "🎖️"
|
|
},
|
|
"perfect_week": {
|
|
"name": "Perfect Week",
|
|
"description": "Complete all active habits for 7 consecutive days",
|
|
"xp_reward": 300,
|
|
"icon": "💎"
|
|
}
|
|
}
|
|
|
|
def calculate_level_from_xp(total_xp: int) -> int:
|
|
"""Calculate level based on total XP."""
|
|
if total_xp < XP_BASE:
|
|
return 1
|
|
|
|
level = 1
|
|
xp_needed = XP_BASE
|
|
remaining_xp = total_xp
|
|
|
|
while remaining_xp >= xp_needed and level < MAX_LEVEL:
|
|
remaining_xp -= xp_needed
|
|
level += 1
|
|
xp_needed = int(xp_needed * XP_MULTIPLIER)
|
|
|
|
return level
|
|
|
|
def calculate_xp_for_level(level: int) -> int:
|
|
"""Calculate total XP needed to reach a given level."""
|
|
if level <= 1:
|
|
return 0
|
|
|
|
total_xp = 0
|
|
xp_needed = XP_BASE
|
|
|
|
for _ in range(2, level + 1):
|
|
total_xp += xp_needed
|
|
xp_needed = int(xp_needed * XP_MULTIPLIER)
|
|
|
|
return total_xp
|
|
|
|
def calculate_xp_for_next_level(current_xp: int) -> int:
|
|
"""Calculate XP needed for the next level."""
|
|
current_level = calculate_level_from_xp(current_xp)
|
|
if current_level >= MAX_LEVEL:
|
|
return 0
|
|
|
|
next_level_xp = calculate_xp_for_level(current_level + 1)
|
|
return next_level_xp - current_xp
|
|
|
|
def get_user_stats(db: Session, user_id: int) -> Dict:
|
|
"""Get comprehensive user gamification stats."""
|
|
# Get user's total XP from profile
|
|
xp_profile = db.query(models.Profile).filter(
|
|
models.Profile.user_id == user_id,
|
|
models.Profile.key == "total_xp"
|
|
).first()
|
|
|
|
total_xp = int(xp_profile.value) if xp_profile and xp_profile.value else 0
|
|
current_level = calculate_level_from_xp(total_xp)
|
|
xp_for_current_level = calculate_xp_for_level(current_level)
|
|
xp_for_next_level = calculate_xp_for_level(current_level + 1) if current_level < MAX_LEVEL else 0
|
|
xp_progress = total_xp - xp_for_current_level
|
|
xp_needed = xp_for_next_level - xp_for_current_level if current_level < MAX_LEVEL else 0
|
|
|
|
# Get habit stats
|
|
total_habits = db.query(models.Habit).filter(models.Habit.user_id == user_id).count()
|
|
active_habits = db.query(models.Habit).filter(
|
|
models.Habit.user_id == user_id,
|
|
models.Habit.status == "active"
|
|
).count()
|
|
|
|
# Get total completions
|
|
total_completions = db.query(models.Log).filter(
|
|
models.Log.user_id == user_id,
|
|
models.Log.action == "complete"
|
|
).count()
|
|
|
|
# Calculate current streak (simplified - longest consecutive days with any habit completion)
|
|
current_streak = calculate_current_streak(db, user_id)
|
|
longest_streak = calculate_longest_streak(db, user_id)
|
|
|
|
# Get achievements
|
|
achievements = db.query(models.Achievement).filter(
|
|
models.Achievement.user_id == user_id
|
|
).all()
|
|
|
|
return {
|
|
"total_xp": total_xp,
|
|
"current_level": current_level,
|
|
"xp_progress": xp_progress,
|
|
"xp_needed": xp_needed,
|
|
"xp_percentage": int((xp_progress / xp_needed * 100)) if xp_needed > 0 else 100,
|
|
"total_habits": total_habits,
|
|
"active_habits": active_habits,
|
|
"total_completions": total_completions,
|
|
"current_streak": current_streak,
|
|
"longest_streak": longest_streak,
|
|
"achievements_count": len(achievements),
|
|
"achievements": [
|
|
{
|
|
"id": a.id,
|
|
"name": a.name,
|
|
"description": a.description,
|
|
"earned_at": a.earned_at.isoformat() if a.earned_at else None
|
|
}
|
|
for a in achievements
|
|
]
|
|
}
|
|
|
|
def calculate_current_streak(db: Session, user_id: int) -> int:
|
|
"""Calculate user's current consecutive day streak."""
|
|
# Get recent completions, grouped by date
|
|
recent_logs = db.query(
|
|
func.date(models.Log.timestamp).label('log_date')
|
|
).filter(
|
|
models.Log.user_id == user_id,
|
|
models.Log.action == "complete",
|
|
models.Log.timestamp >= datetime.now() - timedelta(days=365)
|
|
).group_by(
|
|
func.date(models.Log.timestamp)
|
|
).order_by(
|
|
func.date(models.Log.timestamp).desc()
|
|
).all()
|
|
|
|
if not recent_logs:
|
|
return 0
|
|
|
|
# Check for consecutive days starting from today
|
|
today = datetime.now().date()
|
|
current_streak = 0
|
|
check_date = today
|
|
|
|
for log in recent_logs:
|
|
if log.log_date == check_date:
|
|
current_streak += 1
|
|
check_date = check_date - timedelta(days=1)
|
|
elif log.log_date == check_date - timedelta(days=1):
|
|
# Allow for today not having completions yet
|
|
current_streak += 1
|
|
check_date = log.log_date - timedelta(days=1)
|
|
else:
|
|
break
|
|
|
|
return current_streak
|
|
|
|
def calculate_longest_streak(db: Session, user_id: int) -> int:
|
|
"""Calculate user's longest ever consecutive day streak."""
|
|
# Get all completion dates
|
|
logs = db.query(
|
|
func.date(models.Log.timestamp).label('log_date')
|
|
).filter(
|
|
models.Log.user_id == user_id,
|
|
models.Log.action == "complete"
|
|
).group_by(
|
|
func.date(models.Log.timestamp)
|
|
).order_by(
|
|
func.date(models.Log.timestamp)
|
|
).all()
|
|
|
|
if not logs:
|
|
return 0
|
|
|
|
max_streak = 1
|
|
current_streak = 1
|
|
|
|
for i in range(1, len(logs)):
|
|
prev_date = logs[i-1].log_date
|
|
curr_date = logs[i].log_date
|
|
|
|
if curr_date == prev_date + timedelta(days=1):
|
|
current_streak += 1
|
|
max_streak = max(max_streak, current_streak)
|
|
else:
|
|
current_streak = 1
|
|
|
|
return max_streak
|
|
|
|
def award_xp(db: Session, user_id: int, xp_amount: int, source: str = "habit_completion") -> Dict:
|
|
"""Award XP to a user and check for level-ups and achievements."""
|
|
# Get current XP
|
|
xp_profile = db.query(models.Profile).filter(
|
|
models.Profile.user_id == user_id,
|
|
models.Profile.key == "total_xp"
|
|
).first()
|
|
|
|
old_xp = int(xp_profile.value) if xp_profile and xp_profile.value else 0
|
|
new_xp = old_xp + xp_amount
|
|
old_level = calculate_level_from_xp(old_xp)
|
|
new_level = calculate_level_from_xp(new_xp)
|
|
|
|
# Update XP in profile
|
|
if xp_profile:
|
|
xp_profile.value = str(new_xp)
|
|
else:
|
|
xp_profile = models.Profile(user_id=user_id, key="total_xp", value=str(new_xp))
|
|
db.add(xp_profile)
|
|
|
|
# Check for level-up achievements
|
|
level_up = new_level > old_level
|
|
new_achievements = []
|
|
|
|
if level_up:
|
|
# Check level-based achievements
|
|
for achievement_key in ["level_10", "level_25"]:
|
|
if achievement_key not in [a["name"] for a in new_achievements]:
|
|
required_level = int(achievement_key.split("_")[1])
|
|
if new_level >= required_level and old_level < required_level:
|
|
achievement = award_achievement(db, user_id, achievement_key)
|
|
if achievement:
|
|
new_achievements.append(achievement)
|
|
|
|
# Check XP-based achievements
|
|
if new_xp >= 1000 and old_xp < 1000:
|
|
achievement = award_achievement(db, user_id, "xp_1000")
|
|
if achievement:
|
|
new_achievements.append(achievement)
|
|
|
|
db.commit()
|
|
|
|
return {
|
|
"xp_awarded": xp_amount,
|
|
"total_xp": new_xp,
|
|
"old_level": old_level,
|
|
"new_level": new_level,
|
|
"level_up": level_up,
|
|
"new_achievements": new_achievements,
|
|
"source": source
|
|
}
|
|
|
|
def award_achievement(db: Session, user_id: int, achievement_key: str) -> Optional[Dict]:
|
|
"""Award an achievement to a user if they don't already have it."""
|
|
# Check if user already has this achievement
|
|
existing = db.query(models.Achievement).filter(
|
|
models.Achievement.user_id == user_id,
|
|
models.Achievement.name == achievement_key
|
|
).first()
|
|
|
|
if existing:
|
|
return None
|
|
|
|
# Get achievement definition
|
|
achievement_def = ACHIEVEMENT_DEFINITIONS.get(achievement_key)
|
|
if not achievement_def:
|
|
return None
|
|
|
|
# Create achievement
|
|
achievement = models.Achievement(
|
|
user_id=user_id,
|
|
name=achievement_key,
|
|
description=f"{achievement_def['name']}: {achievement_def['description']}",
|
|
earned_at=datetime.now()
|
|
)
|
|
|
|
db.add(achievement)
|
|
|
|
# Award XP bonus if specified
|
|
if achievement_def.get("xp_reward", 0) > 0:
|
|
award_xp(db, user_id, achievement_def["xp_reward"], f"achievement_{achievement_key}")
|
|
|
|
return {
|
|
"key": achievement_key,
|
|
"name": achievement_def["name"],
|
|
"description": achievement_def["description"],
|
|
"xp_reward": achievement_def.get("xp_reward", 0),
|
|
"icon": achievement_def.get("icon", "🏆")
|
|
}
|
|
|
|
def check_habit_achievements(db: Session, user_id: int) -> List[Dict]:
|
|
"""Check and award habit-related achievements."""
|
|
new_achievements = []
|
|
|
|
# Check habit count achievements
|
|
total_habits = db.query(models.Habit).filter(models.Habit.user_id == user_id).count()
|
|
|
|
if total_habits >= 10:
|
|
achievement = award_achievement(db, user_id, "habit_count_10")
|
|
if achievement:
|
|
new_achievements.append(achievement)
|
|
|
|
if total_habits >= 50:
|
|
achievement = award_achievement(db, user_id, "habit_count_50")
|
|
if achievement:
|
|
new_achievements.append(achievement)
|
|
|
|
# Check first habit achievement
|
|
if total_habits >= 1:
|
|
achievement = award_achievement(db, user_id, "first_habit")
|
|
if achievement:
|
|
new_achievements.append(achievement)
|
|
|
|
# Check streak achievements
|
|
current_streak = calculate_current_streak(db, user_id)
|
|
|
|
if current_streak >= 7:
|
|
achievement = award_achievement(db, user_id, "streak_7")
|
|
if achievement:
|
|
new_achievements.append(achievement)
|
|
|
|
if current_streak >= 30:
|
|
achievement = award_achievement(db, user_id, "streak_30")
|
|
if achievement:
|
|
new_achievements.append(achievement)
|
|
|
|
if current_streak >= 100:
|
|
achievement = award_achievement(db, user_id, "streak_100")
|
|
if achievement:
|
|
new_achievements.append(achievement)
|
|
|
|
return new_achievements
|
|
|
|
def process_habit_completion(db: Session, user_id: int, habit_id: int) -> Dict:
|
|
"""Process a habit completion - award XP and check achievements."""
|
|
habit = db.query(models.Habit).filter(
|
|
models.Habit.id == habit_id,
|
|
models.Habit.user_id == user_id
|
|
).first()
|
|
|
|
if not habit:
|
|
raise ValueError("Habit not found")
|
|
|
|
# Award XP based on habit difficulty/reward
|
|
xp_amount = habit.xp_reward or 10
|
|
xp_result = award_xp(db, user_id, xp_amount, "habit_completion")
|
|
|
|
# Check for new achievements
|
|
habit_achievements = check_habit_achievements(db, user_id)
|
|
|
|
# Combine achievement lists
|
|
all_achievements = xp_result.get("new_achievements", []) + habit_achievements
|
|
xp_result["new_achievements"] = all_achievements
|
|
|
|
return xp_result
|