From 8d717101d7a7a0f22608bb65a0405f76be7ee77f Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 30 Jul 2026 19:42:24 -0500 Subject: [PATCH 1/2] fix(copy): one description of the measuring wait (chat#1912 row 10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The minute between seeding a catalog and its measurements landing was described three different ways in chat, with three different estimates: the /setup/artists toast said "Valuing X's catalog…", MeasuringCatalogPanel said "a minute", and the catalog report said "about a minute". The estimate a customer got depended on which surface they were looking at. Adds lib/catalog/measuringCopy.ts as the single source and points all three at it. The toast now uses the same verb as the pages. Each surface keeps its own contextual sentence (the report's "no need to run the valuation again", the setup panel's "your baseline value appears here"), but none restates the estimate. Marketing keeps its own copy of the strings since it deploys separately; the matching change is in that repo. Co-Authored-By: Claude Opus 5 (1M context) --- .../Onboarding/MeasuringCatalogPanel.tsx | 8 ++-- hooks/useAddSpotifyArtist.ts | 12 +++-- lib/catalog/__tests__/measuringCopy.test.ts | 46 +++++++++++++++++++ lib/catalog/getCatalogReportEmptyCopy.ts | 5 +- lib/catalog/measuringCopy.ts | 30 ++++++++++++ 5 files changed, 90 insertions(+), 11 deletions(-) create mode 100644 lib/catalog/__tests__/measuringCopy.test.ts create mode 100644 lib/catalog/measuringCopy.ts diff --git a/components/Onboarding/MeasuringCatalogPanel.tsx b/components/Onboarding/MeasuringCatalogPanel.tsx index 40f2325aa..550b44b82 100644 --- a/components/Onboarding/MeasuringCatalogPanel.tsx +++ b/components/Onboarding/MeasuringCatalogPanel.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import { buttonVariants } from "@/components/ui/button"; import { cn } from "@/lib/utils"; +import { MEASURING_BODY, MEASURING_TITLE } from "@/lib/catalog/measuringCopy"; /** * Terminal state for `/setup/valuation` while a catalog exists but has no @@ -15,12 +16,9 @@ import { cn } from "@/lib/utils"; */ const MeasuringCatalogPanel = () => (
-

- Measuring your catalog -

+

{MEASURING_TITLE}

- We are pulling play counts for every track. This usually takes a minute. - Your baseline value appears here as soon as it is ready. + {MEASURING_BODY} Your baseline value appears here as soon as it is ready.

Set up your weekly report diff --git a/hooks/useAddSpotifyArtist.ts b/hooks/useAddSpotifyArtist.ts index 0631f2d2a..54534561c 100644 --- a/hooks/useAddSpotifyArtist.ts +++ b/hooks/useAddSpotifyArtist.ts @@ -6,6 +6,11 @@ import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { addSpotifyArtist } from "@/lib/artists/addSpotifyArtist"; import { runValuation } from "@/lib/valuation/runValuation"; +import { + MEASURING_TOAST_ERROR, + MEASURING_TOAST_SUCCESS, + measuringToastLoading, +} from "@/lib/catalog/measuringCopy"; import { useArtistProvider } from "@/providers/ArtistProvider"; import { useOrganization } from "@/providers/OrganizationProvider"; import useCatalogs from "@/hooks/useCatalogs"; @@ -56,10 +61,9 @@ export function useAddSpotifyArtist() { queryClient.invalidateQueries({ queryKey: ["catalogs"] }); }), { - loading: `Valuing ${artist.name}'s catalog…`, - success: "Your catalog is ready", - error: - "Couldn't value the catalog automatically — you can claim it later", + loading: measuringToastLoading(artist.name), + success: MEASURING_TOAST_SUCCESS, + error: MEASURING_TOAST_ERROR, }, ); } diff --git a/lib/catalog/__tests__/measuringCopy.test.ts b/lib/catalog/__tests__/measuringCopy.test.ts new file mode 100644 index 000000000..d69341da7 --- /dev/null +++ b/lib/catalog/__tests__/measuringCopy.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { + MEASURING_TITLE, + MEASURING_ESTIMATE, + MEASURING_BODY, + MEASURING_TOAST_SUCCESS, + measuringToastLoading, +} from "@/lib/catalog/measuringCopy"; +import { getCatalogReportEmptyCopy } from "@/lib/catalog/getCatalogReportEmptyCopy"; + +describe("measuringCopy", () => { + // chat#1912 row 10: the same minute of waiting was described four different + // ways across the seeding toast, /setup/valuation, /catalogs/{id} and + // marketing, with three different time estimates. The estimate a customer + // gets should not depend on which surface they happen to be looking at. + it("states the time estimate once", () => { + expect(MEASURING_ESTIMATE).toContain("about a minute"); + expect(MEASURING_BODY).toContain(MEASURING_ESTIMATE); + }); + + it("is used by the catalog report's measuring state", () => { + const copy = getCatalogReportEmptyCopy("measuring", { hasOwnCatalogs: true }); + + expect(copy.title).toBe(MEASURING_TITLE); + expect(copy.body).toContain(MEASURING_ESTIMATE); + }); + + it("keeps the report's own reassurance without restating the estimate", () => { + const copy = getCatalogReportEmptyCopy("measuring", { hasOwnCatalogs: true }); + + expect(copy.body).toContain("no need to run the valuation again"); + // The estimate must appear once in the sentence, not twice. + expect(copy.body.split("about a minute").length - 1).toBe(1); + }); + + it("uses the same verb in the seeding toast as on the pages", () => { + expect(measuringToastLoading("BennyJ504")).toBe("Measuring BennyJ504's catalog…"); + expect(MEASURING_TOAST_SUCCESS.length).toBeGreaterThan(0); + }); + + // House style: em dashes read as machine-written in product copy. + it("keeps the shared copy free of em dashes", () => { + const all = [MEASURING_TITLE, MEASURING_ESTIMATE, MEASURING_BODY, MEASURING_TOAST_SUCCESS].join(" "); + expect(all).not.toMatch(/[—–]/); + }); +}); diff --git a/lib/catalog/getCatalogReportEmptyCopy.ts b/lib/catalog/getCatalogReportEmptyCopy.ts index 91a627b16..7e0a132c6 100644 --- a/lib/catalog/getCatalogReportEmptyCopy.ts +++ b/lib/catalog/getCatalogReportEmptyCopy.ts @@ -1,4 +1,5 @@ import type { CatalogReportState } from "./getCatalogReportState"; +import { MEASURING_BODY, MEASURING_TITLE } from "./measuringCopy"; export type CatalogReportEmptyState = Exclude< CatalogReportState, @@ -24,8 +25,8 @@ const COPY: Record = { cta: { label: "Sign in", action: "login" }, }, measuring: { - title: "Measuring your catalog", - body: "We are pulling live play counts for every track. This usually takes about a minute, and the report appears here on its own. There is no need to run the valuation again.", + title: MEASURING_TITLE, + body: `${MEASURING_BODY} The report appears here on its own, so there is no need to run the valuation again.`, }, "other-account": { title: "This catalog was measured by another account", diff --git a/lib/catalog/measuringCopy.ts b/lib/catalog/measuringCopy.ts new file mode 100644 index 000000000..aee9878d4 --- /dev/null +++ b/lib/catalog/measuringCopy.ts @@ -0,0 +1,30 @@ +/** + * One description of one wait. + * + * The minute between seeding a catalog and its measurements landing is shown on + * three chat surfaces — the `/setup/artists` seeding toast, `/setup/valuation`, + * and the `/catalogs/{id}` report — and each had drifted into its own wording + * and its own time estimate ("a minute", "about a minute", "a minute or two"). + * The estimate a customer is given should not depend on which surface they + * happen to be looking at (chat#1912 row 10). + * + * Marketing keeps its own copy of these strings, since it is a separate + * deployment; the contract is that the text matches, not that the module is + * imported across repos. + */ +export const MEASURING_TITLE = "Measuring your catalog"; + +/** The single source for how long this takes. Change it here, nowhere else. */ +export const MEASURING_ESTIMATE = + "This usually takes about a minute, and longer for large catalogs."; + +export const MEASURING_BODY = `We are pulling live play counts for every track. ${MEASURING_ESTIMATE}`; + +/** Seeding runs in the background, so its toast uses the same verb as the pages. */ +export const measuringToastLoading = (artistName: string) => + `Measuring ${artistName}'s catalog…`; + +export const MEASURING_TOAST_SUCCESS = "Your catalog is ready"; + +export const MEASURING_TOAST_ERROR = + "Couldn't measure the catalog automatically, you can claim it later"; From 4b68134e32313bd48617f52605587393110117cf Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 30 Jul 2026 20:43:26 -0500 Subject: [PATCH 2/2] test(copy): assert the toast verb rather than that a string is non-empty Review (cubic P3): the test named "uses the same verb in the seeding toast as on the pages" asserted MEASURING_TOAST_SUCCESS.length > 0, which cannot fail and does not check the verb. Derives the verb from MEASURING_TITLE so the assertion fails if either side drifts, and moves the toast outcome strings into their own case. Co-Authored-By: Claude Opus 5 (1M context) --- lib/catalog/__tests__/measuringCopy.test.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/lib/catalog/__tests__/measuringCopy.test.ts b/lib/catalog/__tests__/measuringCopy.test.ts index d69341da7..47b878c4d 100644 --- a/lib/catalog/__tests__/measuringCopy.test.ts +++ b/lib/catalog/__tests__/measuringCopy.test.ts @@ -4,6 +4,7 @@ import { MEASURING_ESTIMATE, MEASURING_BODY, MEASURING_TOAST_SUCCESS, + MEASURING_TOAST_ERROR, measuringToastLoading, } from "@/lib/catalog/measuringCopy"; import { getCatalogReportEmptyCopy } from "@/lib/catalog/getCatalogReportEmptyCopy"; @@ -33,14 +34,24 @@ describe("measuringCopy", () => { expect(copy.body.split("about a minute").length - 1).toBe(1); }); + // Derived from the title rather than hard-coded, so the assertion fails if + // either side drifts — the point is that they agree, not that they are any + // particular word. it("uses the same verb in the seeding toast as on the pages", () => { - expect(measuringToastLoading("BennyJ504")).toBe("Measuring BennyJ504's catalog…"); - expect(MEASURING_TOAST_SUCCESS.length).toBeGreaterThan(0); + const verb = MEASURING_TITLE.split(" ")[0]; + + expect(verb).toBe("Measuring"); + expect(measuringToastLoading("BennyJ504")).toBe(`${verb} BennyJ504's catalog…`); + }); + + it("states the toast outcomes", () => { + expect(MEASURING_TOAST_SUCCESS).toBe("Your catalog is ready"); + expect(MEASURING_TOAST_ERROR).toContain("claim it later"); }); // House style: em dashes read as machine-written in product copy. it("keeps the shared copy free of em dashes", () => { - const all = [MEASURING_TITLE, MEASURING_ESTIMATE, MEASURING_BODY, MEASURING_TOAST_SUCCESS].join(" "); + const all = [MEASURING_TITLE, MEASURING_ESTIMATE, MEASURING_BODY, MEASURING_TOAST_SUCCESS, MEASURING_TOAST_ERROR].join(" "); expect(all).not.toMatch(/[—–]/); }); });