docs: plan for What's New release numbers and commit IDs

Three tasks, each ending with the suite green: give every release a
number and a commit, switch the popup over and drop the date-shaped
version string, then document the convention and verify.

The order matters — the new fields land first so the component never
sits on a broken type mid-change.
This commit is contained in:
Dave 2026-11-02 20:25:25 +02:00
parent ebfee9eeaa
commit a0e02759d2

View file

@ -0,0 +1,605 @@
# What's New — release numbers and commit IDs instead of dates — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** The What's New popup identifies every release by a release number and the commit SHA it shipped with, and shows no dates at all.
**Architecture:** `src/lib/release-notes.ts` stays a hand-written static array. Its `version: string` (a date-shaped string) is replaced by `release: number` plus a new `commit: string`. `src/components/whats-new-dialog.tsx` renders "Release N" and the SHA in the slot the date used to occupy, and the date formatter is **deleted** so no code path can turn a date into text. The release number doubles as the localStorage "seen" marker.
**Tech Stack:** Next.js (App Router), React, TypeScript (`strict: true`), Tailwind, vitest + @testing-library/react (jsdom), base-ui `Dialog`.
**Design:** `docs/superpowers/specs/2026-11-02-whats-new-release-ids-design.md`
## Global Constraints
- **No dates in the popup.** Not the `date` field, not the version string, not a formatted date. The date formatter is deleted, not merely hidden.
- The `date` field **stays** in `src/lib/release-notes.ts` as an authoring reference and is **never rendered**.
- Release numbers are contiguous `1..N`, unique, and the array is ordered newest first. The next release after this change is **Release 6**.
- Every release carries the short SHA of the last commit that landed in it.
- The commit SHA is **plain text, never a link** — the Forgejo instance is on an internal host.
- The release notes stay hand-written. No generator, no fetch, no changelog table.
- The popup stays client-side and offline-capable: no server round-trip, no account state.
- No date is removed anywhere else in the app. `BuildStamp`, order dates, invoice dates and every other date are untouched.
- Stage by explicit path. **Never `git add -A`** — this repo carries unrelated untracked artifacts (`reports/`, `scripts/data/backups/`).
- Commit and push to `main` when a task's tests pass.
## Task order, and why it is this way
Task 1 adds the new fields without removing the old one, so the component still compiles and the popup still behaves exactly as it does today. Task 2 switches the popup over and removes `version` in the same commit. Task 3 documents the convention and verifies the whole thing.
Every task ends with the full suite green. Do not reorder them.
---
### Task 1: Give every release a number and a commit
**Files:**
- Create: `src/lib/release-notes.test.ts`
- Modify: `src/lib/release-notes.ts`
**Interfaces:**
- Consumes: nothing.
- Produces: `ReleaseNote` gains `release: number` and `commit: string`. `RELEASE_NOTES: ReleaseNote[]` (newest first) and `LATEST_RELEASE: ReleaseNote` (the same object as `RELEASE_NOTES[0]`) are unchanged in shape. `version: string` is still present — Task 2 removes it.
- [ ] **Step 1: Write the failing test**
Create `src/lib/release-notes.test.ts`:
```ts
import { describe, expect, it } from "vitest";
import { LATEST_RELEASE, RELEASE_NOTES } from "./release-notes";
describe("the release-note registry", () => {
it("numbers the releases 1..N with no gaps and no duplicates", () => {
const numbers = RELEASE_NOTES.map((r) => r.release);
expect(new Set(numbers).size).toBe(numbers.length);
expect([...numbers].sort((a, b) => a - b)).toEqual(
Array.from({ length: RELEASE_NOTES.length }, (_, i) => i + 1)
);
});
it("keeps the newest release at the top of the list", () => {
const numbers = RELEASE_NOTES.map((r) => r.release);
expect(numbers).toEqual([...numbers].sort((a, b) => b - a));
expect(LATEST_RELEASE).toBe(RELEASE_NOTES[0]);
});
it("gives every release a commit SHA", () => {
for (const release of RELEASE_NOTES) {
expect(release.commit).toMatch(/^[0-9a-f]{7,40}$/);
}
});
it("gives every release a headline and at least one item", () => {
for (const release of RELEASE_NOTES) {
expect(release.title.length).toBeGreaterThan(0);
expect(release.items.length).toBeGreaterThan(0);
}
});
});
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `npx vitest run src/lib/release-notes.test.ts`
Expected: FAIL. `r.release` and `r.commit` do not exist yet, so `numbers` is `[undefined, undefined, undefined, undefined, undefined]` and the numbering and SHA assertions fail.
- [ ] **Step 3: Add the two fields to the interface**
In `src/lib/release-notes.ts`, change the interface to:
```ts
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;
/** Short SHA of the commit this release went out with. */
commit: string;
/**
* Authoring reference only — the day the release was written up.
* Deliberately never rendered: the popup carries no dates.
*/
date: string;
/** One-line headline for the release. */
title: string;
/** The changes, one sentence each. */
items: string[];
}
```
`version` stays for now. Task 2 removes it.
- [ ] **Step 4: Fill in the release number and commit on every entry**
In `src/lib/release-notes.ts`, give each of the five entries its `release` number and `commit` SHA. The array is newest first, so the numbers run 5 down to 1:
```ts
export const RELEASE_NOTES: ReleaseNote[] = [
{
version: "2026.11.01",
release: 5,
commit: "139ea25",
date: "2026-11-01",
title: "Delivery Pool, letterhead lock and a dark mode section",
items: [
"Delivery Pool: invoiced lines waiting to go out, one card per customer invoice. Assign a driver, upload the signed copy, and the delivery closes.",
"Quote letterheads are locked to the customer's company — one letterhead per company, so quotes always go out on the right paper.",
"The counter never sells at R0. Items without a price stay off the till, and customer lines go to the price list instead.",
"The sidebar now has a Dark Mode section with a light/dark switch. Theme colours live in Settings → Appearance.",
"This What's New popup — press it in the sidebar to see what changed.",
],
},
{
version: "2026.10.30",
release: 4,
commit: "83aec14",
date: "2026-10-30",
title: "Collection Confirm gets a second pair of eyes",
items: [
"Collection Confirm: a Notes button on each PO card, so the driver photos can be checked against the PO before you confirm.",
"The AI never changes a price. A different cost on the invoice is flagged as a red Price tick for a person to settle.",
"Collection Pool: stock lines can go back to pending and be marked ready again.",
"Quotes now record who they were sent to.",
],
},
{
version: "2026.10.28",
release: 3,
commit: "3eea87e",
date: "2026-10-28",
title: "Auto-push and a shortcut to AI settings",
items: [
"Collection Confirm can auto-push purple cards straight on to invoicing, with a two-day log of everything it moved.",
"The Settings page links straight to AI settings.",
],
},
{
version: "2026.10.26",
release: 2,
commit: "b212d0f",
date: "2026-10-26",
title: "A phone board for the managers",
items: [
"/manager is a mobile board for the managers: every order and where it is, on a phone.",
"The preview popup is the same one the desk uses on /search.",
],
},
{
version: "2026.10.24",
release: 1,
commit: "55cc7fa",
date: "2026-10-24",
title: "One POS matcher, AI-only quote matching",
items: [
"One size-aware matcher now drives the quote assistant, the order fill and the blue price check — so they can't disagree about an item.",
"The quote assistant prices only an exact match and shows the closest one instead when it isn't sure.",
"Quote matching is AI-only. The old rule-based scorer is retired; the AI links an order when it's sure and suggests otherwise.",
"The blue price check badge appears on both Process pages.",
],
},
];
```
Each `commit` is the **last commit that landed in that release**:
| Release | Anchor commit | What landed there |
|---|---|---|
| 5 | `139ea25` | What's New button + Dark Mode section in the sidebar |
| 4 | `83aec14` | quotes record `email_to` |
| 3 | `3eea87e` | Collection Confirm auto-push with a 2-day log |
| 2 | `b212d0f` | `/manager` mobile orders board |
| 1 | `55cc7fa` | merge of `feat/ai-only-quote-match` |
Titles, items and `date` values are carried over untouched. Only the identity is added.
- [ ] **Step 5: Run the test to verify it passes**
Run: `npx vitest run src/lib/release-notes.test.ts`
Expected: PASS, 4 tests.
- [ ] **Step 6: Run the whole suite to confirm nothing else moved**
Run: `npx vitest run`
Expected: PASS. The popup still renders exactly as before — Task 1 changes the data, not the screen.
- [ ] **Step 7: Commit**
```bash
git add src/lib/release-notes.ts src/lib/release-notes.test.ts
git commit -m "refactor(release-notes): give every release a number and a commit
The version strings are dates (\"2026.11.01\"), so a release has no
identity that is not a date. Every entry now also carries a release
number and the short SHA of the commit it went out with.
The date-shaped version string is left in place for now — the popup
switches over and drops it in the next commit, so this one stays
green on its own.
A test holds the numbering unique, contiguous and newest-first, and
holds every commit to a real SHA."
```
- [ ] **Step 8: Push**
```bash
git push origin main
```
---
### Task 2: The popup shows the number and the commit, and no date
**Files:**
- Modify: `src/lib/release-notes.ts`
- Modify: `src/components/whats-new-dialog.tsx`
- Modify: `src/components/whats-new-dialog.test.tsx`
**Interfaces:**
- Consumes: `ReleaseNote.release` (number) and `ReleaseNote.commit` (string) from Task 1.
- Produces: `WhatsNewButton` — unchanged signature `({ className }: { className?: string })`. The "seen" marker written to `localStorage["wfdash-whats-new-seen"]` is now `String(LATEST_RELEASE.release)`, i.e. `"5"`.
- [ ] **Step 1: Write the failing test**
Replace the whole of `src/components/whats-new-dialog.test.tsx` with:
```tsx
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it } from "vitest";
import { WhatsNewButton } from "./whats-new-dialog";
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();
});
it("flags the latest release as new until it has been read", () => {
render(<WhatsNewButton />);
expect(screen.getByTestId("whats-new-badge")).toBeInTheDocument();
});
it("marks the release as seen when the popup is opened", () => {
render(<WhatsNewButton />);
// The dialog is opened by us, not by base-ui, so the "seen" stamp
// has to be written here. A version that only fired from
// onOpenChange would leave the badge lit forever.
fireEvent.click(screen.getByRole("button", { name: "What's New" }));
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, String(LATEST_RELEASE.release));
render(<WhatsNewButton />);
expect(screen.queryByTestId("whats-new-badge")).not.toBeInTheDocument();
});
it("shows the release notes in the popup", async () => {
render(<WhatsNewButton />);
fireEvent.click(screen.getByRole("button", { name: "What's New" }));
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);
});
});
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `npx vitest run src/components/whats-new-dialog.test.tsx`
Expected: FAIL on three counts — the "seen" marker is still the old version string, "Release 5" is not on screen, and the popup still renders "1 Nov 2026", which the date guard catches.
- [ ] **Step 3: Delete the date formatter**
In `src/components/whats-new-dialog.tsx`, delete the whole function:
```ts
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",
});
}
```
- [ ] **Step 4: Switch the "seen" marker to the release number**
In `src/components/whats-new-dialog.tsx`, add a module-level marker next to `SEEN_KEY`:
```ts
const SEEN_KEY = "wfdash-whats-new-seen";
/**
* 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);
```
Then replace `markSeen` and `isNew`:
```tsx
const markSeen = useCallback(() => {
try {
window.localStorage.setItem(SEEN_KEY, LATEST_MARKER);
} catch {
/* ignore */
}
setState((s) => ({ ...s, seen: LATEST_MARKER }));
}, []);
const isNew = state.mounted && state.seen !== LATEST_MARKER;
```
- [ ] **Step 5: Render the release number and the commit**
In `src/components/whats-new-dialog.tsx`, replace the badge row:
```tsx
<div className="flex items-baseline gap-2">
<span
className={cn(
"rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide",
i === 0
? "bg-primary/15 text-primary"
: "bg-muted text-muted-foreground"
)}
>
Release {release.release}
</span>
<span className="font-mono text-xs text-muted-foreground">
{release.commit}
</span>
</div>
```
The commit sits in the slot the date used to occupy. It is monospace, matching the build stamp in the corner of the app.
- [ ] **Step 6: Update the file header comment**
In `src/components/whats-new-dialog.tsx`, replace the header comment block with:
```tsx
/**
* What's New — a button in the sidebar that opens a popup of the
* releases that have landed since the operator last looked.
*
* 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.
*/
```
- [ ] **Step 7: Drop the date-shaped version string from the registry**
The component no longer reads `version`, so it can go. In `src/lib/release-notes.ts`, remove `version` from the interface:
```ts
export interface ReleaseNote {
/** Release number. 1 = the first release. Also the "seen" marker. */
release: number;
/** Short SHA of the commit this release went out with. */
commit: string;
/**
* Authoring reference only — the day the release was written up.
* Deliberately never rendered: the popup carries no dates.
*/
date: string;
/** One-line headline for the release. */
title: string;
/** The changes, one sentence each. */
items: string[];
}
```
Then remove the `version:` line from all five entries in `RELEASE_NOTES`.
- [ ] **Step 8: Update the registry header comment**
In `src/lib/release-notes.ts`, replace the header comment block with:
```ts
/**
* 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
* popup renders whatever is in here. That keeps the whole feature
* client-side (it opens instantly, works offline) and means the copy
* stays deliberate rather than being generated from commit messages.
*
* 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
*/
```
- [ ] **Step 9: Run the test to verify it passes**
Run: `npx vitest run src/components/whats-new-dialog.test.tsx`
Expected: PASS, 6 tests.
- [ ] **Step 10: Confirm the date formatter is really gone**
Run: `grep -rn "formatDate\|toLocaleDateString\|release.date" src/components/whats-new-dialog.tsx`
Expected: no output.
- [ ] **Step 11: Run the whole suite**
Run: `npx vitest run`
Expected: PASS. `src/components/sidebar.test.tsx` only asserts the button exists, so it is unaffected.
- [ ] **Step 12: Commit**
```bash
git add src/lib/release-notes.ts src/components/whats-new-dialog.tsx src/components/whats-new-dialog.test.tsx
git commit -m "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."
```
- [ ] **Step 13: Push**
```bash
git push origin main
```
---
### Task 3: Document the convention, and verify the whole thing
**Files:**
- Modify: `AGENTS.md` (append a new section at the end of the file)
**Interfaces:**
- Consumes: the final shape of `src/lib/release-notes.ts` from Task 2.
- Produces: nothing consumed by code. The section tells the next agent how to publish a release and that dates are banned from the popup.
- [ ] **Step 1: Add the AGENTS.md section**
Append to the end of `AGENTS.md`:
```markdown
# What's New (sidebar popup) — release numbers and commits, never dates
The sidebar's "What's New" popup lists releases from `src/lib/release-notes.ts` — a
hand-written array, newest first. There is no changelog table and no fetch: the notes are
typed in when a release goes out. The "New" badge lights up when the top entry's release
number differs from `wfdash-whats-new-seen` in the operator's localStorage.
- A release is identified by **its number** and **the short SHA of the commit it went out
with** (`commit`). The SHA is plain text — never a link; the Forgejo instance is on an
internal host.
- **No dates on screen.** The `date` field in the data file is an authoring reference only
(which window of commits a release covers) and is never rendered. The date formatter was
deleted, so there is no code path that can turn a date into text, and
`src/components/whats-new-dialog.test.tsx` fails the build if a date comes back.
- To publish a release: merge, take `git log --oneline -1`, add an entry at the top with the
next number (the next one after this change is 6) and that SHA. The number is the "seen"
marker — bumping it re-notifies everyone.
```
- [ ] **Step 2: Run the full test suite**
Run: `npx vitest run`
Expected: PASS, no failures. The suite is the only cheap regression net this repo has.
- [ ] **Step 3: Lint the four touched files**
Run: `npx eslint src/lib/release-notes.ts src/lib/release-notes.test.ts src/components/whats-new-dialog.tsx src/components/whats-new-dialog.test.tsx`
Expected: no output, exit 0.
- [ ] **Step 4: Type-check**
Run: `npx tsc --noEmit`
Expected: no errors.
- [ ] **Step 5: Confirm no date survives in the popup**
Run: `grep -rn "date" src/components/whats-new-dialog.tsx`
Expected: no output.
- [ ] **Step 6: Commit**
```bash
git add AGENTS.md
git commit -m "docs: the What's New popup carries release numbers, never dates"
```
- [ ] **Step 7: Push**
```bash
git push origin main
```
- [ ] **Step 8: Report**
Tell Dave:
- the popup now shows "Release N" and the commit SHA, and no date;
- the five existing releases are numbered 1-5, newest is Release 5;
- the next release he ships is Release 6 — merge, `git log --oneline -1`, add an entry at the top of `src/lib/release-notes.ts`;
- the "New" badge lights up once for every operator on the next deploy, then settles.