A statically generated site for WXYC. Built using the frontend framework React, NextJs as a static site generator and TinaCMS as a Git-based content management system. Styled using TailwindCSS, with help from the MUI Joy UI component library on implementing breadcrumbs and Headless UI on implementing dropdown menus. Deployed to Github Pages.
Supports content management for the radio station's blog and for an archive of the radio station's specialty shows and live events. Very much a work in progress!
[the development of this site is brought to you by the easily accessible assortment of photos of Adrianne Lenker that live on my desktop]
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 goes dark at the 2026-08-31 tubafrenzy cutover (WXYC/wiki#93).
- Data source: Backend-Service
GET /flowsheet/range?start=&end=(epoch milliseconds, half-open[start, end), 8-day ceiling). Contract lives inwxyc-shared/api.yaml. Override the origin at build time withNEXT_PUBLIC_WXYC_API_URL; it defaults tohttps://api.wxyc.org. - Client-side only. This site is a static export, so there is no SSR and no
getStaticPathsover a 2.6-million-row table. The week lives in?week=YYYY-MM-DD(always a Monday) so a week is linkable, and the fetch happens after hydration. - Weeks and days are Eastern, not UTC and not browser-local — see
lib/easternTime.js. 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. - Playlists are collapsed by default. A week is 2,300–2,800 entries and 470–640 KB gzipped, so each show is a
<details>: the schedule is always visible and the rows only get laid out when opened. Note that the rows are still built —<details>skips layout, not DOM construction. Deferring construction to first open is the next lever if the page ever feels slow on a phone. - Entries are ordered by
play_order, not by arrival. The endpoint returns rows inadd_timeorder, and the two disagree whenever a DJ enters a row after the fact — 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. - Past weeks are cached in memory (
lib/weekCache.js). The endpoint sends noCache-Control, so without this every Previous/Next click and every browser Back re-downloads half a megabyte. The week in progress is deliberately never cached. - The requested week is clamped to
[2004-11-01, current week].?week=accepts anything, and<input type="date">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.
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-08-31 tubafrenzy cutover (WXYC/wiki#93).
- Data source: Backend-Service
GET /flowsheet?page=0&limit=50. Distinct from the archive's/flowsheet/range: this endpoint returns one flatentriesarray plus pagination metadata and the currently on-air DJ, rather than a separateshowsarray, so there is no grouping step. Contract lives inwxyc-shared/api.yaml. Override the origin at build time withNEXT_PUBLIC_WXYC_API_URL; it defaults tohttps://api.wxyc.org. - Client-side only, for the same reason as the archive: a static export has no SSR and no server to poll from, so the fetch happens in the browser, on an interval, for as long as the tab stays open.
- Entries are ordered by
show_idthenplay_order, both descending, theniddescending — most recent show first, and within a show by the DJ's stated air order rather than the order the endpoint returned them in (insertion order). The endpoint returns rows newest-inserted-first, and a DJ who enters a row after the fact gets a row whoseidsays "just now" but whoseplay_ordersays otherwise; rendering in insertion order strands it in the wrong place. Theidtie-break exists becauseplay_ordercan legitimately collide — the tubafrenzy webhook and the dj-site live-insert path assign it independently with no per-show UNIQUE constraint — matching how Backend's owngetEntriesByShowbreaks the same tie. SeecompareEntriesByAirOrderDescinlib/flowsheetRange.js, shared with the archive page's ownplay_orderrule. - A failed poll keeps the last good playlist on screen rather than replacing it with an error: the table is still true, just stale. A "Last updated HH:MM — couldn't refresh" notice and a Retry button appear alongside it so the page is never silently frozen; only a failure on the very first load (nothing to show yet) replaces the page with a full error state.
- Polling pauses while the tab is hidden — checked both at mount and on every subsequent visibility change, so a tab opened directly in the background never fetches at all until it is actually shown — and catches up with one fetch when it becomes visible again, rather than continuing to poll a tab nobody is looking at. The response is ~51 KB with
Cache-Control: no-cache, and a tab left open for a workday would otherwise issue roughly 1,440 requests for ~73 MB. - 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.
Public search over WXYC's full airplay history, back to at least November 2004. Successor to wxyc.info/playlists/searchPlaylists, which goes dark at the 2026-08-31 tubafrenzy cutover (WXYC/wiki#93).
- Data source: Backend-Service
GET /flowsheet/search?q=&page=&limit=. Contract lives inwxyc-shared/api.yaml. An empty or omittedqis a supported request, not an edge case — it is what makes the backend serve its most-recent-tracks default, which is this page's landing view. SameNEXT_PUBLIC_WXYC_API_URLbuild-time override as the archive page. - Client-side only, 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.
totalis a capped sentinel, not a count. The backend'sCOUNT_CAPis 10000; any query matching more rows — including the empty-query landing view, against the ~2.6-million-row table — comes back withtotalpinned at exactly 10001. Render it raw and the default state of the page prints a false number to every visitor.formatSearchTotalshows anything past the cap as an open-ended10,000+instead.- Reachable pages are clamped well short of where the endpoint actually fails (
MAX_REACHABLE_PAGE, currently 99). DeepOFFSETpages 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 — 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 × limit) and has no such cliff; raising this constant instead of making that switch just moves the same failure further out.
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.
- Setlist component for blog posts
- Contact form and instagram integrations (implemented in development, need to figure out how to host)
- Search bar for archive and blog
- Utilize Tina blocks to make more components of site editable by admin
- Embed audio player in layout so music can continue as you browse the site