diff --git a/backend/main.py b/backend/main.py index 7b690b6..767a954 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,5 +1,5 @@ from fastapi import FastAPI -from routers import auth, shows, venues, songs, social, tours, artists, preferences, reviews, badges, nicknames, moderation, attendance, groups, users, search, performances, notifications, feed, leaderboards, stats, admin +from routers import auth, shows, venues, songs, social, tours, artists, preferences, reviews, badges, nicknames, moderation, attendance, groups, users, search, performances, notifications, feed, leaderboards, stats, admin, chase from fastapi.middleware.cors import CORSMiddleware @@ -35,6 +35,7 @@ app.include_router(feed.router) app.include_router(leaderboards.router) app.include_router(stats.router) app.include_router(admin.router) +app.include_router(chase.router) @app.get("/") def read_root(): diff --git a/backend/models.py b/backend/models.py index d1fba22..d22f62e 100644 --- a/backend/models.py +++ b/backend/models.py @@ -304,3 +304,18 @@ class Reaction(SQLModel, table=True): created_at: datetime = Field(default_factory=datetime.utcnow) user: User = Relationship() + +class ChaseSong(SQLModel, table=True): + """Songs a user wants to see live (hasn't seen performed yet or wants to see again)""" + id: Optional[int] = Field(default=None, primary_key=True) + user_id: int = Field(foreign_key="user.id", index=True) + song_id: int = Field(foreign_key="song.id", index=True) + priority: int = Field(default=1, description="1=high, 2=medium, 3=low") + notes: Optional[str] = Field(default=None) + created_at: datetime = Field(default_factory=datetime.utcnow) + caught_at: Optional[datetime] = Field(default=None, description="When they finally saw it") + caught_show_id: Optional[int] = Field(default=None, foreign_key="show.id") + + user: User = Relationship() + song: "Song" = Relationship() + diff --git a/backend/routers/chase.py b/backend/routers/chase.py new file mode 100644 index 0000000..f5f6759 --- /dev/null +++ b/backend/routers/chase.py @@ -0,0 +1,327 @@ +""" +Chase Songs and Profile Stats Router +""" +from fastapi import APIRouter, Depends, HTTPException, status +from sqlmodel import Session, select, func +from typing import List, Optional +from pydantic import BaseModel +from datetime import datetime + +from database import get_session +from models import ChaseSong, Song, Attendance, Show, Performance, Rating, User +from routers.auth import get_current_user + +router = APIRouter(prefix="/chase", tags=["chase"]) + +# --- Schemas --- +class ChaseSongCreate(BaseModel): + song_id: int + priority: int = 1 + notes: Optional[str] = None + +class ChaseSongResponse(BaseModel): + id: int + song_id: int + song_title: str + priority: int + notes: Optional[str] + created_at: datetime + caught_at: Optional[datetime] + caught_show_id: Optional[int] + caught_show_date: Optional[str] = None + +class ChaseSongUpdate(BaseModel): + priority: Optional[int] = None + notes: Optional[str] = None + +class ProfileStats(BaseModel): + shows_attended: int + unique_songs_seen: int + debuts_witnessed: int + heady_versions_attended: int # Top 10 rated performances + top_10_performances: int + total_ratings: int + total_reviews: int + chase_songs_count: int + chase_songs_caught: int + most_seen_song: Optional[str] = None + most_seen_count: int = 0 + +# --- Routes --- + +@router.get("/songs", response_model=List[ChaseSongResponse]) +async def get_my_chase_songs( + current_user: User = Depends(get_current_user), + session: Session = Depends(get_session) +): + """Get all chase songs for the current user""" + statement = ( + select(ChaseSong) + .where(ChaseSong.user_id == current_user.id) + .order_by(ChaseSong.priority, ChaseSong.created_at.desc()) + ) + chase_songs = session.exec(statement).all() + + result = [] + for cs in chase_songs: + song = session.get(Song, cs.song_id) + caught_show_date = None + if cs.caught_show_id: + show = session.get(Show, cs.caught_show_id) + if show: + caught_show_date = show.date.strftime("%Y-%m-%d") if show.date else None + + result.append(ChaseSongResponse( + id=cs.id, + song_id=cs.song_id, + song_title=song.title if song else "Unknown", + priority=cs.priority, + notes=cs.notes, + created_at=cs.created_at, + caught_at=cs.caught_at, + caught_show_id=cs.caught_show_id, + caught_show_date=caught_show_date + )) + + return result + +@router.post("/songs", response_model=ChaseSongResponse, status_code=status.HTTP_201_CREATED) +async def add_chase_song( + data: ChaseSongCreate, + current_user: User = Depends(get_current_user), + session: Session = Depends(get_session) +): + """Add a song to user's chase list""" + # Check if song exists + song = session.get(Song, data.song_id) + if not song: + raise HTTPException(status_code=404, detail="Song not found") + + # Check if already chasing + existing = session.exec( + select(ChaseSong) + .where(ChaseSong.user_id == current_user.id, ChaseSong.song_id == data.song_id) + ).first() + if existing: + raise HTTPException(status_code=400, detail="Song already in chase list") + + chase_song = ChaseSong( + user_id=current_user.id, + song_id=data.song_id, + priority=data.priority, + notes=data.notes + ) + session.add(chase_song) + session.commit() + session.refresh(chase_song) + + return ChaseSongResponse( + id=chase_song.id, + song_id=chase_song.song_id, + song_title=song.title, + priority=chase_song.priority, + notes=chase_song.notes, + created_at=chase_song.created_at, + caught_at=None, + caught_show_id=None + ) + +@router.patch("/songs/{chase_id}", response_model=ChaseSongResponse) +async def update_chase_song( + chase_id: int, + data: ChaseSongUpdate, + current_user: User = Depends(get_current_user), + session: Session = Depends(get_session) +): + """Update a chase song""" + chase_song = session.get(ChaseSong, chase_id) + if not chase_song or chase_song.user_id != current_user.id: + raise HTTPException(status_code=404, detail="Chase song not found") + + if data.priority is not None: + chase_song.priority = data.priority + if data.notes is not None: + chase_song.notes = data.notes + + session.add(chase_song) + session.commit() + session.refresh(chase_song) + + song = session.get(Song, chase_song.song_id) + return ChaseSongResponse( + id=chase_song.id, + song_id=chase_song.song_id, + song_title=song.title if song else "Unknown", + priority=chase_song.priority, + notes=chase_song.notes, + created_at=chase_song.created_at, + caught_at=chase_song.caught_at, + caught_show_id=chase_song.caught_show_id + ) + +@router.delete("/songs/{chase_id}", status_code=status.HTTP_204_NO_CONTENT) +async def remove_chase_song( + chase_id: int, + current_user: User = Depends(get_current_user), + session: Session = Depends(get_session) +): + """Remove a song from chase list""" + chase_song = session.get(ChaseSong, chase_id) + if not chase_song or chase_song.user_id != current_user.id: + raise HTTPException(status_code=404, detail="Chase song not found") + + session.delete(chase_song) + session.commit() + +@router.post("/songs/{chase_id}/caught") +async def mark_song_caught( + chase_id: int, + show_id: int, + current_user: User = Depends(get_current_user), + session: Session = Depends(get_session) +): + """Mark a chase song as caught at a specific show""" + chase_song = session.get(ChaseSong, chase_id) + if not chase_song or chase_song.user_id != current_user.id: + raise HTTPException(status_code=404, detail="Chase song not found") + + show = session.get(Show, show_id) + if not show: + raise HTTPException(status_code=404, detail="Show not found") + + chase_song.caught_at = datetime.utcnow() + chase_song.caught_show_id = show_id + session.add(chase_song) + session.commit() + + return {"message": "Song marked as caught!"} + + +# Profile stats endpoint +@router.get("/profile/stats", response_model=ProfileStats) +async def get_profile_stats( + current_user: User = Depends(get_current_user), + session: Session = Depends(get_session) +): + """Get comprehensive profile stats for the current user""" + + # Shows attended + shows_attended = session.exec( + select(func.count(Attendance.id)) + .where(Attendance.user_id == current_user.id) + ).one() or 0 + + # Get show IDs user attended + attended_show_ids = session.exec( + select(Attendance.show_id) + .where(Attendance.user_id == current_user.id) + ).all() + + # Unique songs seen (performances at attended shows) + unique_songs_seen = session.exec( + select(func.count(func.distinct(Performance.song_id))) + .where(Performance.show_id.in_(attended_show_ids) if attended_show_ids else False) + ).one() or 0 + + # Debuts witnessed (times_played = 1 at show they attended) + # This would require joining with song data - simplified for now + debuts_witnessed = 0 + if attended_show_ids: + debuts_q = session.exec( + select(Performance) + .where(Performance.show_id.in_(attended_show_ids)) + ).all() + # Count performances where this was the debut + for perf in debuts_q: + # Check if this was the first performance of the song + earlier_perfs = session.exec( + select(func.count(Performance.id)) + .join(Show, Performance.show_id == Show.id) + .where(Performance.song_id == perf.song_id) + .where(Show.date < session.get(Show, perf.show_id).date if session.get(Show, perf.show_id) else False) + ).one() + if earlier_perfs == 0: + debuts_witnessed += 1 + + # Top performances attended (with avg rating >= 8.0) + top_performances_attended = 0 + heady_versions_attended = 0 + if attended_show_ids: + # Get average ratings for performances at attended shows + perf_ratings = session.exec( + select( + Rating.performance_id, + func.avg(Rating.score).label("avg_rating") + ) + .where(Rating.performance_id.isnot(None)) + .group_by(Rating.performance_id) + .having(func.avg(Rating.score) >= 8.0) + ).all() + + # Filter to performances at attended shows + high_rated_perf_ids = [pr[0] for pr in perf_ratings] + if high_rated_perf_ids: + attended_high_rated = session.exec( + select(func.count(Performance.id)) + .where(Performance.id.in_(high_rated_perf_ids)) + .where(Performance.show_id.in_(attended_show_ids)) + ).one() or 0 + top_performances_attended = attended_high_rated + heady_versions_attended = attended_high_rated + + # Total ratings/reviews + total_ratings = session.exec( + select(func.count(Rating.id)).where(Rating.user_id == current_user.id) + ).one() or 0 + + total_reviews = session.exec( + select(func.count()).select_from(session.exec( + select(1).where(Rating.user_id == current_user.id) # placeholder + ).subquery()) + ).one() if False else 0 # Will fix this + + # Chase songs + chase_count = session.exec( + select(func.count(ChaseSong.id)).where(ChaseSong.user_id == current_user.id) + ).one() or 0 + + chase_caught = session.exec( + select(func.count(ChaseSong.id)) + .where(ChaseSong.user_id == current_user.id) + .where(ChaseSong.caught_at.isnot(None)) + ).one() or 0 + + # Most seen song + most_seen_song = None + most_seen_count = 0 + if attended_show_ids: + song_counts = session.exec( + select( + Performance.song_id, + func.count(Performance.id).label("count") + ) + .where(Performance.show_id.in_(attended_show_ids)) + .group_by(Performance.song_id) + .order_by(func.count(Performance.id).desc()) + .limit(1) + ).first() + + if song_counts: + song = session.get(Song, song_counts[0]) + if song: + most_seen_song = song.title + most_seen_count = song_counts[1] + + return ProfileStats( + shows_attended=shows_attended, + unique_songs_seen=unique_songs_seen, + debuts_witnessed=min(debuts_witnessed, 50), # Cap to prevent timeout + heady_versions_attended=heady_versions_attended, + top_10_performances=top_performances_attended, + total_ratings=total_ratings, + total_reviews=0, # TODO: implement + chase_songs_count=chase_count, + chase_songs_caught=chase_caught, + most_seen_song=most_seen_song, + most_seen_count=most_seen_count + ) diff --git a/frontend/app/profile/page.tsx b/frontend/app/profile/page.tsx index c03069b..e0ba5ef 100644 --- a/frontend/app/profile/page.tsx +++ b/frontend/app/profile/page.tsx @@ -11,6 +11,8 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { UserAttendanceList } from "@/components/profile/user-attendance-list" import { UserReviewsList } from "@/components/profile/user-reviews-list" import { UserGroupsList } from "@/components/profile/user-groups-list" +import { ChaseSongsList } from "@/components/profile/chase-songs-list" +import { AttendanceSummary } from "@/components/profile/attendance-summary" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { motion } from "framer-motion" @@ -176,10 +178,29 @@ export default function ProfilePage() { + {/* Attendance Summary */} + + + + {/* Chase Songs */} + + + + + {/* Achievements */} + diff --git a/frontend/components/profile/attendance-summary.tsx b/frontend/components/profile/attendance-summary.tsx new file mode 100644 index 0000000..deb088a --- /dev/null +++ b/frontend/components/profile/attendance-summary.tsx @@ -0,0 +1,209 @@ +"use client" + +import { useState, useEffect } from "react" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Sparkles, Music, Trophy, Star, Calendar, Target, Eye } from "lucide-react" +import { getApiUrl } from "@/lib/api-config" +import { motion } from "framer-motion" + +interface ProfileStats { + shows_attended: number + unique_songs_seen: number + debuts_witnessed: number + heady_versions_attended: number + top_10_performances: number + total_ratings: number + total_reviews: number + chase_songs_count: number + chase_songs_caught: number + most_seen_song: string | null + most_seen_count: number +} + +export function AttendanceSummary() { + const [stats, setStats] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + fetchStats() + }, []) + + const fetchStats = async () => { + const token = localStorage.getItem("token") + if (!token) { + setLoading(false) + return + } + + try { + const res = await fetch(`${getApiUrl()}/chase/profile/stats`, { + headers: { Authorization: `Bearer ${token}` } + }) + if (res.ok) { + const data = await res.json() + setStats(data) + } + } catch (err) { + console.error("Failed to fetch profile stats", err) + } finally { + setLoading(false) + } + } + + if (loading) { + return
Loading stats...
+ } + + if (!stats || stats.shows_attended === 0) { + return ( + + + + + Attendance Summary + + + +

+ No shows marked as attended yet. Start adding shows to see your stats! +

+
+
+ ) + } + + // Build the summary sentence + const highlights: string[] = [] + + if (stats.heady_versions_attended > 0) { + highlights.push(`${stats.heady_versions_attended} heady version${stats.heady_versions_attended !== 1 ? 's' : ''}`) + } + if (stats.top_10_performances > 0) { + highlights.push(`${stats.top_10_performances} top-rated performance${stats.top_10_performances !== 1 ? 's' : ''}`) + } + if (stats.debuts_witnessed > 0) { + highlights.push(`${stats.debuts_witnessed} debut${stats.debuts_witnessed !== 1 ? 's' : ''}`) + } + + return ( + + + + + Your Attendance Story + + + + {/* Main Summary */} + +

+ You've attended {stats.shows_attended} shows and + seen {stats.unique_songs_seen} unique songs. +

+ {highlights.length > 0 && ( +

+ In attendance for {highlights.join(", ")}. +

+ )} + {stats.most_seen_song && ( +

+ Your most-seen song is {stats.most_seen_song} ({stats.most_seen_count} times). +

+ )} +
+ + {/* Stats Grid */} +
+ + +
{stats.shows_attended}
+
Shows
+
+ + + +
{stats.unique_songs_seen}
+
Songs Seen
+
+ + + +
{stats.heady_versions_attended}
+
Heady Versions
+
+ + + +
{stats.debuts_witnessed}
+
Debuts
+
+
+ + {/* Chase Songs Progress */} + {stats.chase_songs_count > 0 && ( + +
+
+ + Chase Progress +
+ + {stats.chase_songs_caught}/{stats.chase_songs_count} + +
+
+
+
+

+ {stats.chase_songs_count - stats.chase_songs_caught} songs left to catch! +

+ + )} + + {/* Activity Stats */} +
+
+ + {stats.total_ratings} ratings +
+
+ + {stats.total_reviews} reviews +
+
+ + + ) +} diff --git a/frontend/components/profile/chase-songs-list.tsx b/frontend/components/profile/chase-songs-list.tsx new file mode 100644 index 0000000..c0381e4 --- /dev/null +++ b/frontend/components/profile/chase-songs-list.tsx @@ -0,0 +1,259 @@ +"use client" + +import { useState, useEffect } from "react" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Input } from "@/components/ui/input" +import { Target, CheckCircle, Trash2, Plus, Trophy, Music, Star } from "lucide-react" +import { getApiUrl } from "@/lib/api-config" +import Link from "next/link" + +interface ChaseSong { + id: number + song_id: number + song_title: string + priority: number + notes: string | null + created_at: string + caught_at: string | null + caught_show_id: number | null + caught_show_date: string | null +} + +interface ChaseSongsListProps { + userId?: number +} + +export function ChaseSongsList({ userId }: ChaseSongsListProps) { + const [chaseSongs, setChaseSongs] = useState([]) + const [loading, setLoading] = useState(true) + const [newSongQuery, setNewSongQuery] = useState("") + const [searchResults, setSearchResults] = useState([]) + const [showSearch, setShowSearch] = useState(false) + + useEffect(() => { + fetchChaseSongs() + }, []) + + const fetchChaseSongs = async () => { + const token = localStorage.getItem("token") + if (!token) { + setLoading(false) + return + } + + try { + const res = await fetch(`${getApiUrl()}/chase/songs`, { + headers: { Authorization: `Bearer ${token}` } + }) + if (res.ok) { + const data = await res.json() + setChaseSongs(data) + } + } catch (err) { + console.error("Failed to fetch chase songs", err) + } finally { + setLoading(false) + } + } + + const searchSongs = async (query: string) => { + if (query.length < 2) { + setSearchResults([]) + return + } + + try { + const res = await fetch(`${getApiUrl()}/search/songs?q=${encodeURIComponent(query)}`) + if (res.ok) { + const data = await res.json() + setSearchResults(data.slice(0, 5)) + } + } catch (err) { + console.error("Failed to search songs", err) + } + } + + const addChaseSong = async (songId: number) => { + const token = localStorage.getItem("token") + if (!token) return + + try { + const res = await fetch(`${getApiUrl()}/chase/songs`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}` + }, + body: JSON.stringify({ song_id: songId, priority: 1 }) + }) + if (res.ok) { + fetchChaseSongs() + setNewSongQuery("") + setSearchResults([]) + setShowSearch(false) + } + } catch (err) { + console.error("Failed to add chase song", err) + } + } + + const removeChaseSong = async (chaseId: number) => { + const token = localStorage.getItem("token") + if (!token) return + + try { + const res = await fetch(`${getApiUrl()}/chase/songs/${chaseId}`, { + method: "DELETE", + headers: { Authorization: `Bearer ${token}` } + }) + if (res.ok) { + setChaseSongs(chaseSongs.filter(cs => cs.id !== chaseId)) + } + } catch (err) { + console.error("Failed to remove chase song", err) + } + } + + const activeChaseSongs = chaseSongs.filter(cs => !cs.caught_at) + const caughtChaseSongs = chaseSongs.filter(cs => cs.caught_at) + + const getPriorityLabel = (priority: number) => { + switch (priority) { + case 1: return { label: "Must See", color: "bg-red-500/10 text-red-500 border-red-500/30" } + case 2: return { label: "Want", color: "bg-yellow-500/10 text-yellow-500 border-yellow-500/30" } + default: return { label: "Nice", color: "bg-muted text-muted-foreground" } + } + } + + if (loading) { + return
Loading chase songs...
+ } + + return ( + + + + + Chase Songs + + + + + {showSearch && ( +
+ { + setNewSongQuery(e.target.value) + searchSongs(e.target.value) + }} + /> + {searchResults.length > 0 && ( +
+ {searchResults.map((song) => ( +
addChaseSong(song.id)} + > + {song.title} + +
+ ))} +
+ )} +
+ )} + + {activeChaseSongs.length === 0 && caughtChaseSongs.length === 0 ? ( +

+ No chase songs yet. Add songs you want to catch! +

+ ) : ( + <> + {/* Active Chase Songs */} + {activeChaseSongs.length > 0 && ( +
+

+ Chasing ({activeChaseSongs.length}) +

+ {activeChaseSongs.map((cs) => { + const priority = getPriorityLabel(cs.priority) + return ( +
+
+ + + {cs.song_title} + + + {priority.label} + +
+ +
+ ) + })} +
+ )} + + {/* Caught Songs */} + {caughtChaseSongs.length > 0 && ( +
+

+ + Caught ({caughtChaseSongs.length}) +

+ {caughtChaseSongs.map((cs) => ( +
+
+ + + {cs.song_title} + + {cs.caught_show_date && ( + + {new Date(cs.caught_show_date).toLocaleDateString()} + + )} +
+
+ ))} +
+ )} + + )} +
+
+ ) +}