feat: Sprint 2 - empty states, discovery, attendance stats
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:
fullsizemalt 2025-12-28 16:49:24 -08:00
parent fe81271ab3
commit 99e5924588
4 changed files with 320 additions and 1 deletions

View file

@ -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

View file

@ -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
View 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

View 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>
)
}