diff --git a/components/valuation/MeasuredCatalog.tsx b/components/valuation/MeasuredCatalog.tsx
index a2b984a..8195d4e 100644
--- a/components/valuation/MeasuredCatalog.tsx
+++ b/components/valuation/MeasuredCatalog.tsx
@@ -1,5 +1,7 @@
import type { MeasuredAlbum, StartedAlbum } from "@/components/valuation/types";
import { MeasuredAlbumRow } from "@/components/valuation/MeasuredAlbumRow";
+import { UnmeasuredReleasesRow } from "@/components/valuation/UnmeasuredReleasesRow";
+import { partitionMeasuredAlbums } from "@/lib/valuation/partitionMeasuredAlbums";
type MeasuredCatalogProps = {
albums: MeasuredAlbum[];
@@ -10,9 +12,12 @@ type MeasuredCatalogProps = {
/**
* The "What we measured" breakdown: every captured release, biggest first.
+ * Zero-stream releases collapse into a single count row at the end so the
+ * list never trails off in "$0" rows right above the CTA.
*/
export function MeasuredCatalog(props: MeasuredCatalogProps) {
if (props.albums.length === 0) return null;
+ const { measured, unmeasured } = partitionMeasuredAlbums(props.albums);
return (
@@ -24,17 +29,19 @@ export function MeasuredCatalog(props: MeasuredCatalogProps) {
boxShadow: "0 0 0 1px color-mix(in srgb, var(--foreground) 10%, transparent)",
}}
>
- {[...props.albums]
- .sort((a, b) => b.streams - a.streams)
- .map(album => (
- a.id === album.id)}
- centralValue={props.centralValue}
- totalStreams={props.totalStreams}
- />
- ))}
+ {measured.map(album => (
+ a.id === album.id)}
+ centralValue={props.centralValue}
+ totalStreams={props.totalStreams}
+ />
+ ))}
+
);
diff --git a/components/valuation/UnmeasuredReleasesRow.tsx b/components/valuation/UnmeasuredReleasesRow.tsx
new file mode 100644
index 0000000..192f020
--- /dev/null
+++ b/components/valuation/UnmeasuredReleasesRow.tsx
@@ -0,0 +1,47 @@
+import type { MeasuredAlbum, StartedAlbum } from "@/components/valuation/types";
+
+type UnmeasuredReleasesRowProps = {
+ albums: MeasuredAlbum[];
+ catalogAlbums: StartedAlbum[];
+};
+
+/**
+ * Collapses the zero-stream tail of the "What we measured" list into one row
+ * ("+N releases with no measurable streams"), expandable to the release names.
+ * Rendering each as its own "$0 · 0 streams" row read as failure right above
+ * the CTA (chat#1850).
+ */
+export function UnmeasuredReleasesRow(props: UnmeasuredReleasesRowProps) {
+ if (props.albums.length === 0) return null;
+ const count = props.albums.length;
+ return (
+
+
+
+
+ +{count} {count === 1 ? "release" : "releases"} with no measurable
+ streams
+
+
+ not counted in the estimate
+
+
+
+ {props.albums.map(album => {
+ const meta = props.catalogAlbums.find(a => a.id === album.id);
+ return (
+ -
+
+ {meta?.name ?? album.id}
+
+
+ {meta?.releaseDate?.slice(0, 4)}
+
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/lib/valuation/__tests__/partitionMeasuredAlbums.test.ts b/lib/valuation/__tests__/partitionMeasuredAlbums.test.ts
new file mode 100644
index 0000000..7bbb82d
--- /dev/null
+++ b/lib/valuation/__tests__/partitionMeasuredAlbums.test.ts
@@ -0,0 +1,62 @@
+import { describe, expect, it } from "vitest";
+import { partitionMeasuredAlbums } from "@/lib/valuation/partitionMeasuredAlbums";
+import type { MeasuredAlbum } from "@/components/valuation/types";
+
+const album = (id: string, streams: number): MeasuredAlbum => ({
+ id,
+ streams,
+ tracks: [{ name: `${id}-t1`, streams }],
+});
+
+describe("partitionMeasuredAlbums", () => {
+ it("splits zero-stream releases out of the measured list", () => {
+ const { measured, unmeasured } = partitionMeasuredAlbums([
+ album("a", 100),
+ album("b", 0),
+ album("c", 50),
+ album("d", 0),
+ ]);
+ expect(measured.map(a => a.id)).toEqual(["a", "c"]);
+ expect(unmeasured.map(a => a.id)).toEqual(["b", "d"]);
+ });
+
+ it("sorts measured releases by streams, biggest first", () => {
+ const { measured } = partitionMeasuredAlbums([
+ album("small", 1),
+ album("big", 1_000_000),
+ album("mid", 500),
+ ]);
+ expect(measured.map(a => a.id)).toEqual(["big", "mid", "small"]);
+ });
+
+ it("returns no unmeasured releases when everything has streams", () => {
+ const { measured, unmeasured } = partitionMeasuredAlbums([
+ album("a", 2),
+ album("b", 1),
+ ]);
+ expect(measured).toHaveLength(2);
+ expect(unmeasured).toEqual([]);
+ });
+
+ it("returns everything as unmeasured when nothing has streams", () => {
+ const { measured, unmeasured } = partitionMeasuredAlbums([
+ album("a", 0),
+ album("b", 0),
+ ]);
+ expect(measured).toEqual([]);
+ expect(unmeasured).toHaveLength(2);
+ });
+
+ it("handles an empty catalog", () => {
+ expect(partitionMeasuredAlbums([])).toEqual({
+ measured: [],
+ unmeasured: [],
+ });
+ });
+
+ it("does not mutate the input array", () => {
+ const input = [album("low", 1), album("high", 2)];
+ partitionMeasuredAlbums(input);
+ expect(input.map(a => a.id)).toEqual(["low", "high"]);
+ });
+});
diff --git a/lib/valuation/partitionMeasuredAlbums.ts b/lib/valuation/partitionMeasuredAlbums.ts
new file mode 100644
index 0000000..1f22e57
--- /dev/null
+++ b/lib/valuation/partitionMeasuredAlbums.ts
@@ -0,0 +1,20 @@
+import type { MeasuredAlbum } from "@/components/valuation/types";
+
+/**
+ * Splits a valuation's releases into the ones with measured streams (sorted
+ * biggest first, ready to render) and the zero-stream tail. A wall of
+ * "$0 · 0 streams" rows right above the CTA reads as failure, so the UI
+ * collapses the unmeasured group into a single count row instead.
+ */
+export function partitionMeasuredAlbums(albums: MeasuredAlbum[]): {
+ measured: MeasuredAlbum[];
+ unmeasured: MeasuredAlbum[];
+} {
+ const measured: MeasuredAlbum[] = [];
+ const unmeasured: MeasuredAlbum[] = [];
+ for (const album of albums) {
+ (album.streams > 0 ? measured : unmeasured).push(album);
+ }
+ measured.sort((a, b) => b.streams - a.streams);
+ return { measured, unmeasured };
+}