From 0385ede7218e3eccddcb3079ff8c96d093324313 Mon Sep 17 00:00:00 2001 From: Andre Levesque <0sniffs_scaled@icloud.com> Date: Fri, 17 Jul 2026 11:49:00 -0400 Subject: [PATCH] fix: recap first-timer chronology + roster-only archive noise MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit is_returning now requires a chronologically EARLIER published-or- archived event (date, id tie-break) — a band whose only other appearance was a later edition no longer counts as returning back in time, so Vol 1's recap correctly shows all first-timers. Roster-only archives (no venue/time data) drop the "Venues: 0" tile (grid adjusts to 4 columns), render nothing instead of "TBD-TBD", and head their single group "Lineup" instead of "Unscheduled"; partially-scheduled events keep "Unscheduled" for the genuine leftover group. Closes #613 Closes #614 Co-Authored-By: Claude Fable 5 --- frontend/src/pages/EventRecapPage.jsx | 30 ++- .../pages/__tests__/EventRecapPage.test.jsx | 187 ++++++++++++++++++ functions/api/events/[id]/recap.js | 13 +- functions/api/events/__tests__/recap.test.js | 156 +++++++++++++++ 4 files changed, 378 insertions(+), 8 deletions(-) create mode 100644 frontend/src/pages/__tests__/EventRecapPage.test.jsx diff --git a/frontend/src/pages/EventRecapPage.jsx b/frontend/src/pages/EventRecapPage.jsx index 0d1e0a5c..117c7b5c 100644 --- a/frontend/src/pages/EventRecapPage.jsx +++ b/frontend/src/pages/EventRecapPage.jsx @@ -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 ( <> @@ -193,9 +197,12 @@ export default function EventRecapPage() { -
+
- + {hasVenueData && } @@ -275,7 +282,14 @@ export default function EventRecapPage() {
-

{venue.name}

+ {/* "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). */} +

+ {venue.id === 'unscheduled' && !hasVenueData ? 'Lineup' : venue.name} +

{venue.bands.length} {venue.bands.length === 1 ? 'set' : 'sets'} on the night

@@ -299,9 +313,13 @@ export default function EventRecapPage() { > {band.name} - - {band.start_time || 'TBD'}–{band.end_time || 'TBD'} - + {/* Roster-only archives have neither start nor end time recorded — + render nothing rather than "TBD–TBD" (#614). */} + {(band.start_time || band.end_time) && ( + + {band.start_time || 'TBD'}–{band.end_time || 'TBD'} + + )} ))} diff --git a/frontend/src/pages/__tests__/EventRecapPage.test.jsx b/frontend/src/pages/__tests__/EventRecapPage.test.jsx new file mode 100644 index 00000000..8e19f8a9 --- /dev/null +++ b/frontend/src/pages/__tests__/EventRecapPage.test.jsx @@ -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( + + + + } /> + + + + ) +} + +// #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() + }) +}) diff --git a/functions/api/events/[id]/recap.js b/functions/api/events/[id]/recap.js index e4277457..5bb0da00 100644 --- a/functions/api/events/[id]/recap.js +++ b/functions/api/events/[id]/recap.js @@ -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 @@ -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 || []; diff --git a/functions/api/events/__tests__/recap.test.js b/functions/api/events/__tests__/recap.test.js index 5d07862b..3f34c780 100644 --- a/functions/api/events/__tests__/recap.test.js +++ b/functions/api/events/__tests__/recap.test.js @@ -175,6 +175,162 @@ describe("GET /api/events/:id/recap", () => { expect(newBand.is_returning).toBe(false); }); + // #613: is_returning must require a STRICTLY EARLIER event, not merely "any + // other" event. A band that only appears in a later edition must not be + // "returning" at an earlier one it hasn't played yet. + test("band playing an earlier and a later archived event is first-timer at the earlier, returning at the later", async () => { + const { env, rawDb } = createTestEnv(); + env.PUBLIC_DATA_PUBLISH_ENABLED = "true"; + + const earlierEvent = insertEvent(rawDb, { + name: "LWBC Vol1", + slug: "lwbc-vol1", + date: "2022-05-22", + status: "archived", + }); + const laterEvent = insertEvent(rawDb, { + name: "LWBC Vol3", + slug: "lwbc-vol3", + date: "2023-05-21", + status: "archived", + }); + + const venue = insertVenue(rawDb, { name: "Main Stage" }); + // Same band (same name -> same band_profile_id) plays both events. + insertBand(rawDb, { name: "Loop Riders", event_id: earlierEvent.id, venue_id: venue.id }); + insertBand(rawDb, { name: "Loop Riders", event_id: laterEvent.id, venue_id: venue.id }); + + const earlierRes = await onRequestGet({ + request: new Request(`https://example.test/api/events/${earlierEvent.slug}/recap`), + env, + params: { id: earlierEvent.slug }, + }); + const earlierPayload = await earlierRes.json(); + expect(earlierPayload.stats.first_timers).toBe(1); + expect(earlierPayload.stats.returning_acts).toBe(0); + expect(earlierPayload.bands[0].is_returning).toBe(false); + + const laterRes = await onRequestGet({ + request: new Request(`https://example.test/api/events/${laterEvent.slug}/recap`), + env, + params: { id: laterEvent.slug }, + }); + const laterPayload = await laterRes.json(); + expect(laterPayload.stats.first_timers).toBe(0); + expect(laterPayload.stats.returning_acts).toBe(1); + expect(laterPayload.bands[0].is_returning).toBe(true); + }); + + test("band playing only the later event is a first-timer there", async () => { + const { env, rawDb } = createTestEnv(); + env.PUBLIC_DATA_PUBLISH_ENABLED = "true"; + + const earlierEvent = insertEvent(rawDb, { + name: "LWBC Vol1", + slug: "lwbc-vol1-solo", + date: "2022-05-22", + status: "archived", + }); + const laterEvent = insertEvent(rawDb, { + name: "LWBC Vol3", + slug: "lwbc-vol3-solo", + date: "2023-05-21", + status: "archived", + }); + + const venue = insertVenue(rawDb, { name: "Main Stage" }); + insertBand(rawDb, { name: "Only At Vol1", event_id: earlierEvent.id, venue_id: venue.id }); + insertBand(rawDb, { name: "Only At Vol3", event_id: laterEvent.id, venue_id: venue.id }); + + const laterRes = await onRequestGet({ + request: new Request(`https://example.test/api/events/${laterEvent.slug}/recap`), + env, + params: { id: laterEvent.slug }, + }); + const laterPayload = await laterRes.json(); + const onlyAtVol3 = laterPayload.bands.find((b) => b.name === "Only At Vol3"); + expect(onlyAtVol3.is_returning).toBe(false); + expect(laterPayload.stats.first_timers).toBe(1); + expect(laterPayload.stats.returning_acts).toBe(0); + }); + + test("same-day events tie-break deterministically on id — returning only at the higher id", async () => { + const { env, rawDb } = createTestEnv(); + env.PUBLIC_DATA_PUBLISH_ENABLED = "true"; + + const sameDate = "2024-08-03"; + // Insertion order guarantees lowerIdEvent.id < higherIdEvent.id. + const lowerIdEvent = insertEvent(rawDb, { + name: "Same Day A", + slug: "same-day-a", + date: sameDate, + status: "archived", + }); + const higherIdEvent = insertEvent(rawDb, { + name: "Same Day B", + slug: "same-day-b", + date: sameDate, + status: "archived", + }); + expect(higherIdEvent.id).toBeGreaterThan(lowerIdEvent.id); + + const venue = insertVenue(rawDb, { name: "Main Stage" }); + insertBand(rawDb, { name: "Tie Break Band", event_id: lowerIdEvent.id, venue_id: venue.id }); + insertBand(rawDb, { name: "Tie Break Band", event_id: higherIdEvent.id, venue_id: venue.id }); + + const lowerRes = await onRequestGet({ + request: new Request(`https://example.test/api/events/${lowerIdEvent.slug}/recap`), + env, + params: { id: lowerIdEvent.slug }, + }); + const lowerPayload = await lowerRes.json(); + expect(lowerPayload.bands[0].is_returning).toBe(false); + + const higherRes = await onRequestGet({ + request: new Request(`https://example.test/api/events/${higherIdEvent.slug}/recap`), + env, + params: { id: higherIdEvent.slug }, + }); + const higherPayload = await higherRes.json(); + expect(higherPayload.bands[0].is_returning).toBe(true); + }); + + // #613: the earlier event only needs to be published (not yet archived) to + // count — a recap generated shortly after an event should still recognize + // acts returning from a prior, still-published (not-yet-archived) edition. + test("counts a prior published (not archived) event when computing is_returning", async () => { + const { env, rawDb } = createTestEnv(); + env.PUBLIC_DATA_PUBLISH_ENABLED = "true"; + + const priorEvent = insertEvent(rawDb, { + name: "Still Published Vol", + slug: "still-published-vol", + date: "2023-06-01", + status: "published", + }); + rawDb.prepare("UPDATE events SET is_published = 1 WHERE id = ?").run(priorEvent.id); + + const currentEvent = insertEvent(rawDb, { + name: "Archived Vol", + slug: "archived-vol", + date: "2024-06-01", + status: "archived", + }); + + const venue = insertVenue(rawDb, { name: "Main Stage" }); + insertBand(rawDb, { name: "Cross Edition Band", event_id: priorEvent.id, venue_id: venue.id }); + insertBand(rawDb, { name: "Cross Edition Band", event_id: currentEvent.id, venue_id: venue.id }); + + const res = await onRequestGet({ + request: new Request(`https://example.test/api/events/${currentEvent.slug}/recap`), + env, + params: { id: currentEvent.slug }, + }); + const payload = await res.json(); + expect(payload.bands[0].is_returning).toBe(true); + expect(payload.stats.returning_acts).toBe(1); + }); + it("counts only assigned venues (null venue does not increment venue_count)", async () => { const { env, rawDb } = createTestEnv(); env.PUBLIC_DATA_PUBLISH_ENABLED = "true";