From 658a55b85627bb70ea9608bcea9afd200214d757 Mon Sep 17 00:00:00 2001 From: Jake Bromberg Date: Tue, 8 Sep 2026 11:39:53 -0700 Subject: [PATCH] Restore the airplay-search and playlist-archive listener surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both pages were reverted off main on 2026-08-26 (43c433dc, 90643dc1) because they render historical DJ identity that could hold real names. WXYC/Backend-Service#2281 ran against production on 2026-09-08 and rewrote 1,103,206 rows, removing Cohort A (the auth_user.name era) and Cohort B (the tubafrenzy DJ_NAME era) from flowsheet.dj_name. Cohort C remains by design — names DJs typed into tubafrenzy's own free-text DJ_HANDLE field. Verified against the oldest retained backup (wxycmusic-2025-10-13) and the final pre-turndown dump (wxycmusic-2026-09-07) that 994 of the 995 affected shows carried that value in tubafrenzy's public DJ_HANDLE column rather than its private DJ_NAME column, so restoring re-publishes what tubafrenzy itself published on those shows. The single exception, legacy_show_id 168503, had an empty upstream handle and was nulled out separately. Pages and their tests are re-added whole from the revert parents; the reverts left lib/flowsheetRange.js, lib/flowsheetSearch.js and components/OrganizingArchive.js in place, so no data-layer work was needed. The wxyc.info turndown guard in wxycInfoLinks.test.js stays and still passes — only the deliberate-gap note and the suppressed assertions come out. Two corrections folded in rather than restored verbatim: programming.mdx said the archive reaches back to 2009 when flowsheet history runs to at least 2004-11-01, and a plain checkout of the pre-revert wxycInfoLinks.test.js would have silently undone e089f9c9's 2026-08-31 to 2026-09-07 cutover-date fix. Closes #232. --- README.md | 21 ++ __tests__/airplaySearch.test.jsx | 313 +++++++++++++++++++++ __tests__/archivePlaylists.test.jsx | 409 ++++++++++++++++++++++++++++ __tests__/wxycInfoLinks.test.js | 30 +- content/page/programming.mdx | 4 + pages/airplay-search.jsx | 260 ++++++++++++++++++ pages/playlists/archive.jsx | 369 +++++++++++++++++++++++++ 7 files changed, 1393 insertions(+), 13 deletions(-) create mode 100644 __tests__/airplaySearch.test.jsx create mode 100644 __tests__/archivePlaylists.test.jsx create mode 100644 pages/airplay-search.jsx create mode 100644 pages/playlists/archive.jsx diff --git a/README.md b/README.md index a48b4fbf7..70903d640 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,18 @@ image

[the development of this site is brought to you by the easily accessible assortment of photos of Adrianne Lenker that live on my desktop]

+

Playlist archive (/playlists/archive)

+

Public, week-at-a-time browse of every show WXYC has logged, back to at least November 2004. Successor to wxyc.info/playlists/radioWeek, which went dark at the 2026-09-07 tubafrenzy cutover (WXYC/wiki#93).

+ +

Live playlist (/playlist)

Public view of the most recent flowsheet entries, refreshing while the tab stays open. Successor to wxyc.info/playlists/recent, which goes dark at the 2026-09-07 tubafrenzy cutover (WXYC/wiki#93).

+

Airplay search (/airplay-search)

+

Public search over WXYC's full airplay history, back to at least November 2004. Successor to wxyc.info/playlists/searchPlaylists, which went dark at the 2026-09-07 tubafrenzy cutover (WXYC/wiki#93).

+ +

DNS & hosting (do not "fix" the Pages domain warning)

wxyc.org and www.wxyc.org are proxied through Cloudflare (orange-cloud) rather than pointing their DNS records directly at GitHub Pages. Cloudflare forwards the Host header to GitHub Pages as the origin, so the site is still built and served by this repo's Pages deploy exactly as before — Cloudflare just sits in front of it (this is what lets us attach edge Workers to the apex).

Because the public A/AAAA records no longer resolve to GitHub's Pages IPs (they resolve to Cloudflare's edge), repo Settings → Pages will show a warning that the custom domain's DNS does not point at GitHub Pages. This warning is expected and cosmetic — the site works. Do not change the DNS records back to GitHub's IPs, and do not clear the custom-domain field in Settings → Pages (the custom domain lives only there; there is no CNAME file in the repo). Reverting either would break the Cloudflare proxy in front of the apex. If you need to take Cloudflare out of the path, toggle the apex + www records from orange (Proxied) back to gray (DNS only) in the Cloudflare dashboard.

diff --git a/__tests__/airplaySearch.test.jsx b/__tests__/airplaySearch.test.jsx new file mode 100644 index 000000000..1ad8c20b9 --- /dev/null +++ b/__tests__/airplaySearch.test.jsx @@ -0,0 +1,313 @@ +import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest' +import {render, screen, fireEvent, waitFor} from '@testing-library/react' +import {createMockFetch, createTestLifecycle, testData} from './test-utils' + +vi.mock('next/head', () => ({ + default: ({children}) => <>{children}, +})) + +const AirplaySearch = (await import('../pages/airplay-search')).default + +const result = testData.flowsheetSearchResult + +function mockFetchOnce(body, options) { + global.fetch = createMockFetch(body, options) + return global.fetch +} + +const lifecycle = createTestLifecycle() + +beforeEach(lifecycle.beforeEach) +afterEach(lifecycle.afterEach) + +describe('Airplay search page', () => { + it('shows a loading state before the first fetch resolves', () => { + global.fetch = vi.fn(() => new Promise(() => {})) + render() + + expect(screen.getByRole('status')).toBeDefined() + }) + + it('loads the recent-tracks default for an empty query, with no q param', async () => { + const fetchMock = mockFetchOnce({ + results: [result()], + total: 823, + page: 0, + totalPages: 33, + }) + render() + + await waitFor(() => expect(fetchMock).toHaveBeenCalled()) + const url = new URL(fetchMock.mock.calls[0][0]) + expect(url.searchParams.has('q')).toBe(false) + expect(url.searchParams.get('page')).toBe('0') + expect(await screen.findByText('Juana Molina')).toBeDefined() + }) + + it('renders a results table with artist, track, album, label, play date, and DJ', async () => { + mockFetchOnce({ + results: [result()], + total: 1, + page: 0, + totalPages: 1, + }) + render() + + await screen.findByText('Juana Molina') + expect(screen.getByText('Quien? (Suite)')).toBeDefined() + expect(screen.getByText('un dia')).toBeDefined() + expect(screen.getByText('Domino')).toBeDefined() + expect(screen.getByText('July 21, 2026, 11:47 AM')).toBeDefined() + expect(screen.getByText('Unknown DJ')).toBeDefined() + }) + + it('renders matching rows for a typed query, not just the default landing view', async () => { + const fetchMock = mockFetchOnce({ + results: [], + total: 0, + page: 0, + totalPages: 0, + }) + render() + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)) + + mockFetchOnce({ + results: [ + result({id: 99, artist_name: 'Stereolab', track_title: 'Percolator'}), + ], + total: 1, + page: 0, + totalPages: 1, + }) + fireEvent.change(screen.getByRole('searchbox'), { + target: {value: 'stereolab'}, + }) + + expect( + await screen.findByText('Stereolab', {}, {timeout: 2000}) + ).toBeDefined() + expect(screen.getByText('Percolator')).toBeDefined() + }) + + it('shows the sentinel-aware total rather than the raw capped count', async () => { + mockFetchOnce({ + results: [result()], + total: 10001, + page: 0, + totalPages: 401, + }) + render() + + expect(await screen.findByText(/10,000\+/)).toBeDefined() + }) + + it('renders a no-results message for a query with no matches, not a blank table', async () => { + mockFetchOnce({results: [], total: 0, page: 0, totalPages: 0}) + render() + + expect(await screen.findByText(/no.*airplay/i)).toBeDefined() + expect(screen.queryByRole('table')).toBeNull() + }) + + it('renders a fetch failure as an alert with a retry, not a blank table', async () => { + mockFetchOnce(null, {ok: false, status: 503}) + render() + + const alert = await screen.findByRole('alert') + expect(alert.textContent).toContain('503') + expect(screen.getByRole('button', {name: 'Retry'})).toBeDefined() + expect(screen.queryByRole('table')).toBeNull() + }) + + it('preserves the last good results and pager when a later fetch fails, rather than wiping the page', async () => { + mockFetchOnce({ + results: [result()], + total: 50, + page: 0, + totalPages: 2, + }) + render() + await screen.findByText('Juana Molina') + + mockFetchOnce(null, {ok: false, status: 503}) + fireEvent.click(screen.getByRole('button', {name: /Next/})) + + const alert = await screen.findByRole('alert') + expect(alert.textContent).toContain('503') + // The stale row and the pager survive the failed request rather than + // being wiped by it. + expect(screen.getByText('Juana Molina')).toBeDefined() + expect(screen.getByRole('button', {name: /Previous/})).toBeDefined() + }) + + it('keeps the pager mounted, with Previous reachable, when paging lands on an empty page', async () => { + mockFetchOnce({ + results: [result()], + total: 50, + page: 0, + totalPages: 3, + }) + render() + await screen.findByText('Juana Molina') + + mockFetchOnce({results: [], total: 50, page: 1, totalPages: 3}) + fireEvent.click(screen.getByRole('button', {name: /Next/})) + + await screen.findByText(/paged past the end/i) + expect(screen.getByRole('button', {name: /Previous/}).disabled).toBe(false) + }) + + it('debounces the search input rather than firing a request per keystroke', async () => { + const fetchMock = mockFetchOnce({ + results: [], + total: 0, + page: 0, + totalPages: 0, + }) + render() + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)) + fetchMock.mockClear() + + const input = screen.getByRole('searchbox') + fireEvent.change(input, {target: {value: 'j'}}) + fireEvent.change(input, {target: {value: 'ju'}}) + fireEvent.change(input, {target: {value: 'jua'}}) + fireEvent.change(input, {target: {value: 'juana'}}) + + // Still inside the debounce window immediately after typing. + expect(fetchMock).not.toHaveBeenCalled() + + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1), { + timeout: 2000, + }) + const url = new URL(fetchMock.mock.calls[0][0]) + expect(url.searchParams.get('q')).toBe('juana') + }) + + it('resets to page 0 when the query changes', async () => { + const fetchMock = mockFetchOnce({ + results: Array.from({length: 25}, (_, i) => result({id: i})), + total: 100, + page: 0, + totalPages: 4, + }) + render() + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)) + + fireEvent.click(screen.getByRole('button', {name: /Next/})) + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)) + expect(new URL(fetchMock.mock.calls[1][0]).searchParams.get('page')).toBe( + '1' + ) + + fetchMock.mockClear() + const input = screen.getByRole('searchbox') + fireEvent.change(input, {target: {value: 'stereolab'}}) + + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1), { + timeout: 2000, + }) + expect(new URL(fetchMock.mock.calls[0][0]).searchParams.get('page')).toBe( + '0' + ) + }) + + it('paginates forward and back, bounded by totalPages', async () => { + mockFetchOnce({ + results: [result()], + total: 50, + page: 0, + totalPages: 2, + }) + render() + await screen.findByText('Juana Molina') + + const previous = screen.getByRole('button', {name: /Previous/}) + const next = screen.getByRole('button', {name: /Next/}) + expect(previous.disabled).toBe(true) + expect(next.disabled).toBe(false) + + mockFetchOnce({ + results: [result({id: 2, artist_name: 'Stereolab'})], + total: 50, + page: 1, + totalPages: 2, + }) + fireEvent.click(next) + + await screen.findByText('Stereolab') + expect(screen.getByRole('button', {name: /Next/}).disabled).toBe(true) + expect(screen.getByRole('button', {name: /Previous/}).disabled).toBe(false) + }) + + it('sends no credentials', async () => { + const fetchMock = mockFetchOnce({ + results: [], + total: 0, + page: 0, + totalPages: 0, + }) + render() + + await waitFor(() => expect(fetchMock).toHaveBeenCalled()) + expect(fetchMock.mock.calls[0][1].credentials).toBe('omit') + }) + + describe('field-syntax guidance', () => { + it('names the recognized field prefixes in the static tip', async () => { + const fetchMock = mockFetchOnce({ + results: [], + total: 0, + page: 0, + totalPages: 0, + }) + render() + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)) + + expect(screen.getByText('album:')).toBeDefined() + expect(screen.getByText('dateRange:')).toBeDefined() + }) + + it('warns when a field prefix is sent with no value, rather than silently searching everything', async () => { + const fetchMock = mockFetchOnce({ + results: [], + total: 0, + page: 0, + totalPages: 0, + }) + render() + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)) + + mockFetchOnce({results: [], total: 0, page: 0, totalPages: 0}) + fireEvent.change(screen.getByRole('searchbox'), { + target: {value: 'artist:'}, + }) + + expect( + await screen.findByText( + /no value after the colon/i, + {}, + {timeout: 2000} + ) + ).toBeDefined() + }) + + it('does not warn when a colon is a literal, non-prefix character', async () => { + const fetchMock = mockFetchOnce({ + results: [result()], + total: 31, + page: 0, + totalPages: 2, + }) + render() + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)) + + fireEvent.change(screen.getByRole('searchbox'), { + target: {value: 'Emperor: Lift Your Skinny Fists'}, + }) + + await screen.findByText('Juana Molina', {}, {timeout: 2000}) + expect(screen.queryByText(/no value after the colon/i)).toBeNull() + }) + }) +}) diff --git a/__tests__/archivePlaylists.test.jsx b/__tests__/archivePlaylists.test.jsx new file mode 100644 index 000000000..1a7ee2d8e --- /dev/null +++ b/__tests__/archivePlaylists.test.jsx @@ -0,0 +1,409 @@ +import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest' +import {render, screen, fireEvent, waitFor} from '@testing-library/react' +import {createMockFetch, createTestLifecycle, testData} from './test-utils' +import {clearWeekCache} from '../lib/weekCache' +import { + addDays, + easternMidnightEpoch, + easternToday, + startOfWeek, +} from '../lib/easternTime' + +const push = vi.fn() +let routerQuery = {} +let routerIsReady = true + +vi.mock('next/router', () => ({ + useRouter: () => ({ + isReady: routerIsReady, + query: routerQuery, + push, + }), +})) + +vi.mock('next/head', () => ({ + default: ({children}) => <>{children}, +})) + +const ArchivePlaylists = (await import('../pages/playlists/archive')).default + +const track = testData.flowsheetTrack + +const RANGE = { + shows: [testData.flowsheetShow()], + entries: [ + { + id: 99, + show_id: 1, + play_order: 0, + add_time: '2026-08-03T14:00:00.000Z', + entry_type: 'show_start', + }, + track(), + track({ + id: 101, + play_order: 2, + artist_name: 'Jessica Pratt', + track_title: 'Back, Baby', + album_title: 'On Your Own Love Again', + record_label: 'Drag City', + rotation_bin: 'H', + request_flag: true, + }), + { + id: 102, + show_id: 1, + play_order: 3, + add_time: '2026-08-03T14:20:00.000Z', + entry_type: 'talkset', + message: 'TALKSET', + }, + ], +} + +function mockFetchOnce(body, options) { + global.fetch = createMockFetch(body, options) + return global.fetch +} + +const lifecycle = createTestLifecycle() + +beforeEach(() => { + lifecycle.beforeEach() + routerQuery = {week: '2026-08-03'} + routerIsReady = true + // The week cache outlives an unmount by design, so it also outlives a test. + clearWeekCache() +}) + +afterEach(lifecycle.afterEach) + +describe('Playlist archive page', () => { + it('shows a loading state before the fetch resolves', () => { + global.fetch = vi.fn(() => new Promise(() => {})) + render() + + expect(screen.getByRole('status')).toHaveProperty( + 'textContent', + expect.stringContaining('Loading') + ) + }) + + it('renders each day of the week and the shows within it', async () => { + mockFetchOnce(RANGE) + render() + + await screen.findByText('DJ Biscuit') + // All seven days get a heading, so the whole week is browsable even when + // only one of them has playlists. + expect(screen.getByText('Monday, August 3, 2026')).toBeDefined() + expect(screen.getByText('Sunday, August 9, 2026')).toBeDefined() + expect( + screen.getAllByText('No playlists recorded for this day.') + ).toHaveLength(6) + }) + + it('renders track rows with artist, song, release and label', async () => { + mockFetchOnce(RANGE) + render() + + await screen.findByText('Juana Molina') + expect(screen.getByText('la paradoja')).toBeDefined() + expect(screen.getByText('DOGA')).toBeDefined() + expect(screen.getByText('Sonamos')).toBeDefined() + expect(screen.getByText('Jessica Pratt')).toBeDefined() + expect(screen.getByText('(request)')).toBeDefined() + }) + + it('keeps each playlist collapsed behind its schedule line', async () => { + // A week is 2,300-2,800 entries in production. The schedule stays visible; + // the rows only get laid out when a visitor opens one. + mockFetchOnce(RANGE) + const {container} = render() + + await screen.findByText('DJ Biscuit') + const details = container.querySelectorAll('details') + expect(details).toHaveLength(1) + expect(details[0].open).toBe(false) + // Track count on the summary, so the schedule line is informative closed. + expect(details[0].querySelector('summary').textContent).toContain( + '2 tracks' + ) + }) + + it('renders talksets and breakpoints as separators rather than blank rows', async () => { + mockFetchOnce(RANGE) + render() + + expect(await screen.findByText('TALKSET')).toBeDefined() + }) + + it('does not repeat the show delimiters inside the show they delimit', async () => { + // show_start / show_end restate the header's DJ and air time. + mockFetchOnce(RANGE) + const {container} = render() + + await screen.findByText('DJ Biscuit') + expect(container.textContent).not.toContain('show start') + expect(container.textContent).not.toContain('show end') + }) + + it('drops a sign-on/sign-off shell with no air content', async () => { + mockFetchOnce({ + shows: [ + RANGE.shows[0], + { + id: 2, + dj_name: 'Funland Research', + show_name: null, + specialty_id: null, + start_time: '2026-08-03T21:22:00.000Z', + end_time: '2026-08-03T21:24:00.000Z', + }, + ], + entries: [ + ...RANGE.entries, + { + id: 300, + show_id: 2, + play_order: 1, + add_time: '2026-08-03T21:22:00.000Z', + entry_type: 'show_start', + }, + { + id: 301, + show_id: 2, + play_order: 2, + add_time: '2026-08-03T21:24:00.000Z', + entry_type: 'show_end', + }, + ], + }) + render() + + await screen.findByText('DJ Biscuit') + expect(screen.queryByText('Funland Research')).toBeNull() + }) + + it('renders an entry with a null show_id as unattributed', async () => { + mockFetchOnce({ + shows: RANGE.shows, + entries: [ + ...RANGE.entries, + track({ + id: 200, + show_id: null, + artist_name: 'Nilüfer Yanya', + track_title: 'Stabilise', + add_time: '2026-08-03T20:00:00.000Z', + }), + ], + }) + render() + + expect(await screen.findByText('Unattributed')).toBeDefined() + expect(screen.getByText('Nilüfer Yanya')).toBeDefined() + }) + + it('renders an empty week without erroring', async () => { + mockFetchOnce({shows: [], entries: []}) + render() + + expect( + await screen.findByText('No playlists were recorded this week.') + ).toBeDefined() + }) + + it('renders a fetch failure as an alert with a retry', async () => { + mockFetchOnce(null, {ok: false, status: 503}) + render() + + const alert = await screen.findByRole('alert') + expect(alert.textContent).toContain('503') + expect(screen.getByRole('button', {name: 'Retry'})).toBeDefined() + }) + + it('renders a 400 as guidance to pick another week', async () => { + mockFetchOnce(null, {ok: false, status: 400}) + render() + + const alert = await screen.findByRole('alert') + expect(alert.textContent).toContain('different week') + }) + + it('reads the week from the query string', async () => { + routerQuery = {week: '2026-08-05'} // a Wednesday + const fetchMock = mockFetchOnce({shows: [], entries: []}) + render() + + await waitFor(() => expect(fetchMock).toHaveBeenCalled()) + // Snapped back to its Monday. + expect(screen.getByText(/Week of August 3, 2026/)).toBeDefined() + }) + + it.each([ + ['a malformed date', 'not-a-date'], + ['a nonexistent date', '2026-02-31'], + ])('falls back to the current week given %s', async (_label, week) => { + routerQuery = {week} + const fetchMock = mockFetchOnce({shows: [], entries: []}) + render() + + await waitFor(() => expect(fetchMock).toHaveBeenCalled()) + const url = new URL(fetchMock.mock.calls[0][0]) + expect(Number(url.searchParams.get('start'))).toBe( + easternMidnightEpoch(startOfWeek(easternToday())) + ) + }) + + it.each([ + ['before the archive begins', '1970-01-05', '2004-11-01'], + ['a partially-typed year', '0202-08-10', '2004-11-01'], + ])( + 'clamps a week %s to the archive rather than querying for it', + async (_label, week, expectedDate) => { + // `` fires onChange on each keystroke of a typed year, + // so 0202 is a value a visitor produces just by editing the field, and + // ?week= accepts anything at all. Neither should reach the backend. + routerQuery = {week} + const fetchMock = mockFetchOnce({shows: [], entries: []}) + render() + + await waitFor(() => expect(fetchMock).toHaveBeenCalled()) + const url = new URL(fetchMock.mock.calls[0][0]) + expect(Number(url.searchParams.get('start'))).toBe( + easternMidnightEpoch(startOfWeek(expectedDate)) + ) + } + ) + + it('does not fetch until the router has resolved the query string', async () => { + // A statically exported page renders once with an empty query string. If + // that render fetches, every deep link costs two full weeks of data. + routerIsReady = false + routerQuery = {} + const fetchMock = mockFetchOnce({shows: [], entries: []}) + const {rerender} = render() + + expect(fetchMock).not.toHaveBeenCalled() + expect(screen.getByRole('status')).toBeDefined() + + routerIsReady = true + routerQuery = {week: '2026-07-06'} + rerender() + + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)) + const url = new URL(fetchMock.mock.calls[0][0]) + expect(Number(url.searchParams.get('start'))).toBe( + easternMidnightEpoch('2026-07-06') + ) + }) + + it('serves a past week it has already fetched from memory', async () => { + // A week is half a megabyte gzipped and the endpoint sends no + // Cache-Control, so paging away and back must not re-download it. + const fetchMock = mockFetchOnce(RANGE) + const {unmount} = render() + await screen.findByText('DJ Biscuit') + expect(fetchMock).toHaveBeenCalledTimes(1) + + unmount() + render() + + await screen.findByText('DJ Biscuit') + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('refetches the current week rather than serving a stale copy', async () => { + // The week in progress is still being written to as shows air. + const monday = startOfWeek(easternToday()) + routerQuery = {week: monday} + const fetchMock = mockFetchOnce({shows: [], entries: []}) + const {unmount} = render() + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)) + + unmount() + render() + + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)) + }) + + it('does not describe days that have not aired yet as missing', async () => { + // The current week's landing view is otherwise several days captioned as + // though the archive had lost them. + const monday = startOfWeek(easternToday()) + const dates = Array.from({length: 7}, (_, i) => addDays(monday, i)) + const elapsed = dates.filter((date) => date <= easternToday()) + + routerQuery = {week: monday} + mockFetchOnce({ + shows: [ + testData.flowsheetShow({ + start_time: `${monday}T14:00:00.000Z`, + end_time: `${monday}T17:00:00.000Z`, + }), + ], + entries: [testData.flowsheetTrack({add_time: `${monday}T14:05:00.000Z`})], + }) + render() + + await screen.findByText('DJ Biscuit') + expect(screen.queryAllByText('Not yet aired.')).toHaveLength( + dates.length - elapsed.length + ) + // Every elapsed day but Monday, which has the show. + expect( + screen.queryAllByText('No playlists recorded for this day.') + ).toHaveLength(elapsed.length - 1) + }) + + it('puts the week in the URL when navigating, so a week is linkable', async () => { + mockFetchOnce({shows: [], entries: []}) + render() + + const previous = await screen.findByRole('button', {name: /Previous week/}) + fireEvent.click(previous) + + await waitFor(() => + expect(push).toHaveBeenCalledWith( + '/playlists/archive?week=2026-07-27', + undefined, + {shallow: true} + ) + ) + }) + + it('disables forward navigation past the current week', async () => { + routerQuery = {} + mockFetchOnce({shows: [], entries: []}) + render() + + await waitFor(() => + expect(screen.getByRole('button', {name: /Next week/}).disabled).toBe( + true + ) + ) + }) + + it('requests exactly the Eastern week it is showing', async () => { + const fetchMock = mockFetchOnce({shows: [], entries: []}) + render() + + await waitFor(() => expect(fetchMock).toHaveBeenCalled()) + const url = new URL(fetchMock.mock.calls[0][0]) + expect(Number(url.searchParams.get('start'))).toBe( + easternMidnightEpoch('2026-08-03') + ) + expect(Number(url.searchParams.get('end'))).toBe( + easternMidnightEpoch('2026-08-10') + ) + }) + + it('sends no credentials', async () => { + const fetchMock = mockFetchOnce({shows: [], entries: []}) + render() + + await waitFor(() => expect(fetchMock).toHaveBeenCalled()) + expect(fetchMock.mock.calls[0][1].credentials).toBe('omit') + }) +}) diff --git a/__tests__/wxycInfoLinks.test.js b/__tests__/wxycInfoLinks.test.js index 128765f44..2b137a8cd 100644 --- a/__tests__/wxycInfoLinks.test.js +++ b/__tests__/wxycInfoLinks.test.js @@ -10,15 +10,6 @@ * does not accept, and the third link site is TinaCMS-managed `.mdx` content that * no component test would ever cover. * - * `searchPlaylists` and `radioWeek` are the two cutover pages left with no - * successor: `/airplay-search` and `/playlists/archive` were both pulled over - * the historical-DJ-name exposure (see the commit that removed them), so - * nothing here links to a replacement for either. That is a deliberate gap, - * not an oversight in this inventory -- do not "repair" it by pointing a link - * back at wxyc.info, which the first assertion below forbids and which goes - * dark at the cutover regardless. `/playlist` is unaffected: it serves only - * the most recent entries, all written after the write-path fix. - * * Prose mentions of wxyc.info are allowed on purpose. The successor pages each * carry a docblock naming the page they replace, and README.md does the same; * that is accurate provenance and deleting it to satisfy a grep would lose @@ -94,11 +85,21 @@ describe('wxyc.info link cutover', () => { expect(violations).toEqual([]) }) - // The link sites inventoried on WXYC/website#214, each with the + // The four link sites inventoried on WXYC/website#214, each with the // wxyc.info page it replaces. it.each([ ['components/Header.js', '/playlist', 'wxyc.info/playlists/recent'], ['components/DropdownMenu.js', '/playlist', 'wxyc.info/playlists/recent'], + [ + 'content/page/programming.mdx', + '/playlists/archive', + 'wxyc.info/playlists/radioWeek', + ], + [ + 'content/page/programming.mdx', + '/airplay-search', + 'wxyc.info/playlists/searchPlaylists', + ], ])('%s links to %s in place of %s', (file, route) => { const targets = linkTargets( fs.readFileSync(path.join(ROOT, file), 'utf8') @@ -106,7 +107,10 @@ describe('wxyc.info link cutover', () => { expect(targets).toContain(route) }) - it.each([['/playlist']])('%s resolves to a page that exists', (route) => { - expect(resolvePage(route)).not.toBeNull() - }) + it.each([['/playlist'], ['/playlists/archive'], ['/airplay-search']])( + '%s resolves to a page that exists', + (route) => { + expect(resolvePage(route)).not.toBeNull() + } + ) }) diff --git a/content/page/programming.mdx b/content/page/programming.mdx index deaddbdce..c5015d5b2 100644 --- a/content/page/programming.mdx +++ b/content/page/programming.mdx @@ -50,3 +50,7 @@ A spotlight on beat-oriented music, dance culture, and the art of live mixing. T ### Programming Archives Want to listen to a show from the past two weeks? Hit up the [WXYC Archive Player](https://archive.wxyc.org/). + +Just want the playlist from any show going back to 2004? Go to our [show archives](/playlists/archive). + +Have a specific song, artist, album, or label in mind? Search our [airplay records](/airplay-search). diff --git a/pages/airplay-search.jsx b/pages/airplay-search.jsx new file mode 100644 index 000000000..fc246ae98 --- /dev/null +++ b/pages/airplay-search.jsx @@ -0,0 +1,260 @@ +import React, {useEffect, useState} from 'react' +import Head from 'next/head' +import { + DEFAULT_PAGE_SIZE, + canGoToNextPage, + fetchFlowsheetSearch, + formatPlayDate, + formatSearchTotal, + hasEmptyFieldFilter, + isAtDepthLimit, +} from '../lib/flowsheetSearch' + +/** + * Public airplay-search page — the successor to + * `wxyc.info/playlists/searchPlaylists`, which dies at the 2026-08-31 + * tubafrenzy cutover. + * + * Client-side only: this site is a static export, so the query and the + * results live entirely in component state rather than a route or an SSR + * prop. The search box is debounced so a listener typing a query does not + * fire one request per keystroke against a public API, and an empty query is + * a real, supported state — the backend serves its most-recent-tracks + * default for it, which becomes this page's landing view. + */ + +/** How long to wait after the last keystroke before searching. */ +const DEBOUNCE_MS = 400 + +function SearchResultRow({row}) { + return ( + + {row.artist_name} + {row.track_title} + {row.album_title} + {row.record_label} + + {formatPlayDate(row.play_date)} + + {row.dj_name} + + ) +} + +const INITIAL_SEARCH = {q: '', page: 0} + +const AirplaySearch = () => { + // Raw input value, updated on every keystroke. + const [query, setQuery] = useState('') + // The query actually searched for and the page within it, held as one + // object updated atomically. A search and its page number have to change + // together: a separate `setPage(0)` effect keyed off the debounced query + // races the fetch effect, because both fire in the same commit and the + // fetch effect still reads the pre-reset page on that pass — one request + // goes out for the old page, a second follows once the reset lands. See + // `pages/playlists/archive.jsx` for the same trap in the week picker. + const [search, setSearch] = useState(INITIAL_SEARCH) + + // Last successful response. Deliberately not cleared when a later fetch + // fails — see the `error` branch below — so a transient failure while + // paging does not strand the listener on a blank page, and does not + // unmount the pager out from under them. + const [data, setData] = useState(null) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + // Bumped by Retry. A plain re-fetch of the same query/page would be a + // no-op from React's point of view, since neither dependency changed. + const [reloadToken, setReloadToken] = useState(0) + + useEffect(() => { + const timer = setTimeout(() => { + const trimmed = query.trim() + // A new search starts back at the first page — paging deep into one + // query and then searching for something else should not silently + // request a page number the new query may not have. + setSearch((prev) => (prev.q === trimmed ? prev : {q: trimmed, page: 0})) + }, DEBOUNCE_MS) + return () => clearTimeout(timer) + }, [query]) + + useEffect(() => { + const controller = new AbortController() + setIsLoading(true) + setError(null) + + fetchFlowsheetSearch( + {q: search.q, page: search.page, limit: DEFAULT_PAGE_SIZE}, + {signal: controller.signal} + ) + .then((response) => { + setData(response) + setIsLoading(false) + }) + .catch((err) => { + if (err?.name === 'AbortError') return + setError(err?.message || 'Could not search airplay records.') + setIsLoading(false) + }) + + return () => controller.abort() + }, [search, reloadToken]) + + const results = data?.results ?? [] + const totalPages = data?.totalPages ?? 0 + // Reached by paging into a page the backend can no longer back up: its + // count query can itself time out and fall back to an offset-derived + // estimate, which can re-enable Next past the real end of the results. A + // zero-result page beyond the first is that condition, not "nothing has + // ever aired" — the empty state below says so, and the pager stays + // mounted so Previous is always reachable. + const pastEnd = results.length === 0 && search.page > 0 + const canGoBack = search.page > 0 + const canGoForward = canGoToNextPage(search, totalPages) + const atDepthLimit = isAtDepthLimit(search, totalPages) + // Whether there is anything worth showing pagination controls for. Keyed + // off `search.page > 0` as well as `results.length` so a listener who + // pages onto an empty page (see `pastEnd`) still has a Previous button. + const showPager = data !== null && (results.length > 0 || search.page > 0) + const goToPage = (page) => setSearch((prev) => ({...prev, page})) + const emptyFieldFilter = hasEmptyFieldFilter(search.q) + + const errorBanner = error ? ( +
+

{error}

+ +
+ ) : null + + return ( + <> + + Airplay Search | WXYC + + + +
+

Airplay Search

+

+ Search everything WXYC has played. Leave the box empty for the most + recent airplay. +

+

+ Tip: narrow a search by field —{' '} + artist:,{' '} + song:,{' '} + album:,{' '} + label:,{' '} + dj:,{' '} + date:, or{' '} + dateRange:, e.g.{' '} + + artist:foo AND album:"bar" + + . Any other colon in your search is read literally, not as syntax. +

+ + {emptyFieldFilter ? ( +

+ One of your field filters has no value after the colon, so it was + ignored — add a value or remove it. +

+ ) : null} + + + + {isLoading ? ( +

+ {search.q ? 'Searching…' : 'Loading recent airplay…'} +

+ ) : results.length > 0 ? ( + <> + {errorBanner} +
+ + + + + + + + + + + + + {results.map((row) => ( + + ))} + +
ArtistTrackAlbumLabelPlayedDJ
+
+ + ) : error ? ( + errorBanner + ) : ( +

+ {pastEnd + ? 'No airplay on this page — you may have paged past the end of the results. Go back for more.' + : search.q + ? `No airplay found for “${search.q}”.` + : 'No airplay has been recorded yet.'} +

+ )} + + {showPager ? ( +
+ + + Page {search.page + 1} of {totalPages} + {typeof data?.total === 'number' + ? ` (${formatSearchTotal(data.total)} total plays)` + : null} + + +
+ ) : null} + + {atDepthLimit ? ( +

+ Showing as deep as this search can safely go. Narrow your search to + see more specific results. +

+ ) : null} +
+ + ) +} + +export default AirplaySearch diff --git a/pages/playlists/archive.jsx b/pages/playlists/archive.jsx new file mode 100644 index 000000000..2261dc389 --- /dev/null +++ b/pages/playlists/archive.jsx @@ -0,0 +1,369 @@ +import React, {useCallback, useEffect, useMemo, useState} from 'react' +import Head from 'next/head' +import {useRouter} from 'next/router' +import { + EARLIEST_ARCHIVE_DATE, + addDays, + clampWeekToArchive, + easternToday, + formatCalendarDate, + startOfWeek, +} from '../../lib/easternTime' +import { + describeNonTrackEntry, + fetchFlowsheetRange, + formatShowTime, + groupRangeByDay, + isTrack, +} from '../../lib/flowsheetRange' +import {getCachedWeek, setCachedWeek} from '../../lib/weekCache' + +/** + * Public historical playlist archive — the successor to + * `wxyc.info/playlists/radioWeek`, which dies at the 2026-08-31 tubafrenzy + * cutover. + * + * The week is chosen client-side and reflected in `?week=YYYY-MM-DD` (always a + * Monday) so a given week is linkable. It has to be the query string rather than + * a path segment: this site is a static export with no SSR and no + * `getStaticPaths`, and there is no sane way to pre-render a page per week back + * to 2004 over a 2.6-million-row table. Query parameters resolve after + * hydration, which costs nothing here because the data is fetched client-side + * anyway. + */ + +const DAYS_PER_WEEK = 7 + +/** + * Query-string week parameter, validated and clamped to the archive's extent. + * + * Returns null for anything that is not a real calendar date, so the caller can + * fall back to the current week. + */ +function weekFromQuery(value, today) { + if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) { + return null + } + // Reject a well-formed but nonexistent date (2026-02-31) before it becomes a + // silently-shifted window. + const [y, m, d] = value.split('-').map(Number) + const parsed = new Date(Date.UTC(y, m - 1, d)) + if ( + parsed.getUTCFullYear() !== y || + parsed.getUTCMonth() !== m - 1 || + parsed.getUTCDate() !== d + ) { + return null + } + return clampWeekToArchive(value, today) +} + +function EntryRow({entry}) { + if (!isTrack(entry)) { + const message = describeNonTrackEntry(entry) + return ( + + + {message} + + + ) + } + + return ( + + + {entry.rotation_bin || ''} + + {entry.artist_name} + {entry.track_title} + {entry.album_title} + + {entry.record_label} + {entry.request_flag ? ( + + (request) + + ) : null} + + + ) +} + +/** + * One show, as a collapsible block: the schedule line is always visible and the + * playlist opens in place. + * + * `
` rather than a state-driven toggle, and closed by default, for a + * reason that is measurement rather than taste. A week is 2,300–2,800 entries + * and 470–640 KB gzipped in production, so rendering every playlist expanded + * puts a couple of thousand table rows through layout on what is a phone-first + * listener surface. A closed `
` keeps its content in the DOM — so it is + * still searchable by the browser's find, and still assertable in tests — while + * the browser skips laying it out. + * + * Note what that does and does not buy: layout is skipped, but the rows are + * still constructed, so the week's ~14,000 DOM nodes are still built on mount. + * Deferring construction until first open would remove that too, at the cost of + * browser find and of the straightforward assertions below. If this page ever + * feels slow on a mid-tier phone, that is the next thing to try — the question + * is not settled by this comment. + * + * It also happens to be the shape of the page this replaces: `radioWeek` showed + * the week's schedule and drilled into `radioShow?radioShowID=…` for a playlist. + * Here the drill-in costs no navigation and no second request. + */ +function ShowBlock({show}) { + const airTime = formatShowTime(show) + const trackCount = show.entries.filter(isTrack).length + return ( +
+ + {show.djName} + {show.showName ? ( + — {show.showName} + ) : null} + {airTime ? ( + + {airTime} + + ) : null} + + {trackCount} {trackCount === 1 ? 'track' : 'tracks'} + + +
+ + + + + + + + + + + + {show.entries.map((entry) => ( + + ))} + +
RotationArtistSongReleaseLabel
+
+
+ ) +} + +function DayBlock({day, isFuture}) { + return ( +
+

+ {formatCalendarDate(day.date)} +

+ {day.shows.length === 0 ? ( + // A day that has not happened yet is not a gap in the archive, and + // saying "no playlists recorded" for it reads as lost data. Most of + // the current week's landing view is otherwise made of those. +

+ {isFuture ? 'Not yet aired.' : 'No playlists recorded for this day.'} +

+ ) : ( + day.shows.map((show, index) => ( + + )) + )} +
+ ) +} + +const ArchivePlaylists = () => { + const router = useRouter() + const today = useMemo(() => easternToday(), []) + const currentWeek = useMemo(() => startOfWeek(today), [today]) + + // Null until the router has resolved the query string. Initialising this to + // `currentWeek` instead would make "we have not read the URL yet" and "the + // URL asked for this week" the same value — and since both effects below run + // in the same commit once the router hydrates, the fetch would go out for the + // current week before the week-reading effect's state update lands. That is a + // wasted half-megabyte on every shared link, and gating the fetch on + // `router.isReady` alone does not prevent it. + const [week, setWeek] = useState(null) + const [days, setDays] = useState(null) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + // Bumped by Retry. A plain re-set of `week` would be a no-op — React bails + // out of a state update to an identical value, so the fetch effect would + // never re-run. + const [reloadToken, setReloadToken] = useState(0) + + // The query string is empty on the first render of a statically exported + // page and populated once the router is ready, so the week has to be read + // then rather than during initialisation. + useEffect(() => { + if (!router.isReady) return + setWeek(weekFromQuery(router.query.week, today) ?? currentWeek) + }, [router.isReady, router.query.week, currentWeek, today]) + + useEffect(() => { + if (week === null) return + + const cached = getCachedWeek(week) + if (cached) { + setDays(cached) + setError(null) + setIsLoading(false) + return + } + + const controller = new AbortController() + setIsLoading(true) + setError(null) + + fetchFlowsheetRange(week, DAYS_PER_WEEK, {signal: controller.signal}) + .then((range) => { + const grouped = groupRangeByDay(range, week, DAYS_PER_WEEK) + setCachedWeek(week, grouped, currentWeek) + setDays(grouped) + setIsLoading(false) + }) + .catch((err) => { + if (err.name === 'AbortError') return + setError(err.message || 'Could not load playlists.') + setDays(null) + setIsLoading(false) + }) + + return () => controller.abort() + }, [week, reloadToken, currentWeek]) + + const goToWeek = useCallback( + (nextWeek) => { + router.push(`/playlists/archive?week=${nextWeek}`, undefined, { + shallow: true, + }) + setWeek(nextWeek) + }, + [router] + ) + + // Standing in for the week until the router resolves one, so the controls can + // render disabled rather than absent. + const navWeek = week ?? currentWeek + const previousWeek = addDays(navWeek, -DAYS_PER_WEEK) + const nextWeek = addDays(navWeek, DAYS_PER_WEEK) + // The window is a fixed 7 days, so it can never exceed the endpoint's 8-day + // ceiling. What it can do is run off the ends of the archive, which is what + // these bound. + const canGoBack = + week !== null && previousWeek >= startOfWeek(EARLIEST_ARCHIVE_DATE) + const canGoForward = week !== null && nextWeek <= currentWeek + + const totalTracks = useMemo( + () => + (days ?? []).reduce( + (sum, day) => + sum + + day.shows.reduce( + (showSum, show) => showSum + show.entries.filter(isTrack).length, + 0 + ), + 0 + ), + [days] + ) + + return ( + <> + + Playlist Archive | WXYC + + + +
+

Playlist Archive

+

+ Every show WXYC has logged, week by week. Use the date picker to jump + anywhere back to 2004. +

+ +
+ + + +
+ + {week !== null ? ( +

+ Week of {formatCalendarDate(week, {weekday: undefined})} + {!isLoading && !error ? ( + + {totalTracks} {totalTracks === 1 ? 'track' : 'tracks'} + + ) : null} +

+ ) : null} + + {isLoading ? ( +

Loading playlists…

+ ) : error ? ( +
+

{error}

+ +
+ ) : days && days.every((day) => day.shows.length === 0) ? ( +

+ {week >= currentWeek + ? 'Nothing has aired yet this week.' + : 'No playlists were recorded this week.'} +

+ ) : ( + (days ?? []).map((day) => ( + today} /> + )) + )} +
+ + ) +} + +export default ArchivePlaylists