diff --git a/src/components/dashboard/whats-new-tile.tsx b/src/components/dashboard/whats-new-tile.tsx
index d8ddc7f6..00565fcf 100644
--- a/src/components/dashboard/whats-new-tile.tsx
+++ b/src/components/dashboard/whats-new-tile.tsx
@@ -3,6 +3,7 @@ import Link from "next/link";
import { Sparkles } from "lucide-react";
import { trpc } from "@/lib/trpc";
import { cn } from "@/lib/utils";
+import { hasUnseenChangelog } from "@/lib/changelog";
/**
* What's New tile — a compact list of the most recent CHANGELOG
@@ -18,17 +19,23 @@ import { cn } from "@/lib/utils";
export function WhatsNewTile({
slug,
seenAt = null,
+ seenRelease = null,
}: {
slug: string;
/** User's `changelogSeenAt`. When the latest dated entry is newer (or
* this is null), an "unseen" dot shows until they open /whats-new. */
seenAt?: string | Date | null;
+ seenRelease?: string | null;
}) {
const { data, isLoading } = trpc.system.changelog.useQuery({ limit: 5 });
// Newest dated entry vs. last-seen timestamp → "unseen" indicator.
- const latestDate = data?.entries.find((e) => e.date)?.date ?? null;
- const hasUnseen = !!latestDate && (!seenAt || new Date(latestDate) > new Date(seenAt));
+ const latest = data?.entries.find((entry) => entry.date) ?? null;
+ // Release identity is authoritative. A null value after this migration is
+ // intentionally unseen once, including for users who viewed an earlier
+ // release on the same day; the timestamp remains a legacy fallback only
+ // for old changelog entries without a release identity.
+ const hasUnseen = hasUnseenChangelog(latest, seenRelease, seenAt);
if (isLoading || !data) {
return (
@@ -84,9 +91,9 @@ export function WhatsNewTile({
{rest.length > 0 && (
- {rest.slice(0, 3).map((entry, i) => (
+ {rest.slice(0, 3).map((entry) => (
- {
- markSeen.mutate(undefined, {
- onSuccess: () => {
- void utils.system.changelog.invalidate();
+ if (!latestReleaseId) return;
+ markSeen.mutate(
+ { releaseId: latestReleaseId },
+ {
+ onSuccess: () => {
+ void utils.system.changelog.invalidate();
+ void utils.user.me.invalidate();
+ },
},
- });
+ );
// once on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
+ }, [latestReleaseId]);
const entries = data?.entries ?? [];
@@ -44,7 +55,10 @@ export function WhatsNewContent() {
) : entries.length === 0 ? (
-
{entries.map((entry, i) => {
@@ -52,13 +66,16 @@ export function WhatsNewContent() {
// name (version + title) lives after the em-dash; the bracket date
// is the timestamp. Bare `## [YYYY-MM-DD]` falls back to the date.
const dash = entry.heading?.indexOf(" — ") ?? -1;
- const title = dash >= 0 ? entry.heading.slice(dash + 3).trim() : entry.date ?? entry.version;
+ const title =
+ dash >= 0 ? entry.heading.slice(dash + 3).trim() : (entry.date ?? entry.version);
return (
-
+
{title}
{entry.date && title !== entry.date && (
- {entry.date}
+
+ {entry.date}
+
)}
{i === 0 && (
@@ -73,14 +90,22 @@ export function WhatsNewContent() {
const Ico = g.icon;
return (
-
+
{g.label}
{items.map((it, j) => (
-
-
+
{it.text}
))}
diff --git a/src/lib/changelog.ts b/src/lib/changelog.ts
new file mode 100644
index 00000000..939fb32c
--- /dev/null
+++ b/src/lib/changelog.ts
@@ -0,0 +1,9 @@
+export function hasUnseenChangelog(
+ latest: { id: string; release: string | null; date: string | null } | null,
+ seenRelease: string | null,
+ seenAt: string | Date | null,
+): boolean {
+ if (!latest) return false;
+ if (latest.release) return latest.id !== seenRelease;
+ return !!latest.date && (!seenAt || new Date(latest.date) > new Date(seenAt));
+}
diff --git a/src/server/routers/system.ts b/src/server/routers/system.ts
index b41df14b..479437f2 100644
--- a/src/server/routers/system.ts
+++ b/src/server/routers/system.ts
@@ -4,6 +4,7 @@ import { promises as fs } from "node:fs";
import path from "node:path";
import { router, protectedProcedure } from "@/server/trpc";
import { forgeBuildIdentity } from "@/server/build-info";
+import { parseChangelog, type ChangelogEntry } from "@/server/services/changelog-parser";
/**
* System-wide read-only data — currently just the parsed CHANGELOG.md
@@ -17,22 +18,6 @@ import { forgeBuildIdentity } from "@/server/build-info";
* changes on disk, e.g. a hot-reload during dev). No DB column.
*/
-interface ChangelogItem {
- type: "added" | "changed" | "fixed" | "removed";
- text: string;
-}
-
-interface ChangelogEntry {
- version: string;
- date: string | null;
- /** Original `## [version] — heading-tail` so the page can show
- * the full release name as a heading. */
- heading: string;
- items: ChangelogItem[];
- /** Raw markdown body for `whats-new/page.tsx` to render. */
- raw: string;
-}
-
interface CachedChangelog {
mtimeMs: number;
entries: ChangelogEntry[];
@@ -71,74 +56,6 @@ async function readChangelog(): Promise {
* raw section is preserved on the entry for the whats-new page to
* render with markdown.
*/
-function parseChangelog(raw: string): ChangelogEntry[] {
- const lines = raw.split(/\r?\n/);
- const entries: ChangelogEntry[] = [];
- let cur: ChangelogEntry | null = null;
- let curRaw: string[] = [];
- let curSubtype: ChangelogItem["type"] | null = null;
-
- const flush = () => {
- if (cur) {
- cur.raw = curRaw.join("\n").trim();
- entries.push(cur);
- }
- };
-
- for (const line of lines) {
- if (line.startsWith("## ")) {
- const heading = line.slice(3).trim();
- const bracketMatch = heading.match(/^\[([^\]]+)\](?:\s+[—-]\s+(.+))?$/);
- const plainMatch = bracketMatch ? null : heading.match(/^(.+?)(?:\s+[—-]\s+(.+))?$/);
- const version = (bracketMatch?.[1] ?? plainMatch?.[1])?.trim();
- if (!version) continue;
- flush();
- const tail = (bracketMatch?.[2] ?? plainMatch?.[2])?.trim() ?? "";
- // Heuristic: a leading ISO date in version (e.g. `2026-05-04`)
- // counts as the date; otherwise keep null.
- const dateMatch = version.match(/^\d{4}-\d{2}-\d{2}$/);
- cur = {
- version,
- date: dateMatch ? version : null,
- heading: tail ? `${version} — ${tail}` : version,
- items: [],
- raw: "",
- };
- curRaw = [];
- curSubtype = null;
- continue;
- }
-
- if (!cur) continue;
- curRaw.push(line);
-
- const subMatch = line.match(/^###\s+(Added|Changed|Fixed|Removed)\b/i);
- if (subMatch) {
- curSubtype = subMatch[1].toLowerCase() as ChangelogItem["type"];
- continue;
- }
-
- const bulletMatch = line.match(/^\s*-\s+(.+?)\s*$/);
- if (bulletMatch && curSubtype) {
- // First-line text only; if the bullet wraps to subsequent
- // lines we'll lose the wrap detail — that's fine for the rail
- // (the whats-new page renders the raw body anyway).
- const text = bulletMatch[1]
- // Strip simple emphasis so the rail stays compact.
- .replace(/\*\*([^*]+)\*\*/g, "$1")
- .replace(/`([^`]+)`/g, "$1");
- cur.items.push({ type: curSubtype, text });
- }
- }
-
- flush();
- // Drop the "Unreleased" section from the structured entries: it's not a
- // shipped release, so it shouldn't head the What's New rail or be counted
- // as the latest version (buildInfo.release already filters by date). Its
- // content still appears in the full raw body on the /whats-new page.
- return entries.filter((e) => e.version.toLowerCase() !== "unreleased");
-}
-
export const systemRouter = router({
changelog: protectedProcedure
.input(
@@ -165,15 +82,15 @@ export const systemRouter = router({
/**
* Build identity — the precise "what's running" anchor for support + "is my
* fix live?". `gitSha` + `buildTime` are baked at `docker compose build`
- * (deploy ritual); `release` is the latest curated CHANGELOG date (the
- * human-facing version); `version` is the coarse package.json major.
+ * (deploy ritual); `release` is the latest curated CHANGELOG SemVer;
+ * `version` is the package.json version.
*/
buildInfo: protectedProcedure.query(async () => {
const cache = await readChangelog();
const build = await forgeBuildIdentity();
return {
...build,
- release: cache.entries.find((e) => e.date)?.version ?? null,
+ release: cache.entries.find((e) => e.date)?.release ?? null,
};
}),
});
diff --git a/src/server/routers/user.ts b/src/server/routers/user.ts
index 118aa047..c7a961d1 100644
--- a/src/server/routers/user.ts
+++ b/src/server/routers/user.ts
@@ -33,6 +33,7 @@ const ME_SELECT = {
dashboardView: true,
dashboardPrefs: true,
changelogSeenAt: true,
+ changelogSeenRelease: true,
onboardingDismissedAt: true,
onboardingSkippedSteps: true,
pomodoroEnabled: true,
@@ -112,14 +113,16 @@ export const userRouter = router({
});
}),
- /** Stamp the changelog as seen now (clears the What's New "unseen" dot). */
- markChangelogSeen: protectedProcedure.mutation(async ({ ctx }) => {
- return ctx.db.user.update({
- where: { id: ctx.session.user.id },
- data: { changelogSeenAt: new Date() },
- select: ME_SELECT,
- });
- }),
+ /** Persist the exact release viewed so same-day releases remain distinguishable. */
+ markChangelogSeen: protectedProcedure
+ .input(z.object({ releaseId: z.string().trim().min(1).max(128) }))
+ .mutation(async ({ ctx, input }) => {
+ return ctx.db.user.update({
+ where: { id: ctx.session.user.id },
+ data: { changelogSeenAt: new Date(), changelogSeenRelease: input.releaseId },
+ select: ME_SELECT,
+ });
+ }),
/**
* Per-user Mission Control preferences. Default-tab can be either
diff --git a/src/server/services/changelog-parser.ts b/src/server/services/changelog-parser.ts
new file mode 100644
index 00000000..ab1a09a9
--- /dev/null
+++ b/src/server/services/changelog-parser.ts
@@ -0,0 +1,76 @@
+export interface ChangelogItem {
+ type: "added" | "changed" | "fixed" | "removed";
+ text: string;
+}
+
+export interface ChangelogEntry {
+ /** Stable, release-unique identity used for persistence and React keys. */
+ id: string;
+ version: string;
+ date: string | null;
+ release: string | null;
+ heading: string;
+ items: ChangelogItem[];
+ raw: string;
+}
+
+/** Parse the repository's Keep-a-Changelog release sections. */
+export function parseChangelog(raw: string): ChangelogEntry[] {
+ const lines = raw.split(/\r?\n/);
+ const entries: ChangelogEntry[] = [];
+ let cur: ChangelogEntry | null = null;
+ let curRaw: string[] = [];
+ let curSubtype: ChangelogItem["type"] | null = null;
+
+ const flush = () => {
+ if (cur) {
+ cur.raw = curRaw.join("\n").trim();
+ entries.push(cur);
+ }
+ };
+
+ for (const line of lines) {
+ if (line.startsWith("## ")) {
+ const heading = line.slice(3).trim();
+ const bracketMatch = heading.match(/^\[([^\]]+)\](?:\s+[—-]\s+(.+))?$/);
+ const plainMatch = bracketMatch ? null : heading.match(/^(.+?)(?:\s+[—-]\s+(.+))?$/);
+ const version = (bracketMatch?.[1] ?? plainMatch?.[1])?.trim();
+ if (!version) continue;
+ flush();
+ const tail = (bracketMatch?.[2] ?? plainMatch?.[2])?.trim() ?? "";
+ const date = /^\d{4}-\d{2}-\d{2}$/.test(version) ? version : null;
+ const release = tail.match(/\bv\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?\b/)?.[0] ?? null;
+ const fullHeading = tail ? `${version} — ${tail}` : version;
+ cur = {
+ id: release ?? `${version}:${fullHeading}`,
+ version,
+ date,
+ release,
+ heading: fullHeading,
+ items: [],
+ raw: "",
+ };
+ curRaw = [];
+ curSubtype = null;
+ continue;
+ }
+
+ if (!cur) continue;
+ curRaw.push(line);
+ const subMatch = line.match(/^###\s+(Added|Changed|Fixed|Removed)\b/i);
+ if (subMatch) {
+ curSubtype = subMatch[1].toLowerCase() as ChangelogItem["type"];
+ continue;
+ }
+ const bulletMatch = line.match(/^\s*-\s+(.+?)\s*$/);
+ if (bulletMatch && curSubtype) {
+ cur.items.push({
+ type: curSubtype,
+ text: bulletMatch[1].replace(/\*\*([^*]+)\*\*/g, "$1").replace(/`([^`]+)`/g, "$1"),
+ });
+ }
+ }
+
+ flush();
+ return entries.filter((entry) => entry.version.toLowerCase() !== "unreleased");
+}
diff --git a/tests/unit/changelog-parser.test.ts b/tests/unit/changelog-parser.test.ts
index 0792c413..262d75a9 100644
--- a/tests/unit/changelog-parser.test.ts
+++ b/tests/unit/changelog-parser.test.ts
@@ -1,80 +1,35 @@
import { describe, expect, it } from "vitest";
import { promises as fs } from "node:fs";
import path from "node:path";
-
-/**
- * Smoke test for the CHANGELOG.md parser shipped with the
- * `system.changelog` tRPC proc. We exercise the parser against the
- * real repo file so a malformed entry surfaces early; the proc
- * itself is tested by integration tests.
- *
- * The parser is intentionally not exported from the module (it lives
- * inside `system.ts` next to the cache), so we re-implement the
- * parse here in a tiny form-pinned shim. If you change the parser
- * shape, mirror the change here — the goal is to lock in the
- * Keep-a-Changelog format we expect, not test the exact same code
- * twice.
- */
-
-interface Item {
- type: "added" | "changed" | "fixed" | "removed";
- text: string;
-}
-
-function parse(raw: string): Array<{
- version: string;
- items: Item[];
-}> {
- const lines = raw.split(/\r?\n/);
- type Entry = { version: string; items: Item[] };
- const entries: Entry[] = [];
- let cur: Entry | null = null;
- let curSubtype: Item["type"] | null = null;
- for (const line of lines) {
- if (line.startsWith("## ")) {
- const heading = line.slice(3).trim();
- const bracketMatch = heading.match(/^\[([^\]]+)\](?:\s+[—-]\s+(.+))?$/);
- const plainMatch = bracketMatch ? null : heading.match(/^(.+?)(?:\s+[—-]\s+(.+))?$/);
- const version = (bracketMatch?.[1] ?? plainMatch?.[1])?.trim();
- if (!version) continue;
- cur = { version, items: [] };
- entries.push(cur);
- curSubtype = null;
- continue;
- }
- if (!cur) continue;
- const sub = line.match(/^###\s+(Added|Changed|Fixed|Removed)\b/i);
- if (sub) {
- curSubtype = sub[1].toLowerCase() as Item["type"];
- continue;
- }
- const bullet = line.match(/^\s*-\s+(.+?)\s*$/);
- if (bullet && curSubtype) {
- cur.items.push({
- type: curSubtype,
- text: bullet[1].replace(/\*\*([^*]+)\*\*/g, "$1").replace(/`([^`]+)`/g, "$1"),
- });
- }
- }
- // Mirror system.ts: the "Unreleased" section is not a shipped release, so it
- // never heads the structured entries (its body still shows in the raw page).
- return entries.filter((e) => e.version.toLowerCase() !== "unreleased");
-}
+import { parseChangelog } from "@/server/services/changelog-parser";
+import { hasUnseenChangelog } from "@/lib/changelog";
describe("CHANGELOG.md", () => {
- it("parses real repo file into at least one entry with structured items", async () => {
- const file = path.join(process.cwd(), "CHANGELOG.md");
- const raw = await fs.readFile(file, "utf8");
- const entries = parse(raw);
+ it("parses the real repo file into structured, uniquely identified releases", async () => {
+ const raw = await fs.readFile(path.join(process.cwd(), "CHANGELOG.md"), "utf8");
+ const entries = parseChangelog(raw);
expect(entries.length).toBeGreaterThan(0);
expect(entries[0]?.version).toMatch(/^\d{4}-\d{2}-\d{2}$/);
- const totalItems = entries.reduce((n, e) => n + e.items.length, 0);
- expect(totalItems).toBeGreaterThan(0);
- // Every parsed item has a known type bucket.
- for (const e of entries) {
- for (const item of e.items) {
- expect(["added", "changed", "fixed", "removed"]).toContain(item.type);
- }
- }
+ expect(entries[0]?.release).toMatch(/^v\d+\.\d+\.\d+/);
+ expect(new Set(entries.map((entry) => entry.id)).size).toBe(entries.length);
+ expect(entries.reduce((count, entry) => count + entry.items.length, 0)).toBeGreaterThan(0);
+ });
+
+ it("distinguishes two releases shipped on the same date", () => {
+ const entries = parseChangelog(`
+## [2026-07-16] — v0.26.0 · Newer
+### Fixed
+- New fix
+
+## [2026-07-16] — v0.25.0 · Older
+### Added
+- Old feature
+`);
+ expect(entries.map((entry) => entry.id)).toEqual(["v0.26.0", "v0.25.0"]);
+ expect(entries[0]?.date).toBe(entries[1]?.date);
+ expect(hasUnseenChangelog(entries[0]!, entries[1]!.id, new Date("2026-07-16T20:00:00Z"))).toBe(
+ true,
+ );
+ expect(hasUnseenChangelog(entries[0]!, entries[0]!.id, null)).toBe(false);
});
});