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
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,18 @@
<img width="1146" alt="image" src="https://github.com/haowens/website/assets/69762131/75c67f8a-f69b-4d54-934d-ce528c9c6964">
<p>[the development of this site is brought to you by the easily accessible assortment of photos of Adrianne Lenker that live on my desktop]</p>

<h3>Playlist archive (<code>/playlists/archive</code>)</h3>
<p>Public, week-at-a-time browse of every show WXYC has logged, back to at least November 2004. Successor to <code>wxyc.info/playlists/radioWeek</code>, which went dark at the 2026-09-07 tubafrenzy cutover (<a href="https://github.com/WXYC/wiki/issues/93">WXYC/wiki#93</a>).</p>
<ul>
<li><b>Data source</b>: Backend-Service <code>GET /flowsheet/range?start=&amp;end=</code> (epoch milliseconds, half-open <code>[start, end)</code>, 8-day ceiling). Contract lives in <code>wxyc-shared/api.yaml</code>. Override the origin at build time with <code>NEXT_PUBLIC_WXYC_API_URL</code>; it defaults to <code>https://api.wxyc.org</code>.</li>
<li><b>Client-side only.</b> This site is a static export, so there is no SSR and no <code>getStaticPaths</code> over a 2.6-million-row table. The week lives in <code>?week=YYYY-MM-DD</code> (always a Monday) so a week is linkable, and the fetch happens after hydration.</li>
<li><b>Weeks and days are Eastern</b>, not UTC and not browser-local &mdash; see <code>lib/easternTime.js</code>. Day and week bounds are computed by calendar arithmetic rather than by adding fixed millisecond offsets, because the spring-forward week is 167 hours and the fall-back week is 169.</li>
<li><b>Playlists are collapsed by default.</b> A week is 2,300&ndash;2,800 entries and 470&ndash;640&nbsp;KB gzipped, so each show is a <code>&lt;details&gt;</code>: the schedule is always visible and the rows only get laid out when opened. Note that the rows are still <i>built</i> &mdash; <code>&lt;details&gt;</code> skips layout, not DOM construction. Deferring construction to first open is the next lever if the page ever feels slow on a phone.</li>
<li><b>Entries are ordered by <code>play_order</code>, not by arrival.</b> The endpoint returns rows in <code>add_time</code> order, and the two disagree whenever a DJ enters a row after the fact &mdash; 88 times across 36 of the 54 shows in a sampled production week. Ordering by arrival strands retroactively-added hour breakpoints in the middle of a later hour.</li>
<li><b>Past weeks are cached in memory</b> (<code>lib/weekCache.js</code>). The endpoint sends no <code>Cache-Control</code>, so without this every Previous/Next click and every browser Back re-downloads half a megabyte. The week in progress is deliberately never cached.</li>
<li><b>The requested week is clamped</b> to <code>[2004-11-01, current week]</code>. <code>?week=</code> accepts anything, and <code>&lt;input type="date"&gt;</code> reports every keystroke of a typed year (editing to 2026 emits 0002, 0020, 0202 first), so without a clamp each of those becomes a live range query against a 2.6-million-row table.</li>
</ul>

<h3>Live playlist (<code>/playlist</code>)</h3>
<p>Public view of the most recent flowsheet entries, refreshing while the tab stays open. Successor to <code>wxyc.info/playlists/recent</code>, which goes dark at the 2026-09-07 tubafrenzy cutover (<a href="https://github.com/WXYC/wiki/issues/93">WXYC/wiki#93</a>).</p>
<ul>
Expand All @@ -20,6 +32,15 @@
<li>Each poll aborts any still-in-flight one before starting, so a slow earlier response landing after a faster later one cannot overwrite fresher data with stale.</li>
</ul>

<h3>Airplay search (<code>/airplay-search</code>)</h3>
<p>Public search over WXYC's full airplay history, back to at least November 2004. Successor to <code>wxyc.info/playlists/searchPlaylists</code>, which went dark at the 2026-09-07 tubafrenzy cutover (<a href="https://github.com/WXYC/wiki/issues/93">WXYC/wiki#93</a>).</p>
<ul>
<li><b>Data source</b>: Backend-Service <code>GET /flowsheet/search?q=&amp;page=&amp;limit=</code>. Contract lives in <code>wxyc-shared/api.yaml</code>. An empty or omitted <code>q</code> is a supported request, not an edge case &mdash; it is what makes the backend serve its most-recent-tracks default, which is this page's landing view. Same <code>NEXT_PUBLIC_WXYC_API_URL</code> build-time override as the archive page.</li>
<li><b>Client-side only</b>, for the same reason as the archive page: this site is a static export, so there is no SSR to fetch behind. The query and page number live in component state rather than the URL.</li>
<li><b><code>total</code> is a capped sentinel, not a count.</b> The backend's <code>COUNT_CAP</code> is 10000; any query matching more rows &mdash; including the empty-query landing view, against the ~2.6-million-row table &mdash; comes back with <code>total</code> pinned at exactly 10001. Render it raw and the default state of the page prints a false number to every visitor. <code>formatSearchTotal</code> shows anything past the cap as an open-ended <code>10,000+</code> instead.</li>
<li><b>Reachable pages are clamped well short of where the endpoint actually fails</b> (<code>MAX_REACHABLE_PAGE</code>, currently 99). Deep <code>OFFSET</code> pages on this endpoint approach and can exceed the backend's 5-second statement timeout, and the page where that happens is not a fixed number &mdash; it moves with database load, so no constant can safely sit right at the edge. The clamp is set at roughly a quarter of the smallest offset ever observed to survive, trading away some reachable depth for headroom against that drift, rather than being tuned close to a boundary that has already been seen to move. The actual fix is switching to the endpoint's cursor-paging mode, which is O(limit) instead of O(page&nbsp;&times;&nbsp;limit) and has no such cliff; raising this constant instead of making that switch just moves the same failure further out.</li>
</ul>

<h3>DNS &amp; hosting (do not "fix" the Pages domain warning)</h3>
<p><code>wxyc.org</code> and <code>www.wxyc.org</code> are proxied through Cloudflare (orange-cloud) rather than pointing their DNS records directly at GitHub Pages. Cloudflare forwards the <code>Host</code> header to GitHub Pages as the origin, so the site is still built and served by this repo's Pages deploy exactly as before &mdash; Cloudflare just sits in front of it (this is what lets us attach edge Workers to the apex).</p>
<p>Because the public A/AAAA records no longer resolve to GitHub's Pages IPs (they resolve to Cloudflare's edge), <b>repo Settings &rarr; Pages will show a warning that the custom domain's DNS does not point at GitHub Pages.</b> This warning is expected and cosmetic &mdash; the site works. <b>Do not change the DNS records back to GitHub's IPs, and do not clear the custom-domain field in Settings &rarr; Pages</b> (the custom domain lives only there; there is no <code>CNAME</code> 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 + <code>www</code> records from orange (Proxied) back to gray (DNS only) in the Cloudflare dashboard.</p>
Expand Down
313 changes: 313 additions & 0 deletions __tests__/airplaySearch.test.jsx
Original file line number Diff line number Diff line change
@@ -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(<AirplaySearch />)

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(<AirplaySearch />)

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(<AirplaySearch />)

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(<AirplaySearch />)
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(<AirplaySearch />)

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(<AirplaySearch />)

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(<AirplaySearch />)

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(<AirplaySearch />)
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(<AirplaySearch />)
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(<AirplaySearch />)
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(<AirplaySearch />)
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(<AirplaySearch />)
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(<AirplaySearch />)

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(<AirplaySearch />)
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(<AirplaySearch />)
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(<AirplaySearch />)
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()
})
})
})
Loading