feat(whats-new): show the release number and commit instead of the date

The popup rendered the day each release went out, which gave the office
a way to ask what was done on a given day. Each release now shows its
number and the short SHA it shipped with, and the date-shaped version
string is gone from the registry.

The date formatter is deleted rather than hidden, so no code path can
put a date on screen, and a test fails the build if one comes back.

The 'seen' marker moves from the version string to the release number,
so the badge lights up once for everyone on this deploy and then
settles.
This commit is contained in:
Dave 2026-11-02 20:41:17 +02:00
parent fd5bb11325
commit a9e9efecba
3 changed files with 67 additions and 35 deletions

View file

@ -6,6 +6,13 @@ import { LATEST_RELEASE } from "@/lib/release-notes";
const SEEN_KEY = "wfdash-whats-new-seen";
/**
* Any date that could reach the popup: the ISO shape ("2026-11-01"), the
* "1 Nov 2026" shape, and the "November 1, 2026" shape.
*/
const DATE_PATTERN =
/\d{4}-\d{2}-\d{2}|\d{1,2}\s+(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{4}|(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\s+\d{1,2},?\s+\d{4}/i;
describe("WhatsNewButton", () => {
beforeEach(() => {
window.localStorage.clear();
@ -25,11 +32,13 @@ describe("WhatsNewButton", () => {
// onOpenChange would leave the badge lit forever.
fireEvent.click(screen.getByRole("button", { name: "What's New" }));
expect(window.localStorage.getItem(SEEN_KEY)).toBe(LATEST_RELEASE.version);
expect(window.localStorage.getItem(SEEN_KEY)).toBe(
String(LATEST_RELEASE.release)
);
});
it("does not flag a release the operator has already read", () => {
window.localStorage.setItem(SEEN_KEY, LATEST_RELEASE.version);
window.localStorage.setItem(SEEN_KEY, String(LATEST_RELEASE.release));
render(<WhatsNewButton />);
expect(screen.queryByTestId("whats-new-badge")).not.toBeInTheDocument();
@ -42,4 +51,28 @@ describe("WhatsNewButton", () => {
expect(await screen.findByText(LATEST_RELEASE.title)).toBeInTheDocument();
});
it("identifies the release by number and commit", async () => {
render(<WhatsNewButton />);
fireEvent.click(screen.getByRole("button", { name: "What's New" }));
expect(
await screen.findByText(`Release ${LATEST_RELEASE.release}`)
).toBeInTheDocument();
expect(screen.getByText(LATEST_RELEASE.commit)).toBeInTheDocument();
});
it("never shows a date in the popup", async () => {
render(<WhatsNewButton />);
fireEvent.click(screen.getByRole("button", { name: "What's New" }));
await screen.findByText(LATEST_RELEASE.title);
// The whole component is on the page here — the button and the
// portalled dialog — so this covers every string the popup can put
// on screen.
expect(document.body.textContent ?? "").not.toMatch(DATE_PATTERN);
});
});

View file

@ -17,22 +17,24 @@ import { cn } from "@/lib/utils";
* What's New — a button in the sidebar that opens a popup of the
* releases that have landed since the operator last looked.
*
* "Seen" is tracked in localStorage against the latest version string.
* Bump the version in src/lib/release-notes.ts and the button lights up
* Each release is identified by its number and by the commit it went out
* with. Nothing in here carries a date: the date formatter that used to
* sit next to the release label is gone, so a date cannot reach the
* screen even by accident.
*
* "Seen" is tracked in localStorage against the latest release number.
* Add a new entry in src/lib/release-notes.ts and the button lights up
* again for everyone — no server round-trip, no account state.
*/
const SEEN_KEY = "wfdash-whats-new-seen";
function formatDate(iso: string): string {
const d = new Date(`${iso}T00:00:00`);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleDateString("en-ZA", {
day: "numeric",
month: "short",
year: "numeric",
});
}
/**
* The "seen" marker for the newest release. It is the release number, so
* adding a new entry in src/lib/release-notes.ts is what re-notifies every
* operator.
*/
const LATEST_MARKER = String(LATEST_RELEASE.release);
export function WhatsNewButton({ className }: { className?: string }) {
const [open, setOpen] = useState(false);
@ -57,14 +59,14 @@ export function WhatsNewButton({ className }: { className?: string }) {
const markSeen = useCallback(() => {
try {
window.localStorage.setItem(SEEN_KEY, LATEST_RELEASE.version);
window.localStorage.setItem(SEEN_KEY, LATEST_MARKER);
} catch {
/* ignore */
}
setState((s) => ({ ...s, seen: LATEST_RELEASE.version }));
setState((s) => ({ ...s, seen: LATEST_MARKER }));
}, []);
const isNew = state.mounted && state.seen !== LATEST_RELEASE.version;
const isNew = state.mounted && state.seen !== LATEST_MARKER;
return (
<>
@ -111,7 +113,7 @@ export function WhatsNewButton({ className }: { className?: string }) {
<div className="overflow-y-auto px-6 py-4">
<ol className="space-y-5">
{RELEASE_NOTES.map((release, i) => (
<li key={release.version} className="space-y-2">
<li key={release.release} className="space-y-2">
<div className="flex items-baseline gap-2">
<span
className={cn(
@ -121,10 +123,10 @@ export function WhatsNewButton({ className }: { className?: string }) {
: "bg-muted text-muted-foreground"
)}
>
{release.version}
Release {release.release}
</span>
<span className="text-xs text-muted-foreground">
{formatDate(release.date)}
<span className="font-mono text-xs text-muted-foreground">
{release.commit}
</span>
</div>
<p className="text-sm font-medium">{release.title}</p>

View file

@ -1,5 +1,5 @@
/**
* What's New — the release-note registry behind the Settings page popup.
* What's New — the release-note registry behind the sidebar popup.
*
* A plain data file, newest first. There's no changelog table and no
* fetch: the notes are written by hand when a release goes out, and the
@ -7,18 +7,20 @@
* client-side (it opens instantly, works offline) and means the copy
* stays deliberate rather than being generated from commit messages.
*
* To publish a release: add an entry at the top of the array. The
* version string is the "seen" marker — bumping it is what makes the
* What's New button light up for every operator again.
* A release is identified by its number and by the commit it went out
* with. There are no dates on screen — `date` is kept here only so the
* author can see which window of commits a release covers while writing
* it, and it is deliberately never rendered.
*
* To publish a release:
* 1. merge the work, then take the short SHA — `git log --oneline -1`
* 2. add an entry at the top of the array with the next release number
* 3. the release number is the "seen" marker, so bumping it is what
* makes the What's New button light up for every operator again
*/
export interface ReleaseNote {
/** Version marker. Bumped to re-notify operators. */
version: string;
/**
* Release number. 1 = the first release. This replaces the date-shaped
* `version` string and becomes the "seen" marker.
*/
/** Release number. 1 = the first release. Also the "seen" marker. */
release: number;
/** Short SHA of the commit this release went out with. */
commit: string;
@ -35,7 +37,6 @@ export interface ReleaseNote {
export const RELEASE_NOTES: ReleaseNote[] = [
{
version: "2026.11.01",
release: 5,
commit: "139ea25",
date: "2026-11-01",
@ -49,7 +50,6 @@ export const RELEASE_NOTES: ReleaseNote[] = [
],
},
{
version: "2026.10.30",
release: 4,
commit: "83aec14",
date: "2026-10-30",
@ -62,7 +62,6 @@ export const RELEASE_NOTES: ReleaseNote[] = [
],
},
{
version: "2026.10.28",
release: 3,
commit: "3eea87e",
date: "2026-10-28",
@ -73,7 +72,6 @@ export const RELEASE_NOTES: ReleaseNote[] = [
],
},
{
version: "2026.10.26",
release: 2,
commit: "b212d0f",
date: "2026-10-26",
@ -84,7 +82,6 @@ export const RELEASE_NOTES: ReleaseNote[] = [
],
},
{
version: "2026.10.24",
release: 1,
commit: "55cc7fa",
date: "2026-10-24",