From 6ffeae12a4bcc0d1a5f5f6a7aaaf86b10a040d8e Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 29 Oct 2026 05:51:34 +0000 Subject: [PATCH] feat(listings): Popular / Recent on every category, language and network (0.6.0) ?sort=recent orders a section by each show's newest episode (newest_pubdate index); pagers keep the sort. /podcasts gains a Just published grid. Recent lists, including the home page's, skip shows dated more than a day ahead: 47 shows are future-dated in production, one to the year 2205. Co-Authored-By: Claude Opus 5.5 --- package.json | 2 +- src/app/category/[category]/page.tsx | 23 ++++++++++----- src/app/globals.css | 1 + src/app/language/[lang]/page.tsx | 17 +++++++---- src/app/networks/[slug]/page.tsx | 16 ++++++---- src/app/page.tsx | 4 ++- src/app/podcasts/page.tsx | 19 +++++++++++- src/components/SortTabs.tsx | 25 ++++++++++++++++ src/lib/sort.ts | 44 ++++++++++++++++++++++++++++ test/sort.test.mjs | 32 ++++++++++++++++++++ 10 files changed, 161 insertions(+), 22 deletions(-) create mode 100644 src/components/SortTabs.tsx create mode 100644 src/lib/sort.ts create mode 100644 test/sort.test.mjs diff --git a/package.json b/package.json index 3c84ec0..a284fc3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "p0dcasters", - "version": "0.5.0", + "version": "0.6.0", "private": true, "type": "module", "scripts": { diff --git a/src/app/category/[category]/page.tsx b/src/app/category/[category]/page.tsx index f84d17d..17b8a8c 100644 --- a/src/app/category/[category]/page.tsx +++ b/src/app/category/[category]/page.tsx @@ -9,6 +9,8 @@ import { safeDecode, titleCase } from "@/lib/format"; import { listingJsonLd, jsonLdScript } from "@/lib/jsonld"; import Link from "next/link"; import TimeAgo from "@/components/TimeAgo"; +import SortTabs from "@/components/SortTabs"; +import { listHref, listSort, sortSql } from "@/lib/sort"; export const revalidate = 3600; const PER = 60; @@ -32,12 +34,15 @@ export default async function Category({ searchParams, }: { params: Promise<{ category: string }>; - searchParams: Promise<{ page?: string }>; + searchParams: Promise<{ page?: string; sort?: string }>; }) { const { category } = await params; - const { page } = await searchParams; + const { page, sort: sortParam } = await searchParams; const cat = safeDecode(category); const pg = Math.max(1, parseInt(page || "1", 10) || 1); + const sort = listSort(sortParam); + const by = sortSql(sort); + const path = `/category/${category}`; const total = await count("SELECT COUNT(*) AS n FROM podcasts WHERE category = ?", [cat]); if (!total) notFound(); @@ -54,8 +59,8 @@ export default async function Category({ ); const rows = await all( - "SELECT * FROM podcasts WHERE category = ? ORDER BY score DESC LIMIT ? OFFSET ?", - [cat, PER, (pg - 1) * PER], + `SELECT * FROM podcasts WHERE category = ?${by.where} ORDER BY ${by.order} LIMIT ? OFFSET ?`, + [cat, ...by.params, PER, (pg - 1) * PER], ); const pages = Math.ceil(total / PER); @@ -105,8 +110,9 @@ export default async function Category({ {facts?.newest ? : <>unknown}.
  • - Ordered by catalogue depth and longevity weighted by recency — not by raw - recency, and not by popularity. Why, and what gets in. + Popular orders by catalogue depth and longevity weighted by recency;{" "} + Recent puts the newest episode first.{" "} + Why, and what gets in.
  • Nothing is hosted here: each card links to the show, and the show links to its @@ -116,6 +122,7 @@ export default async function Category({ )} +
    {(split ? rows.slice(0, split) : rows).map((p) => ( @@ -133,7 +140,7 @@ export default async function Category({ {pages > 1 && (
    {pg > 1 && ( - + ← Previous )} @@ -141,7 +148,7 @@ export default async function Category({ {pg} of {pages} {pg < pages && ( - + Next → )} diff --git a/src/app/globals.css b/src/app/globals.css index afb922b..a2101be 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -154,6 +154,7 @@ h2.sec { .row .n { margin-left: auto; font-family: ui-monospace, Menlo, monospace; font-size: 12.5px; color: var(--muted); white-space: nowrap; } .pager { display: flex; gap: 10px; justify-content: center; padding: 30px 0; } +.sorttabs { display: flex; gap: 8px; margin: 0 0 14px; } .empty { padding: 46px 0; color: var(--muted); text-align: center; } .prose { max-width: 68ch; font-size: 16.5px; line-height: 1.72; } .prose h2 { font-size: 21px; margin: 32px 0 10px; letter-spacing: -0.01em; } diff --git a/src/app/language/[lang]/page.tsx b/src/app/language/[lang]/page.tsx index d574377..54508b5 100644 --- a/src/app/language/[lang]/page.tsx +++ b/src/app/language/[lang]/page.tsx @@ -9,6 +9,8 @@ import { languageName, normalizeLang, safeDecode } from "@/lib/format"; import { listingJsonLd, jsonLdScript } from "@/lib/jsonld"; import Link from "next/link"; import TimeAgo from "@/components/TimeAgo"; +import SortTabs from "@/components/SortTabs"; +import { listHref, listSort, sortSql } from "@/lib/sort"; export const revalidate = 3600; const PER = 60; @@ -35,11 +37,13 @@ export default async function Language({ searchParams, }: { params: Promise<{ lang: string }>; - searchParams: Promise<{ page?: string }>; + searchParams: Promise<{ page?: string; sort?: string }>; }) { const { lang } = await params; - const { page } = await searchParams; + const { page, sort: sortParam } = await searchParams; const pg = Math.max(1, parseInt(page || "1", 10) || 1); + const sort = listSort(sortParam); + const by = sortSql(sort); // One language, one URL. Anything that resolves to a code but isn't spelled // like it — /language/eng, /language/en_US, an old link to /language/franç — @@ -71,8 +75,8 @@ export default async function Language({ ); const rows = await all( - `SELECT * FROM podcasts WHERE lang_base IN (${holes}) ORDER BY score DESC LIMIT ? OFFSET ?`, - [...variants, PER, (pg - 1) * PER], + `SELECT * FROM podcasts WHERE lang_base IN (${holes})${by.where} ORDER BY ${by.order} LIMIT ? OFFSET ?`, + [...variants, ...by.params, PER, (pg - 1) * PER], ); const pages = Math.ceil(total / PER); @@ -133,6 +137,7 @@ export default async function Language({
    )} +
    {(split ? rows.slice(0, split) : rows).map((p) => ( @@ -150,7 +155,7 @@ export default async function Language({ {pages > 1 && (
    {pg > 1 && ( - + ← Previous )} @@ -158,7 +163,7 @@ export default async function Language({ {pg} of {pages} {pg < pages && ( - + Next → )} diff --git a/src/app/networks/[slug]/page.tsx b/src/app/networks/[slug]/page.tsx index 4a33e40..7bddf9f 100644 --- a/src/app/networks/[slug]/page.tsx +++ b/src/app/networks/[slug]/page.tsx @@ -8,6 +8,8 @@ import Card from "@/components/Card"; import Ad from "@/components/Ad"; import { AD_TEXT } from "@/lib/ads"; import { listingJsonLd, jsonLdScript } from "@/lib/jsonld"; +import SortTabs from "@/components/SortTabs"; +import { listHref, listSort, sortSql } from "@/lib/sort"; const { all, count } = cachedDb(3600); @@ -31,10 +33,12 @@ export default async function NetworkPage({ searchParams, }: { params: Promise<{ slug: string }>; - searchParams: Promise<{ page?: string }>; + searchParams: Promise<{ page?: string; sort?: string }>; }) { const { slug } = await params; - const { page } = await searchParams; + const { page, sort: sortParam } = await searchParams; + const sort = listSort(sortParam); + const by = sortSql(sort, "score DESC, newest_pubdate DESC"); const n = networkBySlug(slug); if (!n) notFound(); const pg = Math.max(1, parseInt(page || "1", 10) || 1); @@ -42,8 +46,9 @@ export default async function NetworkPage({ const total = await count(`SELECT COUNT(*) AS n FROM podcasts WHERE ${w.sql}`, w.params); const rows = total - ? await all(`SELECT * FROM podcasts WHERE ${w.sql} ORDER BY score DESC, newest_pubdate DESC LIMIT ? OFFSET ?`, [ + ? await all(`SELECT * FROM podcasts WHERE (${w.sql})${by.where} ORDER BY ${by.order} LIMIT ? OFFSET ?`, [ ...w.params, + ...by.params, PER, (pg - 1) * PER, ]) @@ -82,6 +87,7 @@ export default async function NetworkPage({

    )} +
    {rows.map((p) => ( @@ -90,7 +96,7 @@ export default async function NetworkPage({ {pages > 1 && (
    {pg > 1 && ( - + ← Previous )} @@ -98,7 +104,7 @@ export default async function NetworkPage({ {pg} of {pages} {pg < pages && ( - + Next → )} diff --git a/src/app/page.tsx b/src/app/page.tsx index 09a5080..2c38771 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -6,6 +6,7 @@ import type { Podcast } from "@/lib/db"; import Card from "@/components/Card"; import Art from "@/components/Art"; import Link from "next/link"; +import { recentCeiling } from "@/lib/sort"; const { all, count, languageBuckets } = cachedDb(300); @@ -27,7 +28,8 @@ export default async function Home() { count("SELECT COUNT(DISTINCT host) AS n FROM podcasts"), all("SELECT * FROM podcasts ORDER BY score DESC LIMIT 12"), all( - "SELECT * FROM podcasts WHERE episode_count >= 8 ORDER BY newest_pubdate DESC LIMIT 10", + "SELECT * FROM podcasts WHERE episode_count >= 8 AND newest_pubdate <= ? ORDER BY newest_pubdate DESC LIMIT 10", + [recentCeiling()], ), all( "SELECT category, COUNT(*) AS n FROM podcasts WHERE category IS NOT NULL GROUP BY category ORDER BY n DESC LIMIT 18", diff --git a/src/app/podcasts/page.tsx b/src/app/podcasts/page.tsx index d63e380..b12b024 100644 --- a/src/app/podcasts/page.tsx +++ b/src/app/podcasts/page.tsx @@ -7,6 +7,7 @@ import Card from "@/components/Card"; import Ad from "@/components/Ad"; import { AD_TEXT } from "@/lib/ads"; import { listingJsonLd, jsonLdScript } from "@/lib/jsonld"; +import { recentCeiling } from "@/lib/sort"; const { all, count, languageBuckets } = cachedDb(300); @@ -26,10 +27,14 @@ export const metadata: Metadata = { * page only points into the catalogue. */ export default async function Podcasts() { - const [total, hosts, featured, cats, langs] = await Promise.all([ + const [total, hosts, featured, fresh, cats, langs] = await Promise.all([ count("SELECT COUNT(*) AS n FROM podcasts"), count("SELECT COUNT(DISTINCT host) AS n FROM podcasts"), all("SELECT * FROM podcasts ORDER BY score DESC LIMIT 24"), + all( + "SELECT * FROM podcasts WHERE episode_count >= 8 AND newest_pubdate <= ? ORDER BY newest_pubdate DESC LIMIT 12", + [recentCeiling()], + ), all<{ category: string; n: number }>( "SELECT category, COUNT(*) AS n FROM podcasts WHERE category IS NOT NULL GROUP BY category ORDER BY n DESC LIMIT 24", ), @@ -94,6 +99,18 @@ export default async function Podcasts() { +
    +

    Just published

    +
    + {fresh.map((p) => ( + + ))} +
    +

    + Every subject, language and network has its own: pick one above and choose Recent. +

    +
    +

    Deep catalogues, still publishing

    diff --git a/src/components/SortTabs.tsx b/src/components/SortTabs.tsx new file mode 100644 index 0000000..369d0ca --- /dev/null +++ b/src/components/SortTabs.tsx @@ -0,0 +1,25 @@ +import Link from "next/link"; +import { listHref, type ListSort } from "@/lib/sort"; + +/** Popular / Recent switch above a listing. Changing it goes back to page 1. */ +export default function SortTabs({ path, sort }: { path: string; sort: ListSort }) { + return ( + + ); +} diff --git a/src/lib/sort.ts b/src/lib/sort.ts new file mode 100644 index 0000000..6d49b68 --- /dev/null +++ b/src/lib/sort.ts @@ -0,0 +1,44 @@ +/** + * How a listing is ordered: "popular" (the score, the default) or "recent" + * (newest episode first). Every section page takes it as ?sort=recent, so a + * reader can see what just came out in one category, language or network + * rather than only on the home page. + */ + +export type ListSort = "popular" | "recent"; + +export function listSort(raw: string | string[] | undefined): ListSort { + return raw === "recent" ? "recent" : "popular"; +} + +/** + * A show whose newest episode is dated more than a day ahead (one scheduled for + * next month, or a feed that stamps next year on everything) would otherwise sit + * on top of "recent" until that date. It still lists under "popular"; it joins + * "recent" when the date arrives. A day of slack covers clock skew. + */ +export const FUTURE_SLACK_SECONDS = 86_400; + +export function recentCeiling(now = Date.now()): number { + return Math.floor(now / 1000) + FUTURE_SLACK_SECONDS; +} + +/** + * The extra WHERE and ORDER BY for a sort. Recent walks the newest_pubdate + * index; popular keeps the score order each page already used, plus whatever + * tiebreak the page passes. + */ +export function sortSql(sort: ListSort, popularOrder = "score DESC"): { where: string; order: string; params: number[] } { + return sort === "recent" + ? { where: " AND newest_pubdate <= ?", order: "newest_pubdate DESC", params: [recentCeiling()] } + : { where: "", order: popularOrder, params: [] }; +} + +/** A link to one page of a listing, keeping the sort (popular is the bare URL). */ +export function listHref(path: string, sort: ListSort, page = 1): string { + const q = new URLSearchParams(); + if (sort === "recent") q.set("sort", "recent"); + if (page > 1) q.set("page", String(page)); + const s = q.toString(); + return s ? `${path}?${s}` : path; +} diff --git a/test/sort.test.mjs b/test/sort.test.mjs new file mode 100644 index 0000000..4f7d75f --- /dev/null +++ b/test/sort.test.mjs @@ -0,0 +1,32 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { FUTURE_SLACK_SECONDS, listHref, listSort, recentCeiling, sortSql } from '../src/lib/sort.ts'; + +test('only ?sort=recent switches; anything else is popular', () => { + assert.equal(listSort('recent'), 'recent'); + assert.equal(listSort(undefined), 'popular'); + assert.equal(listSort('newest'), 'popular'); + assert.equal(listSort(['recent', 'x']), 'popular'); +}); + +test('recent orders by the newest episode and stops a day ahead', () => { + const now = Date.UTC(2026, 9, 29); + const by = sortSql('recent'); + assert.equal(by.order, 'newest_pubdate DESC'); + assert.equal(by.where, ' AND newest_pubdate <= ?'); + assert.equal(recentCeiling(now), now / 1000 + FUTURE_SLACK_SECONDS); + // A show whose next episode is dated next month is not "recent" yet. + assert.ok(Date.UTC(2026, 10, 25) / 1000 > recentCeiling(now)); +}); + +test('popular keeps the page\'s own order and adds no filter', () => { + assert.deepEqual(sortSql('popular'), { where: '', order: 'score DESC', params: [] }); + assert.equal(sortSql('popular', 'score DESC, newest_pubdate DESC').order, 'score DESC, newest_pubdate DESC'); +}); + +test('links keep the sort across pages; popular page 1 is the bare URL', () => { + assert.equal(listHref('/category/comedy', 'popular'), '/category/comedy'); + assert.equal(listHref('/category/comedy', 'popular', 2), '/category/comedy?page=2'); + assert.equal(listHref('/category/comedy', 'recent'), '/category/comedy?sort=recent'); + assert.equal(listHref('/category/comedy', 'recent', 3), '/category/comedy?sort=recent&page=3'); +});