From ff411321c6ceb351cef39bde798f134ef45d3010 Mon Sep 17 00:00:00 2001 From: fullsizemalt <106900403+fullsizemalt@users.noreply.github.com> Date: Wed, 24 Dec 2025 10:29:13 -0800 Subject: [PATCH] feat(stats): add set breakdown to song page --- backend/schemas.py | 3 +- backend/services/stats.py | 12 +- frontend/app/songs/[slug]/page.tsx | 234 +++++++++++++++++++++++++++++ 3 files changed, 247 insertions(+), 2 deletions(-) create mode 100644 frontend/app/songs/[slug]/page.tsx diff --git a/backend/schemas.py b/backend/schemas.py index 4c154c0..6a16e5a 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -1,4 +1,4 @@ -from typing import Optional, List +from typing import Optional, List, Dict from sqlmodel import SQLModel from datetime import datetime @@ -111,6 +111,7 @@ class SongReadWithStats(SongRead): times_played: int gap: int last_played: Optional[datetime] = None + set_breakdown: Dict[str, int] = {} performances: List[PerformanceReadWithShow] = [] class PerformanceDetailRead(PerformanceRead): diff --git a/backend/services/stats.py b/backend/services/stats.py index 2485a50..6070436 100644 --- a/backend/services/stats.py +++ b/backend/services/stats.py @@ -28,10 +28,20 @@ def get_song_stats(session: Session, song_id: int): select(func.count(Show.id)).where(Show.date > last_performance.date) ).one() + # Set Breakdown + set_stats_query = session.exec( + select(Performance.set_name, func.count(Performance.id)) + .where(Performance.song_id == song_id) + .group_by(Performance.set_name) + ).all() + + set_breakdown = {row[0]: row[1] for row in set_stats_query if row[0]} + return { "times_played": times_played, "last_played": last_performance.date if last_performance else None, - "gap": gap + "gap": gap, + "set_breakdown": set_breakdown } def check_and_award_badges(session: Session, user_id: int): diff --git a/frontend/app/songs/[slug]/page.tsx b/frontend/app/songs/[slug]/page.tsx new file mode 100644 index 0000000..37edfb1 --- /dev/null +++ b/frontend/app/songs/[slug]/page.tsx @@ -0,0 +1,234 @@ + +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { ArrowLeft, PlayCircle, History, Calendar, Trophy, Youtube, Star } from "lucide-react" +import Link from "next/link" +import { notFound } from "next/navigation" +import { Badge } from "@/components/ui/badge" +import { getApiUrl } from "@/lib/api-config" +import { CommentSection } from "@/components/social/comment-section" +import { EntityRating } from "@/components/social/entity-rating" +import { EntityReviews } from "@/components/reviews/entity-reviews" +import { SocialWrapper } from "@/components/social/social-wrapper" +import { PerformanceList } from "@/components/songs/performance-list" +import { SongEvolutionChart } from "@/components/songs/song-evolution-chart" +import { YouTubeEmbed } from "@/components/ui/youtube-embed" + +async function getSong(id: string) { + try { + const res = await fetch(`${getApiUrl()}/songs/${id}`, { cache: 'no-store' }) + if (!res.ok) return null + return res.json() + } catch (e) { + console.error(e) + return null + } +} + +// Get top rated performances for "Heady Version" leaderboard +function getHeadyVersions(performances: any[]) { + if (!performances || performances.length === 0) return [] + return [...performances] + .filter(p => p.avg_rating && p.rating_count > 0) + .sort((a, b) => b.avg_rating - a.avg_rating) + .slice(0, 5) +} + +export default async function SongDetailPage({ params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params + const song = await getSong(slug) + + if (!song) { + notFound() + } + + const headyVersions = getHeadyVersions(song.performances || []) + const topPerformance = headyVersions[0] + + return ( +
+
+
+ + + +
+
+

{song.title}

+ {song.original_artist && ( + ({song.original_artist}) + )} +
+ {song.tags && song.tags.length > 0 && ( +
+ {song.tags.map((tag: any) => ( + + #{tag.name} + + ))} +
+ )} +
+
+ + + +
+ +
+ + + Times Played + + +
+ + {song.times_played} +
+
+
+ + + Gap (Shows) + + +
+ + {song.gap} +
+
+
+ + + Last Played + + +
+ + {song.last_played ? new Date(song.last_played).toLocaleDateString() : "Never"} +
+
+
+
+ + {/* Set Breakdown */} + {song.set_breakdown && Object.keys(song.set_breakdown).length > 0 && ( + + + Set Distribution + + +
+ {Object.entries(song.set_breakdown).sort((a, b) => (b[1] as number) - (a[1] as number)).map(([set, count]) => ( +
+ {count as number} + {set} +
+ ))} +
+
+
+ )} + + {/* Heady Version Section */} + {headyVersions.length > 0 && ( + + + + + Heady Version Leaderboard + + + + {/* Top Performance with YouTube */} + {topPerformance && ( +
+ {topPerformance.youtube_link ? ( + + ) : song.youtube_link ? ( + + ) : ( +
+
+ +

No video available

+
+
+ )} +
+
+ 🏆 #1 Heady +
+

+ {topPerformance.show?.date ? new Date(topPerformance.show.date).toLocaleDateString() : "Unknown Date"} +

+

+ {topPerformance.show?.venue?.name || "Unknown Venue"} +

+
+ + {topPerformance.avg_rating?.toFixed(1)} + ({topPerformance.rating_count} ratings) +
+
+
+ )} + + {/* Leaderboard List */} +
+ {headyVersions.map((perf, index) => ( +
+
+ + {index === 0 ? "🥇" : index === 1 ? "🥈" : index === 2 ? "🥉" : `${index + 1}.`} + +
+

+ {perf.show?.date ? new Date(perf.show.date).toLocaleDateString() : "Unknown"} +

+

+ {perf.show?.venue?.name || "Unknown Venue"} +

+
+
+
+ {perf.youtube_link && ( + + + + )} +
+ {perf.avg_rating?.toFixed(1)}★ + ({perf.rating_count}) +
+
+
+ ))} +
+
+
+ )} + + + + {/* Performance List Component (Handles Client Sorting) */} + + +
+ + + + + + + +
+
+ ) +}