Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 24 additions & 6 deletions frontend/src/pages/EventRecapPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,10 @@ export default function EventRecapPage() {
const formattedDate = eventDate
? eventDate.toLocaleDateString('en-CA', { year: 'numeric', month: 'long', day: 'numeric' })
: 'Date TBD'
// Roster-only historical archives (Vols 1-14) have no venue assignments at
// all — "Venues: 0" reads as missing data, not a real stat. Mirrors the
// omit-zero-venues precedent in EventTimeline.jsx (EventCard, #608).
const hasVenueData = stats.venue_count > 0

return (
<>
Expand Down Expand Up @@ -193,9 +197,12 @@ export default function EventRecapPage() {
</div>
</header>

<section aria-label="Event statistics" className="mb-10 grid grid-cols-2 gap-4 sm:grid-cols-5">
<section
aria-label="Event statistics"
className={`mb-10 grid grid-cols-2 gap-4 ${hasVenueData ? 'sm:grid-cols-5' : 'sm:grid-cols-4'}`}
>
<StatCard label="Total Sets" value={stats.total_sets ?? '—'} />
<StatCard label="Venues" value={stats.venue_count ?? '—'} />
{hasVenueData && <StatCard label="Venues" value={stats.venue_count} />}
<StatCard label="First Timers" value={stats.first_timers ?? '—'} />
<StatCard label="Returning Acts" value={stats.returning_acts ?? '—'} />
<StatCard label="Saved Route" value={savedBands.length || '—'} />
Expand Down Expand Up @@ -275,7 +282,14 @@ export default function EventRecapPage() {
<article key={venue.id} className="rounded-xl border border-border bg-bg-navy/40 p-4">
<div className="mb-3 flex items-start justify-between gap-3">
<div>
<h3 className="text-lg font-semibold text-text-primary">{venue.name}</h3>
{/* "Unscheduled" is accurate when it's the leftover group next to
real venue assignments (partially-scheduled event). But when it's
the ONLY group — the event has zero venue assignments at all,
e.g. a roster-only historical archive — "Unscheduled" implies a
scheduling gap that doesn't exist; "Lineup" reads correctly (#614). */}
<h3 className="text-lg font-semibold text-text-primary">
{venue.id === 'unscheduled' && !hasVenueData ? 'Lineup' : venue.name}
</h3>
<p className="text-sm text-text-tertiary">
{venue.bands.length} {venue.bands.length === 1 ? 'set' : 'sets'} on the night
</p>
Expand All @@ -299,9 +313,13 @@ export default function EventRecapPage() {
>
{band.name}
</Link>
<span className="shrink-0 text-text-tertiary">
{band.start_time || 'TBD'}–{band.end_time || 'TBD'}
</span>
{/* Roster-only archives have neither start nor end time recorded —
render nothing rather than "TBD–TBD" (#614). */}
{(band.start_time || band.end_time) && (
<span className="shrink-0 text-text-tertiary">
{band.start_time || 'TBD'}–{band.end_time || 'TBD'}
</span>
)}
</li>
))}
</ul>
Expand Down
187 changes: 187 additions & 0 deletions frontend/src/pages/__tests__/EventRecapPage.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import { render, screen, within } from '@testing-library/react'
import '@testing-library/jest-dom'
import { HelmetProvider } from 'react-helmet-async'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import EventRecapPage from '../EventRecapPage.jsx'
import { fetchPublicJson } from '../../utils/publicApi'

vi.mock('../../utils/publicApi', () => ({ fetchPublicJson: vi.fn() }))

function renderPage(slug = 'lwbc01') {
return render(
<HelmetProvider>
<MemoryRouter initialEntries={[`/events/${slug}/recap`]}>
<Routes>
<Route path="/events/:slug/recap" element={<EventRecapPage />} />
</Routes>
</MemoryRouter>
</HelmetProvider>
)
}

// #614: roster-only historical archives (Vols 1-14) have no venue or time
// data — the recap page must not render "we don't know" noise for them.
describe('EventRecapPage — roster-only archive (no venue/time data)', () => {
beforeEach(() => {
fetchPublicJson.mockReset()
fetchPublicJson.mockResolvedValue({
event: { id: 1, name: 'LWBC Vol1', slug: 'lwbc01', date: '2022-05-22' },
stats: { total_sets: 2, venue_count: 0, first_timers: 2, returning_acts: 0 },
bands: [
{
id: 10,
performance_id: 100,
name: 'Roster Band A',
genre: 'Rock',
photo_url: null,
start_time: null,
end_time: null,
venue_id: null,
venue_name: null,
is_returning: false,
},
{
id: 11,
performance_id: 101,
name: 'Roster Band B',
genre: 'Punk',
photo_url: null,
start_time: null,
end_time: null,
venue_id: null,
venue_name: null,
is_returning: false,
},
],
})
})

it('omits the Venues stat tile entirely', async () => {
renderPage()
expect(await screen.findByText('LWBC Vol1')).toBeInTheDocument()

const statsRegion = screen.getByRole('region', { name: 'Event statistics' })
expect(within(statsRegion).queryByText('Venues')).not.toBeInTheDocument()
expect(within(statsRegion).queryByText(/^Venues:/)).not.toBeInTheDocument()
// Sibling stats are unaffected.
expect(within(statsRegion).getByText('Total Sets')).toBeInTheDocument()
expect(within(statsRegion).getByText('First Timers')).toBeInTheDocument()
})

it('never renders "TBD–TBD" time ranges', async () => {
renderPage()
await screen.findByText('LWBC Vol1')

expect(screen.queryByText(/TBD.{1,3}TBD/)).not.toBeInTheDocument()
})

it('labels the lone no-venue group "Lineup", not "Unscheduled"', async () => {
renderPage()
await screen.findByText('LWBC Vol1')

expect(screen.getByRole('heading', { level: 3, name: 'Lineup' })).toBeInTheDocument()
expect(screen.queryByRole('heading', { level: 3, name: 'Unscheduled' })).not.toBeInTheDocument()
})
})

describe('EventRecapPage — fully-scheduled event', () => {
beforeEach(() => {
fetchPublicJson.mockReset()
fetchPublicJson.mockResolvedValue({
event: { id: 2, name: 'LWBC Vol17', slug: 'lwbc17', date: '2026-08-02' },
stats: { total_sets: 2, venue_count: 1, first_timers: 1, returning_acts: 1 },
bands: [
{
id: 20,
performance_id: 200,
name: 'Scheduled Band A',
genre: 'Indie',
photo_url: null,
start_time: '19:00',
end_time: '20:00',
venue_id: 5,
venue_name: 'Main Stage',
is_returning: true,
},
{
id: 21,
performance_id: 201,
name: 'Scheduled Band B',
genre: 'Folk',
photo_url: null,
start_time: '20:15',
end_time: '21:00',
venue_id: 5,
venue_name: 'Main Stage',
is_returning: false,
},
],
})
})

it('shows the Venues stat tile and real venue names as headings', async () => {
renderPage('lwbc17')
expect(await screen.findByText('LWBC Vol17')).toBeInTheDocument()

const statsRegion = screen.getByRole('region', { name: 'Event statistics' })
expect(within(statsRegion).getByText('Venues')).toBeInTheDocument()

expect(screen.getByRole('heading', { level: 3, name: 'Main Stage' })).toBeInTheDocument()
expect(screen.queryByRole('heading', { level: 3, name: 'Lineup' })).not.toBeInTheDocument()
})

it('renders real start/end times', async () => {
renderPage('lwbc17')
await screen.findByText('LWBC Vol17')

// EventRecapPage renders raw 24-hour start/end times as-is (no AM/PM
// conversion) — assert the actual values pass through, not "TBD".
expect(screen.getAllByText(/19:00.{1,3}20:00/).length).toBeGreaterThan(0)
})
})

describe('EventRecapPage — partially-scheduled event', () => {
beforeEach(() => {
fetchPublicJson.mockReset()
fetchPublicJson.mockResolvedValue({
event: { id: 3, name: 'LWBC Vol15', slug: 'lwbc15', date: '2024-08-03' },
stats: { total_sets: 2, venue_count: 1, first_timers: 2, returning_acts: 0 },
bands: [
{
id: 30,
performance_id: 300,
name: 'Assigned Band',
genre: 'Rock',
photo_url: null,
start_time: '19:00',
end_time: '20:00',
venue_id: 5,
venue_name: 'Main Stage',
is_returning: false,
},
{
id: 31,
performance_id: 301,
name: 'Unassigned Band',
genre: 'Pop',
photo_url: null,
start_time: null,
end_time: null,
venue_id: null,
venue_name: null,
is_returning: false,
},
],
})
})

it('keeps "Unscheduled" for the leftover no-venue group alongside a real venue group', async () => {
renderPage('lwbc15')
expect(await screen.findByText('LWBC Vol15')).toBeInTheDocument()

expect(screen.getByRole('heading', { level: 3, name: 'Main Stage' })).toBeInTheDocument()
expect(screen.getByRole('heading', { level: 3, name: 'Unscheduled' })).toBeInTheDocument()
expect(screen.queryByRole('heading', { level: 3, name: 'Lineup' })).not.toBeInTheDocument()
})
})
13 changes: 11 additions & 2 deletions functions/api/events/[id]/recap.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,16 @@ export async function onRequestGet(context) {
JOIN events e2 ON p2.event_id = e2.id
WHERE p2.band_profile_id = bp.id
AND p2.event_id != ?
AND e2.status = 'archived'
-- A recap can be generated shortly after an event goes live, before
-- it's archived — restricting to status='archived' would then miss
-- prior editions that are only published so far and wrongly call a
-- returning act a first-timer. published-or-archived covers both.
AND (e2.is_published = 1 OR e2.status = 'archived')
-- "Returning" means chronologically earlier, not merely "some other
-- event" (#613): without this, a band that only played a LATER
-- archived edition counted as returning at an EARLIER one. Same-day
-- events tie-break on id for a deterministic ordering.
AND (e2.date < ? OR (e2.date = ? AND e2.id < ?))
) THEN 1 ELSE 0 END AS is_returning
FROM performances p
JOIN band_profiles bp ON p.band_profile_id = bp.id
Expand All @@ -78,7 +87,7 @@ export async function onRequestGet(context) {
ORDER BY p.start_time NULLS LAST, bp.name
`,
)
.bind(event.id, event.id)
.bind(event.id, event.date, event.date, event.id, event.id)
.all();

const rows = bandsResult.results || [];
Expand Down
Loading
Loading