Skip to content
Closed
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
8 changes: 5 additions & 3 deletions apps/site/src/app/changelog/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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}`,
Expand Down
44 changes: 30 additions & 14 deletions apps/site/src/components/sections/changelog-feed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<Filter>("all");
// The selected areas. Empty means no filter, which the "All updates" pill
// both shows and restores.
const [selected, setSelected] = useState<ReadonlySet<ChangelogCategory>>(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<Record<string, number>>((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 (
Expand All @@ -60,8 +76,8 @@ export function ChangelogFeed({ entries: allEntries }: { entries: ChangelogEntry
<div className="pointer-events-none absolute inset-0 bg-white/80 backdrop-blur-md [mask-image:linear-gradient(to_bottom,black_70%,transparent)]" />
<div className="relative flex flex-wrap items-center justify-center gap-2">
<FilterPill
active={filter === "all"}
onClick={() => setFilter("all")}
active={selected.size === 0}
onClick={() => setSelected(new Set())}
label="All updates"
count={allEntries.length}
/>
Expand All @@ -71,8 +87,8 @@ export function ChangelogFeed({ entries: allEntries }: { entries: ChangelogEntry
{CATEGORY_ORDER.filter((id) => (COUNTS[id] ?? 0) > 0).map((id) => (
<FilterPill
key={id}
active={filter === id}
onClick={() => setFilter(id)}
active={selected.has(id)}
onClick={() => toggle(id)}
label={CATEGORY_META[id].label}
count={COUNTS[id] ?? 0}
dot={CATEGORY_META[id].dot}
Expand Down
27 changes: 27 additions & 0 deletions apps/site/src/lib/changelog-meta.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
36 changes: 26 additions & 10 deletions apps/site/src/lib/changelog-meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down Expand Up @@ -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 /
Expand Down
Loading