- Fork elmeg-demo codebase for multi-band support - Add data importer infrastructure with base class - Create band-specific importers: - phish.py: Phish.net API v5 - grateful_dead.py: Grateful Stats API - setlistfm.py: Dead & Company, Billy Strings (Setlist.fm) - Add spec-kit configuration for Gemini - Update README with supported bands and architecture
209 lines
8.9 KiB
TypeScript
209 lines
8.9 KiB
TypeScript
"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<ProfileStats | null>(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 <div className="text-muted-foreground">Loading stats...</div>
|
|
}
|
|
|
|
if (!stats || stats.shows_attended === 0) {
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Sparkles className="h-5 w-5 text-yellow-500" />
|
|
Attendance Summary
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<p className="text-muted-foreground text-center py-4">
|
|
No shows marked as attended yet. Start adding shows to see your stats!
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
// 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 (
|
|
<Card className="overflow-hidden">
|
|
<CardHeader className="bg-gradient-to-br from-primary/10 via-primary/5 to-transparent">
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Sparkles className="h-5 w-5 text-yellow-500" />
|
|
Your Attendance Story
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="pt-6 space-y-6">
|
|
{/* Main Summary */}
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
className="text-lg leading-relaxed"
|
|
>
|
|
<p>
|
|
You've attended <strong className="text-primary">{stats.shows_attended} shows</strong> and
|
|
seen <strong className="text-primary">{stats.unique_songs_seen} unique songs</strong>.
|
|
</p>
|
|
{highlights.length > 0 && (
|
|
<p className="mt-2 text-muted-foreground">
|
|
In attendance for {highlights.join(", ")}.
|
|
</p>
|
|
)}
|
|
{stats.most_seen_song && (
|
|
<p className="mt-2">
|
|
Your most-seen song is <strong className="text-primary">{stats.most_seen_song}</strong> ({stats.most_seen_count} times).
|
|
</p>
|
|
)}
|
|
</motion.div>
|
|
|
|
{/* Stats Grid */}
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 pt-4">
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.9 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
transition={{ delay: 0.1 }}
|
|
className="text-center p-4 rounded-lg bg-muted/50"
|
|
>
|
|
<Calendar className="h-6 w-6 mx-auto mb-2 text-primary" />
|
|
<div className="text-2xl font-bold">{stats.shows_attended}</div>
|
|
<div className="text-xs text-muted-foreground uppercase tracking-wider">Shows</div>
|
|
</motion.div>
|
|
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.9 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
transition={{ delay: 0.2 }}
|
|
className="text-center p-4 rounded-lg bg-muted/50"
|
|
>
|
|
<Music className="h-6 w-6 mx-auto mb-2 text-green-500" />
|
|
<div className="text-2xl font-bold">{stats.unique_songs_seen}</div>
|
|
<div className="text-xs text-muted-foreground uppercase tracking-wider">Songs Seen</div>
|
|
</motion.div>
|
|
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.9 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
transition={{ delay: 0.3 }}
|
|
className="text-center p-4 rounded-lg bg-yellow-500/10"
|
|
>
|
|
<Trophy className="h-6 w-6 mx-auto mb-2 text-yellow-500" />
|
|
<div className="text-2xl font-bold">{stats.heady_versions_attended}</div>
|
|
<div className="text-xs text-muted-foreground uppercase tracking-wider">Heady Versions</div>
|
|
</motion.div>
|
|
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.9 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
transition={{ delay: 0.4 }}
|
|
className="text-center p-4 rounded-lg bg-purple-500/10"
|
|
>
|
|
<Star className="h-6 w-6 mx-auto mb-2 text-purple-500" />
|
|
<div className="text-2xl font-bold">{stats.debuts_witnessed}</div>
|
|
<div className="text-xs text-muted-foreground uppercase tracking-wider">Debuts</div>
|
|
</motion.div>
|
|
</div>
|
|
|
|
{/* Chase Songs Progress */}
|
|
{stats.chase_songs_count > 0 && (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: 0.5 }}
|
|
className="p-4 rounded-lg border bg-muted/30"
|
|
>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<div className="flex items-center gap-2 font-medium">
|
|
<Target className="h-4 w-4 text-primary" />
|
|
Chase Progress
|
|
</div>
|
|
<span className="text-sm font-mono">
|
|
{stats.chase_songs_caught}/{stats.chase_songs_count}
|
|
</span>
|
|
</div>
|
|
<div className="w-full bg-muted rounded-full h-2">
|
|
<div
|
|
className="bg-primary h-2 rounded-full transition-all"
|
|
style={{ width: `${(stats.chase_songs_caught / stats.chase_songs_count) * 100}%` }}
|
|
/>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground mt-2">
|
|
{stats.chase_songs_count - stats.chase_songs_caught} songs left to catch!
|
|
</p>
|
|
</motion.div>
|
|
)}
|
|
|
|
{/* Activity Stats */}
|
|
<div className="flex items-center justify-center gap-8 pt-4 border-t text-sm text-muted-foreground">
|
|
<div className="flex items-center gap-2">
|
|
<Star className="h-4 w-4" />
|
|
<span>{stats.total_ratings} ratings</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Eye className="h-4 w-4" />
|
|
<span>{stats.total_reviews} reviews</span>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|