From a0e02759d2721098e06f2c0c8c9cbb9036e24628 Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 2 Nov 2026 20:25:25 +0200 Subject: [PATCH] docs: plan for What's New release numbers and commit IDs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../plans/2026-11-02-whats-new-release-ids.md | 605 ++++++++++++++++++ 1 file changed, 605 insertions(+) create mode 100644 docs/superpowers/plans/2026-11-02-whats-new-release-ids.md diff --git a/docs/superpowers/plans/2026-11-02-whats-new-release-ids.md b/docs/superpowers/plans/2026-11-02-whats-new-release-ids.md new file mode 100644 index 0000000..c16df8a --- /dev/null +++ b/docs/superpowers/plans/2026-11-02-whats-new-release-ids.md @@ -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(); + + expect(screen.getByTestId("whats-new-badge")).toBeInTheDocument(); + }); + + it("marks the release as seen when the popup is opened", () => { + render(); + + // 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(); + + expect(screen.queryByTestId("whats-new-badge")).not.toBeInTheDocument(); + }); + + it("shows the release notes in the popup", async () => { + render(); + + 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(); + + 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(); + + 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 +
+ + Release {release.release} + + + {release.commit} + +
+``` + +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.