feat: Gamification sprint complete

XP System:
- XP now awarded for attendance (+25), ratings (+10), reviews (+50)
- First-time bonuses for first rating (+25) and first review (+50)
- Streak bonuses (+10 per day, capped at 7x)
- Badge awards automatically grant XP

User Titles & Flair System (Tracker-style):
- Level-based free titles: Rookie → Immortal
- Purchasable titles with XP: Jam Connoisseur, Setlist Savant, etc.
- Username colors purchasable with XP (6 colors + Rainbow)
- Emoji flairs purchasable with XP
- Early adopter perks: exclusive titles, colors, 10% XP bonus

New Fields on User:
- custom_title, title_color, flair
- is_early_adopter, is_supporter
- joined_at

Shop API Endpoints:
- GET /gamification/shop/titles
- POST /gamification/shop/titles/purchase
- GET/POST for colors and flairs
- GET /gamification/user/{id}/display
- GET /gamification/early-adopter-perks

Frontend:
- XP Leaderboard added to home page
- LevelProgressCard shows on profile
This commit is contained in:
fullsizemalt 2025-12-21 19:21:20 -08:00
parent 5ffb428bb8
commit bc804a666b
8 changed files with 745 additions and 3 deletions

View file

@ -180,6 +180,14 @@ class User(SQLModel, table=True):
streak_days: int = Field(default=0, description="Consecutive days active")
last_activity: Optional[datetime] = Field(default=None)
# Custom Titles & Flair (tracker forum style)
custom_title: Optional[str] = Field(default=None, description="Custom title chosen by user")
title_color: Optional[str] = Field(default=None, description="Hex color for username display")
flair: Optional[str] = Field(default=None, description="Small text/emoji beside name")
is_early_adopter: bool = Field(default=False, description="First 100 users get special perks")
is_supporter: bool = Field(default=False, description="Donated/supported the platform")
joined_at: datetime = Field(default_factory=datetime.utcnow)
# Email verification
email_verified: bool = Field(default=False)
verification_token: Optional[str] = Field(default=None)

View file

@ -5,6 +5,7 @@ from database import get_session
from models import Attendance, User, Show
from schemas import AttendanceCreate, AttendanceRead
from auth import get_current_user
from services.gamification import award_xp, check_and_award_badges, update_streak, XP_REWARDS
router = APIRouter(prefix="/attendance", tags=["attendance"])
@ -32,6 +33,12 @@ def mark_attendance(
db_attendance = Attendance(**attendance.model_dump(), user_id=current_user.id)
session.add(db_attendance)
# Award XP for marking attendance
new_xp, level_up = award_xp(session, current_user, XP_REWARDS["attendance_add"], "attendance")
update_streak(session, current_user)
new_badges = check_and_award_badges(session, current_user)
session.commit()
session.refresh(db_attendance)
return db_attendance

View file

@ -188,3 +188,184 @@ async def seed_badge_data(
seed_badges(session)
return {"message": "Badges seeded successfully"}
# ==========================================
# TITLE/FLAIR SHOP ENDPOINTS
# ==========================================
from services.gamification import (
get_available_titles,
get_available_colors,
get_available_flairs,
purchase_title,
purchase_color,
purchase_flair,
get_user_display,
EARLY_ADOPTER_PERKS,
)
class ShopItem(BaseModel):
name: str
type: str
cost: int
level_required: int
is_owned: bool = False
class PurchaseRequest(BaseModel):
item_name: str
@router.get("/shop/titles")
async def get_title_shop(
current_user: User = Depends(get_current_user),
session: Session = Depends(get_session)
):
"""Get available titles for purchase"""
available = get_available_titles(current_user)
return {
"current_title": current_user.custom_title,
"current_xp": current_user.xp,
"items": [
{
"name": title,
"type": info["type"],
"cost": info["cost"],
"level_required": info["level_required"],
"is_owned": current_user.custom_title == title,
}
for title, info in available.items()
]
}
@router.post("/shop/titles/purchase")
async def buy_title(
request: PurchaseRequest,
current_user: User = Depends(get_current_user),
session: Session = Depends(get_session)
):
"""Purchase a title with XP"""
success, message = purchase_title(session, current_user, request.item_name)
if not success:
raise HTTPException(status_code=400, detail=message)
return {
"success": True,
"message": message,
"new_title": current_user.custom_title,
"remaining_xp": current_user.xp,
}
@router.get("/shop/colors")
async def get_color_shop(
current_user: User = Depends(get_current_user),
session: Session = Depends(get_session)
):
"""Get available username colors for purchase"""
available = get_available_colors(current_user)
return {
"current_color": current_user.title_color,
"current_xp": current_user.xp,
"items": [
{
"name": name,
"hex": info["hex"],
"cost": info["cost"],
"is_owned": current_user.title_color == info["hex"],
}
for name, info in available.items()
]
}
@router.post("/shop/colors/purchase")
async def buy_color(
request: PurchaseRequest,
current_user: User = Depends(get_current_user),
session: Session = Depends(get_session)
):
"""Purchase a username color with XP"""
success, message = purchase_color(session, current_user, request.item_name)
if not success:
raise HTTPException(status_code=400, detail=message)
return {
"success": True,
"message": message,
"new_color": current_user.title_color,
"remaining_xp": current_user.xp,
}
@router.get("/shop/flairs")
async def get_flair_shop(
current_user: User = Depends(get_current_user),
session: Session = Depends(get_session)
):
"""Get available flairs for purchase"""
available = get_available_flairs(current_user)
return {
"current_flair": current_user.flair,
"current_xp": current_user.xp,
"items": [
{
"name": flair,
"cost": info["cost"],
"is_owned": current_user.flair == flair,
}
for flair, info in available.items()
]
}
@router.post("/shop/flairs/purchase")
async def buy_flair(
request: PurchaseRequest,
current_user: User = Depends(get_current_user),
session: Session = Depends(get_session)
):
"""Purchase a flair with XP"""
success, message = purchase_flair(session, current_user, request.item_name)
if not success:
raise HTTPException(status_code=400, detail=message)
return {
"success": True,
"message": message,
"new_flair": current_user.flair,
"remaining_xp": current_user.xp,
}
@router.get("/user/{user_id}/display")
async def get_user_display_info(
user_id: int,
session: Session = Depends(get_session)
):
"""Get a user's display info (title, color, flair)"""
user = session.get(User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
return get_user_display(user)
@router.get("/early-adopter-perks")
async def get_early_adopter_info():
"""Get information about early adopter perks"""
return {
"perks": EARLY_ADOPTER_PERKS,
"description": "The first 100 users get exclusive perks including unique titles, colors, "
"and a 10% XP bonus on all actions!"
}

View file

@ -1,10 +1,11 @@
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlmodel import Session, select
from sqlmodel import Session, select, func
from database import get_session
from models import Review, User
from schemas import ReviewCreate, ReviewRead
from auth import get_current_user
from services.gamification import award_xp, check_and_award_badges, update_streak, XP_REWARDS
router = APIRouter(prefix="/reviews", tags=["reviews"])
@ -17,6 +18,21 @@ def create_review(
db_review = Review.model_validate(review)
db_review.user_id = current_user.id
session.add(db_review)
# Check if this is user's first review for bonus XP
review_count = session.exec(
select(func.count(Review.id)).where(Review.user_id == current_user.id)
).one() or 0
# Award XP
xp_amount = XP_REWARDS["review_write"]
if review_count == 0:
xp_amount += XP_REWARDS["first_review"] # Bonus for first review
award_xp(session, current_user, xp_amount, "review")
update_streak(session, current_user)
check_and_award_badges(session, current_user)
session.commit()
session.refresh(db_review)
return db_review

View file

@ -7,6 +7,7 @@ from models import Comment, Rating, User, Profile, Reaction
from schemas import CommentCreate, CommentRead, RatingCreate, RatingRead, ReactionCreate, ReactionRead
from auth import get_current_user
from helpers import create_notification
from services.gamification import award_xp, check_and_award_badges, update_streak, XP_REWARDS
router = APIRouter(prefix="/social", tags=["social"])
@ -102,7 +103,7 @@ def create_rating(
existing_rating = session.exec(query).first()
if existing_rating:
# Update existing
# Update existing (no XP for updating)
existing_rating.score = rating.score
session.add(existing_rating)
session.commit()
@ -112,6 +113,21 @@ def create_rating(
db_rating = Rating.model_validate(rating)
db_rating.user_id = current_user.id
session.add(db_rating)
# Award XP for new rating
# Check if first rating for bonus
rating_count = session.exec(
select(func.count(Rating.id)).where(Rating.user_id == current_user.id)
).one() or 0
xp_amount = XP_REWARDS["rating_submit"]
if rating_count == 0:
xp_amount += XP_REWARDS["first_rating"] # Bonus for first rating
award_xp(session, current_user, xp_amount, "rating")
update_streak(session, current_user)
check_and_award_badges(session, current_user)
session.commit()
session.refresh(db_rating)
return db_rating

View file

@ -255,3 +255,216 @@ def seed_badges(session: Session):
session.commit()
print(f"Seeded {len(BADGE_DEFINITIONS)} badge definitions")
# ==========================================
# USER TITLE & FLAIR SYSTEM (Tracker-style)
# ==========================================
# Titles that can be unlocked at certain levels
LEVEL_TITLES = {
1: ["Rookie", "Newbie", "Fresh Ears"],
3: ["Fan", "Listener", "Devotee"],
5: ["Regular", "Familiar Face", "Couch Tour Pro"],
7: ["Veteran", "Road Warrior", "Tour Rat"],
10: ["Legend", "OG", "Scene Elder"],
12: ["Mythic", "Phenom", "Enlightened"],
15: ["Immortal", "Transcendent One", "Ascended"],
}
# Titles that can be purchased with XP
PURCHASABLE_TITLES = {
"Jam Connoisseur": {"cost": 500, "min_level": 3},
"Setlist Savant": {"cost": 750, "min_level": 5},
"Show Historian": {"cost": 1000, "min_level": 5},
"Type II Specialist": {"cost": 1500, "min_level": 7},
"Heady Scholar": {"cost": 2000, "min_level": 8},
"Rager": {"cost": 500, "min_level": 3},
"Rail Rider": {"cost": 750, "min_level": 4},
"Taper Section Regular": {"cost": 1000, "min_level": 5},
"Lot Lizard": {"cost": 600, "min_level": 4},
"Show Whisperer": {"cost": 2500, "min_level": 10},
}
# Username colors that can be purchased with XP
PURCHASABLE_COLORS = {
"Sage Green": {"hex": "#6B9B6B", "cost": 300, "min_level": 2},
"Ocean Blue": {"hex": "#4A90D9", "cost": 300, "min_level": 2},
"Sunset Orange": {"hex": "#E67E22", "cost": 300, "min_level": 2},
"Royal Purple": {"hex": "#9B59B6", "cost": 500, "min_level": 4},
"Ruby Red": {"hex": "#E74C3C", "cost": 500, "min_level": 4},
"Electric Cyan": {"hex": "#00CED1", "cost": 750, "min_level": 6},
"Gold": {"hex": "#FFD700", "cost": 1000, "min_level": 8},
"Rainbow": {"hex": "gradient", "cost": 2500, "min_level": 10},
}
# Flairs (small text/emoji beside username)
PURCHASABLE_FLAIRS = {
"": {"cost": 100, "min_level": 1},
"🎸": {"cost": 100, "min_level": 1},
"🎵": {"cost": 100, "min_level": 1},
"🌈": {"cost": 200, "min_level": 3},
"🔥": {"cost": 200, "min_level": 3},
"": {"cost": 300, "min_level": 5},
"👑": {"cost": 500, "min_level": 7},
"🚀": {"cost": 400, "min_level": 6},
"💎": {"cost": 750, "min_level": 9},
"🌟": {"cost": 1000, "min_level": 10},
}
# Early adopter perks
EARLY_ADOPTER_PERKS = {
"free_title_change": True, # Early adopters can change title for free (once per month)
"exclusive_titles": ["Pioneer", "Founding Member", "OG User", "Day One"],
"exclusive_colors": {"Pioneer Gold": "#FFB347", "Genesis Green": "#50C878"},
"exclusive_flair": ["🥇", "🏆"],
"title_color": "#FFB347", # Default gold color for early adopters
"bonus_xp_multiplier": 1.1, # 10% XP bonus
}
def get_available_titles(user: User) -> dict:
"""Get all titles available to this user based on level and status"""
available = {}
# Level-based titles
for level, titles in LEVEL_TITLES.items():
if user.level >= level:
for title in titles:
available[title] = {"type": "level", "level_required": level, "cost": 0}
# Purchasable titles
for title, info in PURCHASABLE_TITLES.items():
if user.level >= info["min_level"]:
available[title] = {"type": "purchase", "level_required": info["min_level"], "cost": info["cost"]}
# Early adopter exclusive titles
if user.is_early_adopter:
for title in EARLY_ADOPTER_PERKS["exclusive_titles"]:
available[title] = {"type": "early_adopter", "level_required": 1, "cost": 0}
return available
def get_available_colors(user: User) -> dict:
"""Get all colors available to this user"""
available = {}
for name, info in PURCHASABLE_COLORS.items():
if user.level >= info["min_level"]:
available[name] = {"hex": info["hex"], "cost": info["cost"]}
# Early adopter exclusive colors
if user.is_early_adopter:
for name, hex_color in EARLY_ADOPTER_PERKS["exclusive_colors"].items():
available[name] = {"hex": hex_color, "cost": 0}
return available
def get_available_flairs(user: User) -> dict:
"""Get all flairs available to this user"""
available = {}
for flair, info in PURCHASABLE_FLAIRS.items():
if user.level >= info["min_level"]:
available[flair] = {"cost": info["cost"]}
# Early adopter exclusive flairs
if user.is_early_adopter:
for flair in EARLY_ADOPTER_PERKS["exclusive_flair"]:
available[flair] = {"cost": 0}
return available
def purchase_title(session: Session, user: User, title: str) -> Tuple[bool, str]:
"""Attempt to purchase a title. Returns (success, message)"""
available = get_available_titles(user)
if title not in available:
return False, "Title not available at your level"
info = available[title]
cost = info["cost"]
# Early adopters get free title changes for level/early_adopter titles
if user.is_early_adopter and info["type"] in ["level", "early_adopter"]:
cost = 0
if user.xp < cost:
return False, f"Not enough XP. Need {cost}, have {user.xp}"
# Deduct XP and set title
user.xp -= cost
user.custom_title = title
session.add(user)
session.commit()
return True, f"Title '{title}' acquired!" + (f" (-{cost} XP)" if cost > 0 else " (Free!)")
def purchase_color(session: Session, user: User, color_name: str) -> Tuple[bool, str]:
"""Attempt to purchase a username color"""
available = get_available_colors(user)
if color_name not in available:
return False, "Color not available at your level"
info = available[color_name]
cost = info["cost"]
if user.xp < cost:
return False, f"Not enough XP. Need {cost}, have {user.xp}"
# Deduct XP and set color
user.xp -= cost
user.title_color = info["hex"]
session.add(user)
session.commit()
return True, f"Color '{color_name}' applied!" + (f" (-{cost} XP)" if cost > 0 else " (Free!)")
def purchase_flair(session: Session, user: User, flair: str) -> Tuple[bool, str]:
"""Attempt to purchase a flair"""
available = get_available_flairs(user)
if flair not in available:
return False, "Flair not available at your level"
info = available[flair]
cost = info["cost"]
if user.xp < cost:
return False, f"Not enough XP. Need {cost}, have {user.xp}"
# Deduct XP and set flair
user.xp -= cost
user.flair = flair
session.add(user)
session.commit()
return True, f"Flair {flair} acquired!" + (f" (-{cost} XP)" if cost > 0 else " (Free!)")
def get_user_display(user: User) -> dict:
"""Get the full display info for a user including title, color, flair"""
username = user.email.split("@")[0] if user.email else "Anonymous"
# Determine title to show
display_title = user.custom_title
if not display_title:
display_title = LEVEL_NAMES.get(user.level, "User")
return {
"username": username,
"title": display_title,
"color": user.title_color,
"flair": user.flair,
"level": user.level,
"xp": user.xp,
"is_early_adopter": user.is_early_adopter,
"is_supporter": user.is_supporter,
}

295
docs/AUDIT_AND_PLAN.md Normal file
View file

@ -0,0 +1,295 @@
# Elmeg Platform Audit & Implementation Plan
>
> **Date**: December 22, 2024
---
## Executive Summary
This audit examines the Elmeg platform against spec'd features, user stories, and interaction gaps. The platform has strong core functionality but has several incomplete areas that impact user experience.
---
## 🔴 Critical Gaps (High Priority)
### 1. Email System Not Functional
**Status**: Backend model ready, email sending not implemented
**Impact**: Users cannot:
- Verify their email addresses
- Reset passwords
- Receive notification emails
**User Stories Affected**:
- ❌ "As a new user, I want to receive a verification email"
- ❌ "As a user, I want to reset my password if I forgot it"
**Fix Required**:
- Implement `backend/services/email_service.py`
- Integrate with AWS SES (docs exist at `AWS_SES_SETUP.md`)
- Connect auth endpoints to email service
---
### 2. XP Not Actually Awarded
**Status**: Models and endpoints exist, but XP isn't awarded on actions
**Impact**: Gamification system is purely cosmetic - actions don't increase XP
**User Stories Affected**:
- ❌ "As a user, I want to earn XP when I rate a performance"
- ❌ "As a user, I want to earn XP when I mark attendance"
**Fix Required**:
- Hook `award_xp()` into attendance, rating, review endpoints
- Call `check_and_award_badges()` after XP-earning actions
---
### 3. Frontend Not Using Slug URLs
**Status**: API supports slugs, frontend still uses numeric IDs
**Impact**: URLs are non-memorable (e.g., `/songs/69` instead of `/songs/tweezer`)
**Fix Required**:
- Update all `<Link>` components to use slug
- Add slug to API response schemas
- Update frontend routing to accept slug params
---
## 🟡 Important Gaps (Medium Priority)
### 4. Onboarding Flow Incomplete
**Status**: `/welcome` page exists but is minimal
**Gaps**:
- No guided tour for new users
- No prompt to set up profile
- No progressive disclosure of features
**User Stories Affected**:
- ❌ "As a new user, I want a guided introduction to the platform"
---
### 5. Chase Song "Mark as Caught" Not Wired
**Status**: Backend endpoint exists, no frontend UI
**Impact**: Users can add chase songs but can't mark them as caught at shows
**Fix Required**:
- Add "Mark Caught" button on show detail page
- Connect to `POST /chase/songs/{id}/caught`
---
### 6. Performance Rating Disconnected
**Status**: RatingInput component exists, not connected to performances
**Impact**: Users can see ratings but can't submit their own on performance pages
**Fix Required**:
- Wire up `POST /ratings` endpoint on performance detail page
- Award XP when rating is submitted
---
### 7. Notification Center Empty
**Status**: Backend + frontend components exist, no triggers
**Impact**: Bell icon in header shows nothing useful
**Fix Required**:
- Create notifications on: ratings received, badge earned, reply to comment
- Add notification sound/toast for new notifications
---
### 8. Groups Feature Skeletal
**Status**: CRUD exists, no member activity
**Gaps**:
- Can't see what members are doing
- No group leaderboards
- No group chat/discussions
---
## 🟢 Working Features (Verified)
| Feature | Status | Notes |
|---------|--------|-------|
| User registration/login | ✅ | Works |
| Show/Song/Venue browsing | ✅ | Working |
| Performance detail pages | ✅ | With navigation |
| Slug-based API lookups | ✅ | All entities |
| Comment sections | ✅ | Threaded |
| Review system | ✅ | With ratings |
| Chase song list | ✅ | Add/remove works |
| Attendance tracking | ✅ | Basic |
| Profile page | ✅ | With stats |
| Activity feed | ✅ | Global |
| Heady Version display | ✅ | Top performances |
| Admin panel | ✅ | User/content management |
| Mod panel | ✅ | Reports/nicknames |
| Theme toggle | ✅ | Light/dark |
| Settings/preferences | ✅ | Wiki mode |
---
## 📋 Implementation Plan
### Sprint 1: Critical Infrastructure (Est. 4-6 hours)
#### 1.1 Email Service Integration
```
- [ ] Create EmailService class with AWS SES
- [ ] Implement send_verification_email()
- [ ] Implement send_password_reset_email()
- [ ] Wire up auth endpoints
- [ ] Test email flow end-to-end
```
#### 1.2 XP Award Hooks
```
- [ ] Hook award_xp() into attendance.py
- [ ] Hook award_xp() into reviews.py
- [ ] Hook award_xp() into ratings endpoint
- [ ] Call check_and_award_badges() automatically
- [ ] Add "XP earned" toast feedback on frontend
```
---
### Sprint 2: URL & UX Polish (Est. 3-4 hours)
#### 2.1 Slug URLs on Frontend
```
- [ ] Add slug to Song, Show, Venue, Performance response schemas
- [ ] Update Link components to use slug
- [ ] Verify all routes work with slugs
- [ ] Update internal links in ActivityFeed
- [ ] Update search results to use slugs
```
#### 2.2 Performance Rating Widget
```
- [ ] Add RatingInput to performance detail page
- [ ] Connect to POST /ratings endpoint
- [ ] Show user's existing rating if any
- [ ] Animate rating confirmation
- [ ] Award XP on rating
```
---
### Sprint 3: Feature Completion (Est. 4-5 hours)
#### 3.1 Chase Song Completion
```
- [ ] Add "Mark Caught" button on show detail page
- [ ] Show user's chase songs that match show setlist
- [ ] Animate "caught" celebration
- [ ] Award badge for catching 5 songs
```
#### 3.2 Notification Triggers
```
- [ ] Create notification on badge earned
- [ ] Create notification on comment reply
- [ ] Create notification on review reaction
- [ ] Add toast/sound for new notifications
```
#### 3.3 Onboarding Experience
```
- [ ] Create multi-step welcome wizard
- [ ] Prompt profile setup (bio, avatar)
- [ ] Highlight key features
- [ ] Set first badge on completion
```
---
### Sprint 4: Social Enhancement (Est. 3-4 hours)
#### 4.1 XP Leaderboard Integration
```
- [ ] Add XP leaderboard to home page
- [ ] Add leaderboard to /leaderboards page
- [ ] Add "Your Rank" indicator
- [ ] Weekly/monthly/all-time views
```
#### 4.2 Groups Upgrade
```
- [ ] Show member activity in group
- [ ] Group XP leaderboard
- [ ] Group attendance stats
```
---
## 📊 Priority Matrix
| Item | Impact | Effort | Priority |
|------|--------|--------|----------|
| Email service | High | Medium | P1 |
| XP award hooks | High | Low | P1 |
| Slug URLs on frontend | Medium | Low | P2 |
| Performance rating widget | High | Low | P2 |
| Chase "Mark Caught" | Medium | Low | P2 |
| Notification triggers | Medium | Medium | P3 |
| Onboarding wizard | Medium | Medium | P3 |
| Groups enhancement | Low | High | P4 |
---
## Recommended Execution Order
1. **Now**: XP award hooks (quick win, high impact)
2. **Today**: Performance rating widget
3. **Today**: Slug URLs on frontend
4. **Next**: Email service (requires AWS config)
5. **Next**: Chase song completion
6. **Later**: Notifications, onboarding, groups
---
## Quick Wins (Can Do in 30 min each)
1. ✨ Wire XP awards to attendance/review endpoints
2. 🎯 Add performance rating widget
3. 🔗 Update frontend links to use slugs
4. 🏆 Add XP leaderboard to home page
5. 🎵 Add "Mark Caught" button to show pages

View file

@ -1,4 +1,5 @@
import { ActivityFeed } from "@/components/feed/activity-feed"
import { XPLeaderboard } from "@/components/gamification/xp-leaderboard"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import Link from "next/link"
@ -202,7 +203,7 @@ export default async function Home() {
</section>
{/* Activity Feed */}
<section className="space-y-4 lg:col-span-2">
<section className="space-y-4 lg:col-span-1">
<div className="flex items-center justify-between">
<h2 className="text-xl font-bold">Recent Activity</h2>
<Link href="/leaderboards" className="text-sm text-muted-foreground hover:underline flex items-center gap-1">
@ -211,6 +212,11 @@ export default async function Home() {
</div>
<ActivityFeed />
</section>
{/* XP Leaderboard */}
<section className="space-y-4 lg:col-span-1">
<XPLeaderboard />
</section>
</div>
{/* Quick Links */}