From 99e5924588c732e4d976ddb18dae87314a7c70fd Mon Sep 17 00:00:00 2001 From: fullsizemalt <106900403+fullsizemalt@users.noreply.github.com> Date: Sun, 28 Dec 2025 16:49:24 -0800 Subject: [PATCH] feat: Sprint 2 - empty states, discovery, attendance stats - 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) --- backend/main.py | 3 +- backend/routers/attendance.py | 42 ++++++ backend/routers/discover.py | 190 +++++++++++++++++++++++++ frontend/components/ui/empty-state.tsx | 86 +++++++++++ 4 files changed, 320 insertions(+), 1 deletion(-) create mode 100644 backend/routers/discover.py create mode 100644 frontend/components/ui/empty-state.tsx diff --git a/backend/main.py b/backend/main.py index 7560c23..5a29fda 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,6 +1,6 @@ from fastapi import FastAPI 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 @@ -47,6 +47,7 @@ app.include_router(sequences.router) app.include_router(verticals.router) app.include_router(canon.router) app.include_router(on_this_day.router) +app.include_router(discover.router) # Optional features - can be disabled via env vars diff --git a/backend/routers/attendance.py b/backend/routers/attendance.py index 4d6e4fe..c8e939d 100644 --- a/backend/routers/attendance.py +++ b/backend/routers/attendance.py @@ -82,3 +82,45 @@ def get_show_attendance( .offset(offset) .limit(limit) ).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) + } + diff --git a/backend/routers/discover.py b/backend/routers/discover.py new file mode 100644 index 0000000..15c7204 --- /dev/null +++ b/backend/routers/discover.py @@ -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 diff --git a/frontend/components/ui/empty-state.tsx b/frontend/components/ui/empty-state.tsx new file mode 100644 index 0000000..c883936 --- /dev/null +++ b/frontend/components/ui/empty-state.tsx @@ -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 ( + + +
+ +
+

+ {bandName ? `${config.title} for ${bandName}` : config.title} +

+

+ {config.description} +

+ {config.action && ( + + )} +
+
+ ) +} + +// Inline empty state for lists +export function EmptyListState({ message }: { message: string }) { + return ( +
+ +

{message}

+
+ ) +}