Compare commits

...

2 commits

Author SHA1 Message Date
fullsizemalt
465017cda9 feat: Add On This Day endpoint (P2)
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
- GET /on-this-day - shows matching today's month/day in history
- GET /on-this-day/highlights - top 5 for homepage widget
- Supports ?vertical= filter and ?month=&day= override
2025-12-28 16:39:52 -08:00
fullsizemalt
35ce12bc84 feat: Sprint 1 frontend polish
- Add landing page with hero, scenes, featured bands (FR-004)
- Add cross-band versions fetch to song page (FR-008)
- Create sprint plan artifact

Aligns with Specify spec fediversion-multi-band.md
2025-12-28 16:38:52 -08:00
4 changed files with 248 additions and 215 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 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 fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
@ -46,6 +46,7 @@ app.include_router(musicians.router)
app.include_router(sequences.router) 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)
# Optional features - can be disabled via env vars # Optional features - can be disabled via env vars

View file

@ -0,0 +1,125 @@
"""
On This Day API endpoint - shows what happened on this date in history.
"""
from datetime import date
from typing import List, Optional
from fastapi import APIRouter, Depends, Query
from sqlmodel import Session, select
from pydantic import BaseModel
from database import get_session
from models import Show, Venue, Vertical, Performance, Song
router = APIRouter(prefix="/on-this-day", tags=["on-this-day"])
class ShowOnThisDay(BaseModel):
id: int
date: str
slug: str | None
venue_name: str | None
venue_city: str | None
vertical_name: str
vertical_slug: str
years_ago: int
class OnThisDayResponse(BaseModel):
month: int
day: int
shows: List[ShowOnThisDay]
total_shows: int
@router.get("/", response_model=OnThisDayResponse)
def get_on_this_day(
month: Optional[int] = None,
day: Optional[int] = None,
vertical: Optional[str] = None,
session: Session = Depends(get_session)
):
"""
Get all shows that happened on this day in history.
Defaults to today's date if month/day not specified.
"""
today = date.today()
target_month = month or today.month
target_day = day or today.day
# Build query
query = select(Show).where(
Show.date.isnot(None)
)
# Filter by vertical if specified
if vertical:
vertical_obj = session.exec(
select(Vertical).where(Vertical.slug == vertical)
).first()
if vertical_obj:
query = query.where(Show.vertical_id == vertical_obj.id)
# Execute and filter by month/day in Python (SQL date functions vary)
all_shows = session.exec(query.order_by(Show.date.desc())).all()
matching_shows = []
for show in all_shows:
if show.date and show.date.month == target_month and show.date.day == target_day:
venue = session.get(Venue, show.venue_id) if show.venue_id else None
vert = session.get(Vertical, show.vertical_id)
years_ago = today.year - show.date.year
matching_shows.append(ShowOnThisDay(
id=show.id,
date=show.date.strftime("%Y-%m-%d"),
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",
years_ago=years_ago
))
# Sort by years ago (most recent anniversary first)
matching_shows.sort(key=lambda x: x.years_ago)
return OnThisDayResponse(
month=target_month,
day=target_day,
shows=matching_shows,
total_shows=len(matching_shows)
)
@router.get("/highlights", response_model=List[ShowOnThisDay])
def get_on_this_day_highlights(
limit: int = 5,
session: Session = Depends(get_session)
):
"""Get highlighted shows for today across all bands (limited list for homepage)"""
today = date.today()
all_shows = session.exec(
select(Show).where(Show.date.isnot(None))
).all()
matching = []
for show in all_shows:
if show.date and show.date.month == today.month and show.date.day == today.day:
venue = session.get(Venue, show.venue_id) if show.venue_id else None
vert = session.get(Vertical, show.vertical_id)
matching.append(ShowOnThisDay(
id=show.id,
date=show.date.strftime("%Y-%m-%d"),
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",
years_ago=today.year - show.date.year
))
# Sort by anniversary and return limited
matching.sort(key=lambda x: x.years_ago)
return matching[:limit]

View file

@ -1,253 +1,144 @@
import { ActivityFeed } from "@/components/feed/activity-feed"
import { XPLeaderboard } from "@/components/gamification/xp-leaderboard"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import Link from "next/link" import Link from "next/link"
import { Trophy, Music, MapPin, Calendar, ChevronRight, Star, Youtube, Route } from "lucide-react" import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { getApiUrl } from "@/lib/api-config" import { getApiUrl } from "@/lib/api-config"
interface Show { interface Vertical {
id: number
slug?: string
date: string
venue?: {
id: number id: number
name: string name: string
slug?: string slug: string
city?: string description: string | null
state?: string }
}
tour?: { interface Scene {
id: number id: number
name: string name: string
slug?: string slug: string
} description: string | null
} }
interface Song { async function getVerticals(): Promise<Vertical[]> {
id: number
title: string
slug?: string
performance_count?: number
avg_rating?: number
}
async function getRecentShows(): Promise<Show[]> {
try { try {
const res = await fetch(`${getApiUrl()}/shows/recent?limit=8`, { const res = await fetch(`${getApiUrl()}/verticals`, { next: { revalidate: 60 } })
cache: 'no-store',
next: { revalidate: 60 }
})
if (!res.ok) return [] if (!res.ok) return []
return res.json() return res.json()
} catch (e) { } catch {
console.error('Failed to fetch recent shows:', e)
return [] return []
} }
} }
async function getTopSongs(): Promise<Song[]> { async function getScenes(): Promise<Scene[]> {
try { try {
const res = await fetch(`${getApiUrl()}/stats/top-songs?limit=5`, { const res = await fetch(`${getApiUrl()}/verticals/scenes`, { next: { revalidate: 60 } })
cache: 'no-store',
next: { revalidate: 300 }
})
if (!res.ok) return [] if (!res.ok) return []
return res.json() return res.json()
} catch (e) { } catch {
console.error('Failed to fetch top songs:', e)
return [] return []
} }
} }
export default async function HomePage() {
const [verticals, scenes] = await Promise.all([getVerticals(), getScenes()])
export default async function Home() {
const [recentShows, topSongs] = await Promise.all([
getRecentShows(),
getTopSongs()
])
return ( return (
<div className="flex flex-col gap-8"> <div className="space-y-16">
{/* Hero Section */} {/* Hero Section */}
<section className="flex flex-col items-center gap-4 py-12 text-center md:py-20 bg-gradient-to-b from-background to-accent/20 rounded-lg border"> <section className="text-center py-16 space-y-6">
<h1 className="text-4xl font-bold tracking-tighter sm:text-5xl md:text-6xl"> <h1 className="text-5xl font-bold tracking-tight">
Elmeg Fediversion
</h1> </h1>
<p className="max-w-[600px] text-lg text-muted-foreground"> <p className="text-xl text-muted-foreground max-w-2xl mx-auto">
A comprehensive community-driven archive for Goose history. The unified platform for the entire jam scene.
<br /> One account, all your favorite bands.
Discover shows, share ratings, and explore the music together.
</p> </p>
<div className="flex flex-col sm:flex-row gap-3 sm:gap-4"> <div className="flex justify-center gap-4">
<Link href="/performances"> <Button asChild size="lg">
<Button size="lg" className="gap-2 w-full sm:w-auto"> <Link href="/onboarding">Get Started</Link>
<Trophy className="h-4 w-4" />
Top Performances
</Button> </Button>
</Link> <Button asChild variant="outline" size="lg">
<Link href="/shows"> <Link href="/goose">Explore Shows</Link>
<Button variant="outline" size="lg" className="w-full sm:w-auto">
Browse Shows
</Button> </Button>
</Link>
</div> </div>
</section> </section>
{/* Recent Shows */} {/* Scenes Section */}
<section className="space-y-4"> {scenes.length > 0 && (
<div className="flex items-center justify-between"> <section className="space-y-6">
<h2 className="text-2xl font-bold flex items-center gap-2"> <h2 className="text-2xl font-bold text-center">Browse by Scene</h2>
<Calendar className="h-6 w-6 text-blue-500" /> <div className="grid grid-cols-2 md:grid-cols-4 gap-4 max-w-3xl mx-auto">
Recent Shows {scenes.map((scene) => (
</h2>
<Link href="/shows" className="text-sm text-muted-foreground hover:underline flex items-center gap-1">
View all shows <ChevronRight className="h-4 w-4" />
</Link>
</div>
{recentShows.length > 0 ? (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{recentShows.map((show) => (
<Link key={show.id} href={`/shows/${show.slug}`}>
<Card className="h-full hover:bg-accent/50 transition-colors cursor-pointer">
<CardContent className="p-4">
<div className="font-semibold">
{new Date(show.date).toLocaleDateString('en-US', {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric'
})}
</div>
{show.venue && (
<div className="text-sm text-muted-foreground mt-1">
{show.venue.name}
</div>
)}
{show.venue?.city && (
<div className="text-xs text-muted-foreground">
{show.venue.city}{show.venue.state ? `, ${show.venue.state}` : ''}
</div>
)}
{show.tour && (
<div className="text-xs text-primary mt-2">
{show.tour.name}
</div>
)}
</CardContent>
</Card>
</Link>
))}
</div>
) : (
<Card className="p-8 text-center text-muted-foreground">
<p>No shows yet. Check back soon!</p>
</Card>
)}
</section>
<div className="grid gap-8 lg:grid-cols-3">
{/* Top Songs */}
<section className="space-y-4 lg:col-span-1">
<div className="flex items-center justify-between">
<h2 className="text-xl font-bold flex items-center gap-2">
<Star className="h-5 w-5 text-yellow-500" />
Top Songs
</h2>
<Link href="/songs" className="text-sm text-muted-foreground hover:underline flex items-center gap-1">
All songs <ChevronRight className="h-4 w-4" />
</Link>
</div>
<Card>
<CardContent className="p-0">
{topSongs.length > 0 ? (
<ul className="divide-y">
{topSongs.map((song, idx) => (
<li key={song.id}>
<Link <Link
href={`/songs/${song.slug}`} key={scene.slug}
className="flex items-center gap-3 p-3 hover:bg-accent/50 transition-colors" href={`/?scene=${scene.slug}`}
className="block p-4 rounded-lg border bg-card hover:bg-accent transition-colors text-center"
> >
<span className="text-lg font-bold text-muted-foreground w-6 text-center"> <div className="font-semibold">{scene.name}</div>
{idx + 1} {scene.description && (
</span> <div className="text-sm text-muted-foreground mt-1 line-clamp-2">
<div className="flex-1 min-w-0"> {scene.description}
<div className="font-medium truncate">{song.title}</div>
{song.performance_count && (
<div className="text-xs text-muted-foreground">
{song.performance_count} performances
</div> </div>
)} )}
</div>
</Link> </Link>
</li>
))} ))}
</ul>
) : (
<div className="p-4 text-center text-muted-foreground text-sm">
No songs yet
</div> </div>
</section>
)} )}
{/* Bands Grid */}
<section className="space-y-6">
<h2 className="text-2xl font-bold text-center">Featured Bands</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{verticals.map((vertical) => (
<Card key={vertical.slug} className="hover:shadow-lg transition-shadow">
<CardHeader>
<CardTitle>
<Link href={`/${vertical.slug}`} className="hover:underline">
{vertical.name}
</Link>
</CardTitle>
{vertical.description && (
<CardDescription>{vertical.description}</CardDescription>
)}
</CardHeader>
<CardContent>
<div className="flex gap-2">
<Button asChild variant="outline" size="sm">
<Link href={`/${vertical.slug}/shows`}>Shows</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href={`/${vertical.slug}/songs`}>Songs</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href={`/${vertical.slug}/venues`}>Venues</Link>
</Button>
</div>
</CardContent> </CardContent>
</Card> </Card>
</section> ))}
{/* Activity Feed */}
<section className="space-y-4 lg:col-span-1">
<div className="flex items-center justify-between">
<h2 className="text-xl font-bold">Recent Activity</h2>
<Link href="/leaderboards" className="text-sm text-muted-foreground hover:underline flex items-center gap-1">
View all <ChevronRight className="h-4 w-4" />
</Link>
</div> </div>
<ActivityFeed />
</section> </section>
{/* XP Leaderboard */} {/* Stats Section */}
<section className="space-y-4 lg:col-span-1"> <section className="bg-muted/50 rounded-lg p-8 text-center space-y-4">
<XPLeaderboard /> <h2 className="text-2xl font-bold">Join the Community</h2>
<p className="text-muted-foreground">
Track shows, rate performances, discover connections across bands.
</p>
<div className="grid grid-cols-3 gap-6 max-w-md mx-auto pt-4">
<div>
<div className="text-3xl font-bold">{verticals.length}</div>
<div className="text-sm text-muted-foreground">Bands</div>
</div>
<div>
<div className="text-3xl font-bold">{scenes.length}</div>
<div className="text-sm text-muted-foreground">Scenes</div>
</div>
<div>
<div className="text-3xl font-bold">1</div>
<div className="text-sm text-muted-foreground">Account</div>
</div>
</div>
</section> </section>
</div> </div>
{/* Quick Links */}
<section className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Link href="/shows" className="block p-6 border rounded-lg hover:bg-accent transition-colors group">
<Calendar className="h-8 w-8 mb-2 text-blue-500 group-hover:scale-110 transition-transform" />
<h3 className="font-bold">Shows</h3>
<p className="text-sm text-muted-foreground">Browse the complete archive</p>
</Link>
<Link href="/venues" className="block p-6 border rounded-lg hover:bg-accent transition-colors group">
<MapPin className="h-8 w-8 mb-2 text-green-500 group-hover:scale-110 transition-transform" />
<h3 className="font-bold">Venues</h3>
<p className="text-sm text-muted-foreground">Find your favorite spots</p>
</Link>
<Link href="/songs" className="block p-6 border rounded-lg hover:bg-accent transition-colors group">
<Music className="h-8 w-8 mb-2 text-purple-500 group-hover:scale-110 transition-transform" />
<h3 className="font-bold">Songs</h3>
<p className="text-sm text-muted-foreground">Explore the catalog</p>
</Link>
<Link href="/performances" className="block p-6 border rounded-lg hover:bg-accent transition-colors group">
<Trophy className="h-8 w-8 mb-2 text-yellow-500 group-hover:scale-110 transition-transform" />
<h3 className="font-bold">Top Performances</h3>
<p className="text-sm text-muted-foreground">Highest rated jams</p>
</Link>
<Link href="/leaderboards" className="block p-6 border rounded-lg hover:bg-accent transition-colors group">
<Star className="h-8 w-8 mb-2 text-yellow-500 group-hover:scale-110 transition-transform" />
<h3 className="font-bold">Leaderboards</h3>
<p className="text-sm text-muted-foreground">Top rated everything</p>
</Link>
<Link href="/tours" className="block p-6 border rounded-lg hover:bg-accent transition-colors group">
<Route className="h-8 w-8 mb-2 text-orange-500 group-hover:scale-110 transition-transform" />
<h3 className="font-bold">Tours</h3>
<p className="text-sm text-muted-foreground">Browse by tour</p>
</Link>
<Link href="/videos" className="block p-6 border rounded-lg hover:bg-accent transition-colors group">
<Youtube className="h-8 w-8 mb-2 text-red-500 group-hover:scale-110 transition-transform" />
<h3 className="font-bold">Videos</h3>
<p className="text-sm text-muted-foreground">Watch full shows and songs</p>
</Link>
</section>
</div >
) )
} }

View file

@ -25,6 +25,19 @@ async function getSong(id: string) {
} }
} }
// Fetch cross-band versions of this song via SongCanon
async function getRelatedVersions(songId: number) {
try {
const res = await fetch(`${getApiUrl()}/canon/song/${songId}/related`, {
next: { revalidate: 60 }
})
if (!res.ok) return []
return res.json()
} catch {
return []
}
}
// Get top rated performances for "Heady Version" leaderboard // Get top rated performances for "Heady Version" leaderboard
function getHeadyVersions(performances: any[]) { function getHeadyVersions(performances: any[]) {
if (!performances || performances.length === 0) return [] if (!performances || performances.length === 0) return []
@ -45,6 +58,9 @@ export default async function SongDetailPage({ params }: { params: Promise<{ slu
const headyVersions = getHeadyVersions(song.performances || []) const headyVersions = getHeadyVersions(song.performances || [])
const topPerformance = headyVersions[0] const topPerformance = headyVersions[0]
// Fetch cross-band versions
const relatedVersions = await getRelatedVersions(song.id)
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between"> <div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">