From 73d93c59c2f929a9fc01da8fd0bd5bade603dfb7 Mon Sep 17 00:00:00 2001 From: Ankur Datta <64993082+ankur-arch@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:24:50 +0530 Subject: [PATCH] site(changelog): list an entry under every area its tags name, multi-select filter Each entry was assigned one product area, so an entry tagged Compute and Postgres only showed under Compute, and the ORM filter listed 14 of the 47 ORM-tagged entries. Entries now carry every matching area and the filter pills toggle; an entry shows when it is in any selected area. Co-Authored-By: Claude Fable 5.1 --- apps/site/src/app/changelog/page.tsx | 8 ++-- .../components/sections/changelog-feed.tsx | 44 +++++++++++++------ apps/site/src/lib/changelog-meta.test.ts | 27 ++++++++++++ apps/site/src/lib/changelog-meta.ts | 36 ++++++++++----- 4 files changed, 88 insertions(+), 27 deletions(-) create mode 100644 apps/site/src/lib/changelog-meta.test.ts diff --git a/apps/site/src/app/changelog/page.tsx b/apps/site/src/app/changelog/page.tsx index f4814626354..b9045abd8f7 100644 --- a/apps/site/src/app/changelog/page.tsx +++ b/apps/site/src/app/changelog/page.tsx @@ -3,7 +3,7 @@ import { ChangelogFeed } from "@/components/sections/changelog-feed"; import { ChangelogHero } from "@/components/sections/changelog-hero"; import { CtaBurst } from "@/components/sections/cta-burst"; import { extractPreview, getChangelogEntries } from "@/lib/changelog"; -import { categoryForTags, isHighlight, type ChangelogEntry } from "@/lib/changelog-meta"; +import { categoriesForTags, isHighlight, type ChangelogEntry } from "@/lib/changelog-meta"; export const metadata = createPageMetadata({ title: "Changelog", @@ -16,16 +16,18 @@ export const metadata = createPageMetadata({ // /changelog — the redesign (spectral hero, product-area filter, colour-coded // timeline) driven by the live changelog content so it keeps auto-updating. // Entries come from the same source as the per-entry pages and the AI/GEO feed; -// each entry's product area is derived from its tags (see categoryForTags), and +// each entry's product areas are derived from its tags (see categoriesForTags), and // the feed is a client component, so we build the list here and pass it down. export default function ChangelogPage() { const entries: ChangelogEntry[] = getChangelogEntries().map((entry) => { const title = entry.frontmatter.headline ?? entry.frontmatter.title; + const categories = categoriesForTags(entry.frontmatter.tags); return { date: entry.frontmatter.date, title, description: entry.frontmatter.metaDescription ?? extractPreview(entry.content) ?? "", - category: categoryForTags(entry.frontmatter.tags), + category: categories[0], + categories, tags: entry.frontmatter.tags?.slice(0, 3) ?? [], highlight: isHighlight(title), href: `/changelog/${entry.slug}`, diff --git a/apps/site/src/components/sections/changelog-feed.tsx b/apps/site/src/components/sections/changelog-feed.tsx index 7a84e91c893..249195ded44 100644 --- a/apps/site/src/components/sections/changelog-feed.tsx +++ b/apps/site/src/components/sections/changelog-feed.tsx @@ -13,9 +13,15 @@ import { cn } from "@/lib/utils"; // The feed: a sticky category filter over a colour-coded release timeline. // One client component because the tabs and the list share a single piece of -// filter state — the same call data/blog-browser makes. Twelve entries is far -// too few to debounce or round-trip, so filtering is a plain array filter in -// the client and the state stays out of the URL (no deep links to share yet). +// filter state — the same call data/blog-browser makes. A few dozen entries is +// far too few to debounce or round-trip, so filtering is a plain array filter +// in the client and the state stays out of the URL (no deep links to share yet). +// +// The filter is multi-select: each pill toggles an area, and an entry shows +// when it belongs to any selected area. An entry belongs to every area its +// tags name (a Compute launch backed by Prisma Postgres is under both), so +// picking Postgres lists everything about Postgres, not only entries whose +// first tag is Postgres. No selection means every entry. // // The timeline is the redesign's answer to the live changelog's flat numbered // list: a vertical spine on desktop with a category-coloured node per entry, @@ -30,25 +36,35 @@ const FMT = new Intl.DateTimeFormat("en-US", { timeZone: "UTC", }); -type Filter = ChangelogCategory | "all"; - export function ChangelogFeed({ entries: allEntries }: { entries: ChangelogEntry[] }) { - const [filter, setFilter] = useState("all"); + // The selected areas. Empty means no filter, which the "All updates" pill + // both shows and restores. + const [selected, setSelected] = useState>(new Set()); + + const toggle = (id: ChangelogCategory) => + setSelected((prev) => { + const next = new Set(prev); + if (!next.delete(id)) next.add(id); + return next; + }); // Counts per category — totals over the whole feed that never change with the - // active filter. + // active filter. An entry counts towards every area it belongs to. const COUNTS = useMemo( () => CATEGORY_ORDER.reduce>((acc, id) => { - acc[id] = allEntries.filter((e) => e.category === id).length; + acc[id] = allEntries.filter((e) => e.categories.includes(id)).length; return acc; }, {}), [allEntries], ); const entries = useMemo( - () => (filter === "all" ? allEntries : allEntries.filter((e) => e.category === filter)), - [filter, allEntries], + () => + selected.size === 0 + ? allEntries + : allEntries.filter((e) => e.categories.some((id) => selected.has(id))), + [selected, allEntries], ); return ( @@ -60,8 +76,8 @@ export function ChangelogFeed({ entries: allEntries }: { entries: ChangelogEntry
setFilter("all")} + active={selected.size === 0} + onClick={() => setSelected(new Set())} label="All updates" count={allEntries.length} /> @@ -71,8 +87,8 @@ export function ChangelogFeed({ entries: allEntries }: { entries: ChangelogEntry {CATEGORY_ORDER.filter((id) => (COUNTS[id] ?? 0) > 0).map((id) => ( setFilter(id)} + active={selected.has(id)} + onClick={() => toggle(id)} label={CATEGORY_META[id].label} count={COUNTS[id] ?? 0} dot={CATEGORY_META[id].dot} diff --git a/apps/site/src/lib/changelog-meta.test.ts b/apps/site/src/lib/changelog-meta.test.ts new file mode 100644 index 00000000000..001ca4781de --- /dev/null +++ b/apps/site/src/lib/changelog-meta.test.ts @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { categoriesForTags, categoryForTags } from "./changelog-meta"; + +test("an entry belongs to every product area its tags name", () => { + assert.deepEqual(categoriesForTags(["Prisma", "Prisma Compute", "Prisma Postgres"]), [ + "compute", + "postgres", + ]); + assert.deepEqual(categoriesForTags(["Prisma ORM", "Prisma Postgres", "Prisma Studio"]), [ + "studio", + "postgres", + "orm", + ]); +}); + +test("the colouring area is the most specific one", () => { + assert.equal(categoryForTags(["Prisma ORM", "Prisma Postgres"]), "postgres"); + assert.equal(categoryForTags(["Prisma Postgres", "Prisma Compute"]), "compute"); + assert.equal(categoryForTags(["Accelerate", "Pulse"]), "postgres"); +}); + +test("tags that name no product are platform-wide", () => { + assert.deepEqual(categoriesForTags(["Prisma", "REST API"]), ["platform"]); + assert.deepEqual(categoriesForTags(), ["platform"]); + assert.equal(categoryForTags(["MCP"]), "platform"); +}); diff --git a/apps/site/src/lib/changelog-meta.ts b/apps/site/src/lib/changelog-meta.ts index e36dfff6fa6..b863ce20d66 100644 --- a/apps/site/src/lib/changelog-meta.ts +++ b/apps/site/src/lib/changelog-meta.ts @@ -11,7 +11,11 @@ export type ChangelogEntry = { date: string; title: string; description: string; + /** The area that colours the entry: the first of `categories`. */ category: ChangelogCategory; + /** Every product area the entry's tags name. An entry about a Compute feature + * backed by Prisma Postgres is in both, so both filters list it. */ + categories: ChangelogCategory[]; /** Short labels shown as pills under the entry. */ tags: string[]; /** Big launches get a featured treatment (larger card, accent). */ @@ -69,17 +73,29 @@ export const CATEGORY_ORDER: ChangelogCategory[] = [ "platform", ]; -// Derive a product area from an entry's tags. Entries carry free-form tags -// (e.g. "Prisma ORM", "Prisma Postgres", "Pulse"), not a single category, so we -// map the most specific product match first and fall back to platform-wide. -// Pulse/Accelerate are data-platform products, grouped under Postgres. +// Derive product areas from an entry's tags. Entries carry free-form tags +// (e.g. "Prisma ORM", "Prisma Postgres", "Pulse"), not a single category, and +// one entry often spans several products, so every area a tag names is kept. +// Pulse/Accelerate are data-platform products, grouped under Postgres. The +// result is ordered most specific first, and that first area colours the entry. +// An entry whose tags name no product is platform-wide. +const AREA_PATTERNS: [ChangelogCategory, RegExp][] = [ + ["studio", /studio/], + ["compute", /compute|composer/], + ["postgres", /postgres|accelerate|pulse|data platform/], + ["orm", /orm|client|migrate|schema/], +]; + +export function categoriesForTags(tags: string[] = []): ChangelogCategory[] { + const areas = AREA_PATTERNS.filter(([, pattern]) => + tags.some((tag) => pattern.test(tag.toLowerCase())), + ).map(([area]) => area); + return areas.length > 0 ? areas : ["platform"]; +} + +/** The one area that colours an entry: the most specific of its areas. */ export function categoryForTags(tags: string[] = []): ChangelogCategory { - const haystack = tags.join(" ").toLowerCase(); - if (/studio/.test(haystack)) return "studio"; - if (/compute/.test(haystack)) return "compute"; - if (/postgres|accelerate|pulse|data platform/.test(haystack)) return "postgres"; - if (/orm|client|migrate|schema/.test(haystack)) return "orm"; - return "platform"; + return categoriesForTags(tags)[0]; } // Big launches get the featured treatment — reserved for genuine GA / beta /