feat: Sprint 2 - empty states, discovery, attendance stats
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
- Add EmptyState component with 6 variants - Add discover.py router with smart filtering - GET /discover/shows (year, venue, city, tour filters) - GET /discover/years - GET /discover/recent - Add GET /attendance/me/stats (by vertical breakdown)
This commit is contained in:
parent
fe81271ab3
commit
99e5924588
4 changed files with 320 additions and 1 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
import os
|
import os
|
||||||
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, gamification, videos, musicians, sequences, verticals, canon, on_this_day
|
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, gamification, videos, musicians, sequences, verticals, canon, on_this_day, discover
|
||||||
|
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
|
@ -47,6 +47,7 @@ app.include_router(sequences.router)
|
||||||
app.include_router(verticals.router)
|
app.include_router(verticals.router)
|
||||||
app.include_router(canon.router)
|
app.include_router(canon.router)
|
||||||
app.include_router(on_this_day.router)
|
app.include_router(on_this_day.router)
|
||||||
|
app.include_router(discover.router)
|
||||||
|
|
||||||
|
|
||||||
# Optional features - can be disabled via env vars
|
# Optional features - can be disabled via env vars
|
||||||
|
|
|
||||||
|
|
@ -82,3 +82,45 @@ def get_show_attendance(
|
||||||
.offset(offset)
|
.offset(offset)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me/stats")
|
||||||
|
def get_my_attendance_stats(
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Get attendance statistics grouped by band"""
|
||||||
|
from models import Vertical
|
||||||
|
|
||||||
|
attendances = session.exec(
|
||||||
|
select(Attendance).where(Attendance.user_id == current_user.id)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
total = len(attendances)
|
||||||
|
by_vertical = {}
|
||||||
|
years = set()
|
||||||
|
|
||||||
|
for a in attendances:
|
||||||
|
show = session.get(Show, a.show_id)
|
||||||
|
if not show:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if show.date:
|
||||||
|
years.add(show.date.year)
|
||||||
|
|
||||||
|
vertical = session.get(Vertical, show.vertical_id)
|
||||||
|
if vertical:
|
||||||
|
if vertical.slug not in by_vertical:
|
||||||
|
by_vertical[vertical.slug] = {
|
||||||
|
"name": vertical.name,
|
||||||
|
"count": 0
|
||||||
|
}
|
||||||
|
by_vertical[vertical.slug]["count"] += 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_shows": total,
|
||||||
|
"by_vertical": by_vertical,
|
||||||
|
"years_attended": sorted(years, reverse=True),
|
||||||
|
"year_count": len(years)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
190
backend/routers/discover.py
Normal file
190
backend/routers/discover.py
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
"""
|
||||||
|
Show Discovery API - smart routing for finding shows.
|
||||||
|
"""
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from typing import List, Optional
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlmodel import Session, select, desc
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from database import get_session
|
||||||
|
from models import Show, Venue, Vertical, Tour
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/discover", tags=["discover"])
|
||||||
|
|
||||||
|
|
||||||
|
class DiscoverShow(BaseModel):
|
||||||
|
id: int
|
||||||
|
date: str
|
||||||
|
slug: str | None
|
||||||
|
venue_name: str | None
|
||||||
|
venue_city: str | None
|
||||||
|
vertical_name: str
|
||||||
|
vertical_slug: str
|
||||||
|
tour_name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class DiscoverResponse(BaseModel):
|
||||||
|
shows: List[DiscoverShow]
|
||||||
|
total: int
|
||||||
|
filters_applied: dict
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/shows", response_model=DiscoverResponse)
|
||||||
|
def discover_shows(
|
||||||
|
vertical: Optional[str] = None,
|
||||||
|
year: Optional[int] = None,
|
||||||
|
month: Optional[int] = None,
|
||||||
|
venue: Optional[str] = None,
|
||||||
|
tour: Optional[str] = None,
|
||||||
|
city: Optional[str] = None,
|
||||||
|
state: Optional[str] = None,
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0,
|
||||||
|
sort: str = "date_desc",
|
||||||
|
session: Session = Depends(get_session)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Discover shows with smart filtering.
|
||||||
|
|
||||||
|
Sort options: date_desc, date_asc
|
||||||
|
"""
|
||||||
|
query = select(Show).where(Show.date.isnot(None))
|
||||||
|
|
||||||
|
filters = {}
|
||||||
|
|
||||||
|
# Filter by vertical (band)
|
||||||
|
if vertical:
|
||||||
|
v = session.exec(select(Vertical).where(Vertical.slug == vertical)).first()
|
||||||
|
if v:
|
||||||
|
query = query.where(Show.vertical_id == v.id)
|
||||||
|
filters["vertical"] = vertical
|
||||||
|
|
||||||
|
# Filter by year
|
||||||
|
if year:
|
||||||
|
start = date(year, 1, 1)
|
||||||
|
end = date(year, 12, 31)
|
||||||
|
query = query.where(Show.date >= start).where(Show.date <= end)
|
||||||
|
filters["year"] = year
|
||||||
|
|
||||||
|
# Filter by month (requires year)
|
||||||
|
if month and year:
|
||||||
|
start = date(year, month, 1)
|
||||||
|
if month == 12:
|
||||||
|
end = date(year + 1, 1, 1) - timedelta(days=1)
|
||||||
|
else:
|
||||||
|
end = date(year, month + 1, 1) - timedelta(days=1)
|
||||||
|
query = query.where(Show.date >= start).where(Show.date <= end)
|
||||||
|
filters["month"] = month
|
||||||
|
|
||||||
|
# Filter by tour
|
||||||
|
if tour:
|
||||||
|
t = session.exec(select(Tour).where(Tour.slug == tour)).first()
|
||||||
|
if t:
|
||||||
|
query = query.where(Show.tour_id == t.id)
|
||||||
|
filters["tour"] = tour
|
||||||
|
|
||||||
|
# Apply sorting
|
||||||
|
if sort == "date_asc":
|
||||||
|
query = query.order_by(Show.date)
|
||||||
|
else:
|
||||||
|
query = query.order_by(desc(Show.date))
|
||||||
|
|
||||||
|
# Get total count before pagination
|
||||||
|
all_shows = session.exec(query).all()
|
||||||
|
total = len(all_shows)
|
||||||
|
|
||||||
|
# Apply pagination
|
||||||
|
paginated = all_shows[offset:offset + limit]
|
||||||
|
|
||||||
|
# Filter by venue/city/state in Python (more flexible)
|
||||||
|
results = []
|
||||||
|
for show in paginated:
|
||||||
|
venue_obj = session.get(Venue, show.venue_id) if show.venue_id else None
|
||||||
|
vert_obj = session.get(Vertical, show.vertical_id)
|
||||||
|
tour_obj = session.get(Tour, show.tour_id) if show.tour_id else None
|
||||||
|
|
||||||
|
# City/state filters
|
||||||
|
if city and venue_obj and city.lower() not in venue_obj.city.lower():
|
||||||
|
continue
|
||||||
|
if state and venue_obj and venue_obj.state and state.lower() not in venue_obj.state.lower():
|
||||||
|
continue
|
||||||
|
if venue and venue_obj and venue.lower() not in venue_obj.name.lower():
|
||||||
|
continue
|
||||||
|
|
||||||
|
results.append(DiscoverShow(
|
||||||
|
id=show.id,
|
||||||
|
date=show.date.strftime("%Y-%m-%d") if show.date else "",
|
||||||
|
slug=show.slug,
|
||||||
|
venue_name=venue_obj.name if venue_obj else None,
|
||||||
|
venue_city=venue_obj.city if venue_obj else None,
|
||||||
|
vertical_name=vert_obj.name if vert_obj else "Unknown",
|
||||||
|
vertical_slug=vert_obj.slug if vert_obj else "unknown",
|
||||||
|
tour_name=tour_obj.name if tour_obj else None
|
||||||
|
))
|
||||||
|
|
||||||
|
if city:
|
||||||
|
filters["city"] = city
|
||||||
|
if state:
|
||||||
|
filters["state"] = state
|
||||||
|
if venue:
|
||||||
|
filters["venue"] = venue
|
||||||
|
|
||||||
|
return DiscoverResponse(
|
||||||
|
shows=results,
|
||||||
|
total=total,
|
||||||
|
filters_applied=filters
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/years")
|
||||||
|
def get_available_years(
|
||||||
|
vertical: Optional[str] = None,
|
||||||
|
session: Session = Depends(get_session)
|
||||||
|
):
|
||||||
|
"""Get list of years with shows for filtering UI"""
|
||||||
|
query = select(Show).where(Show.date.isnot(None))
|
||||||
|
|
||||||
|
if vertical:
|
||||||
|
v = session.exec(select(Vertical).where(Vertical.slug == vertical)).first()
|
||||||
|
if v:
|
||||||
|
query = query.where(Show.vertical_id == v.id)
|
||||||
|
|
||||||
|
shows = session.exec(query).all()
|
||||||
|
years = sorted(set(s.date.year for s in shows if s.date), reverse=True)
|
||||||
|
|
||||||
|
return {"years": years}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/recent", response_model=List[DiscoverShow])
|
||||||
|
def get_recent_shows(
|
||||||
|
limit: int = 10,
|
||||||
|
vertical: Optional[str] = None,
|
||||||
|
session: Session = Depends(get_session)
|
||||||
|
):
|
||||||
|
"""Get most recent shows for quick discovery"""
|
||||||
|
query = select(Show).where(Show.date.isnot(None))
|
||||||
|
|
||||||
|
if vertical:
|
||||||
|
v = session.exec(select(Vertical).where(Vertical.slug == vertical)).first()
|
||||||
|
if v:
|
||||||
|
query = query.where(Show.vertical_id == v.id)
|
||||||
|
|
||||||
|
query = query.order_by(desc(Show.date)).limit(limit)
|
||||||
|
shows = session.exec(query).all()
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for show in shows:
|
||||||
|
venue = session.get(Venue, show.venue_id) if show.venue_id else None
|
||||||
|
vert = session.get(Vertical, show.vertical_id)
|
||||||
|
|
||||||
|
results.append(DiscoverShow(
|
||||||
|
id=show.id,
|
||||||
|
date=show.date.strftime("%Y-%m-%d") if show.date else "",
|
||||||
|
slug=show.slug,
|
||||||
|
venue_name=venue.name if venue else None,
|
||||||
|
venue_city=venue.city if venue else None,
|
||||||
|
vertical_name=vert.name if vert else "Unknown",
|
||||||
|
vertical_slug=vert.slug if vert else "unknown"
|
||||||
|
))
|
||||||
|
|
||||||
|
return results
|
||||||
86
frontend/components/ui/empty-state.tsx
Normal file
86
frontend/components/ui/empty-state.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import Link from "next/link"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
|
import { Music, Users, Calendar, Star } from "lucide-react"
|
||||||
|
|
||||||
|
interface EmptyStateProps {
|
||||||
|
type: "shows" | "songs" | "attendance" | "feed" | "reviews" | "generic"
|
||||||
|
bandName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_STATES = {
|
||||||
|
shows: {
|
||||||
|
icon: Calendar,
|
||||||
|
title: "No Shows Yet",
|
||||||
|
description: "Be the first to explore shows for this band!",
|
||||||
|
action: { label: "Browse All Bands", href: "/" }
|
||||||
|
},
|
||||||
|
songs: {
|
||||||
|
icon: Music,
|
||||||
|
title: "No Songs Found",
|
||||||
|
description: "Song data is still being imported.",
|
||||||
|
action: { label: "Check Back Soon", href: "/" }
|
||||||
|
},
|
||||||
|
attendance: {
|
||||||
|
icon: Users,
|
||||||
|
title: "No Shows Tracked",
|
||||||
|
description: "Start tracking your concert attendance!",
|
||||||
|
action: { label: "Find Shows", href: "/shows" }
|
||||||
|
},
|
||||||
|
feed: {
|
||||||
|
icon: Star,
|
||||||
|
title: "Your Feed is Empty",
|
||||||
|
description: "Follow some bands to see activity here.",
|
||||||
|
action: { label: "Pick Your Bands", href: "/onboarding" }
|
||||||
|
},
|
||||||
|
reviews: {
|
||||||
|
icon: Star,
|
||||||
|
title: "No Reviews Yet",
|
||||||
|
description: "Be the first to share your thoughts!",
|
||||||
|
action: null
|
||||||
|
},
|
||||||
|
generic: {
|
||||||
|
icon: Music,
|
||||||
|
title: "Nothing Here Yet",
|
||||||
|
description: "Check back soon for updates.",
|
||||||
|
action: { label: "Go Home", href: "/" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyState({ type, bandName }: EmptyStateProps) {
|
||||||
|
const config = EMPTY_STATES[type] || EMPTY_STATES.generic
|
||||||
|
const Icon = config.icon
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-dashed">
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<div className="rounded-full bg-muted p-4 mb-4">
|
||||||
|
<Icon className="h-8 w-8 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-semibold text-lg mb-2">
|
||||||
|
{bandName ? `${config.title} for ${bandName}` : config.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-muted-foreground mb-4 max-w-sm">
|
||||||
|
{config.description}
|
||||||
|
</p>
|
||||||
|
{config.action && (
|
||||||
|
<Button asChild>
|
||||||
|
<Link href={config.action.href}>{config.action.label}</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inline empty state for lists
|
||||||
|
export function EmptyListState({ message }: { message: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 text-center text-muted-foreground">
|
||||||
|
<Music className="h-6 w-6 mb-2 opacity-50" />
|
||||||
|
<p>{message}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue