fix: show actual downloads in Trending (#3354)

This commit is contained in:
Patrick Erichsen
2026-07-31 09:09:48 -07:00
committed by GitHub
parent a15f97470f
commit 476feb2af1
10 changed files with 107 additions and 26 deletions
+5
View File
@@ -479,6 +479,7 @@ describe("canonical Trending snapshot storage", () => {
sample: [
expect.objectContaining({
id: expect.stringMatching(/^clawhub:/),
trending24hDownloads: 6,
trending24hInstalls: 8,
}),
],
@@ -854,6 +855,7 @@ describe("canonical Trending snapshot storage", () => {
rank: 1,
lane: "clawhub-trending",
id: expect.stringMatching(/^clawhub:/),
trending24hDownloads: 18,
trending24hInstalls: 12,
lifetimeInstalls: 900,
},
@@ -861,6 +863,7 @@ describe("canonical Trending snapshot storage", () => {
rank: 2,
lane: "skills-sh-trending",
id: "skills-sh:patrick/repo/external",
trending24hDownloads: null,
trending24hInstalls: null,
lifetimeInstalls: 4_200,
},
@@ -928,6 +931,7 @@ describe("canonical Trending snapshot storage", () => {
source: "clawhub",
rank: 1,
metrics: {
trending24hDownloads: 18,
trending24hInstalls: 12,
trending24hBookmarks: 4,
lifetimeInstalls: 900,
@@ -944,6 +948,7 @@ describe("canonical Trending snapshot storage", () => {
sourceUrl: "https://skills.sh/patrick/repo/external",
},
metrics: {
trending24hDownloads: null,
trending24hInstalls: null,
trending24hBookmarks: null,
lifetimeInstalls: 4_200,
+1
View File
@@ -684,6 +684,7 @@ export const materializeInternal = internalAction({
lane: candidate.lane,
id: candidate.card.id,
displayName: candidate.card.displayName,
trending24hDownloads: candidate.card.metrics.trending24hDownloads ?? null,
trending24hInstalls: candidate.card.metrics.trending24hInstalls,
lifetimeInstalls: candidate.card.metrics.lifetimeInstalls,
})),
+6 -3
View File
@@ -266,10 +266,11 @@ describe("canonical Trending cards", () => {
createdAt: 100,
updatedAt: 200,
},
{ installs: 12, bookmarks: 4, updatedAt: 300 },
{ downloads: 37, installs: 12, bookmarks: 4, updatedAt: 300 },
);
expect(result?.card.metrics).toEqual({
trending24hDownloads: 37,
trending24hInstalls: 12,
trending24hBookmarks: 4,
lifetimeInstalls: 900,
@@ -301,7 +302,7 @@ describe("canonical Trending cards", () => {
createdAt: 100,
updatedAt: 200,
},
{ installs: 1, bookmarks: 0, updatedAt: 300 },
{ downloads: 9, installs: 1, bookmarks: 0, updatedAt: 300 },
);
expect(result).toBeNull();
@@ -330,10 +331,11 @@ describe("canonical Trending cards", () => {
createdAt: 100,
updatedAt: 200,
},
{ installs: 1, bookmarks: 0, updatedAt: 300 },
{ downloads: 9, installs: 1, bookmarks: 0, updatedAt: 300 },
);
expect(result?.card.metrics).toMatchObject({
trending24hDownloads: 9,
trending24hInstalls: 1,
lifetimeInstalls: null,
updatedAt: 300,
@@ -365,6 +367,7 @@ describe("canonical Trending cards", () => {
expect(result).toMatchObject({ upstreamRank: 7 });
expect(result?.card.metrics).toEqual({
trending24hDownloads: null,
trending24hInstalls: null,
trending24hBookmarks: null,
lifetimeInstalls: 4_200,
+6 -1
View File
@@ -73,6 +73,9 @@ export const canonicalTrendingCardValidator = v.object({
sourceFreshness: v.union(v.literal("native"), v.literal("observed-only")),
}),
metrics: v.object({
// Optional only so snapshots materialized before this field landed remain readable
// until the next hourly materialization replaces them.
trending24hDownloads: v.optional(v.union(v.number(), v.null())),
trending24hInstalls: v.union(v.number(), v.null()),
trending24hBookmarks: v.union(v.number(), v.null()),
lifetimeInstalls: v.union(v.number(), v.null()),
@@ -137,7 +140,7 @@ type ExternalTrendingDigest = Pick<
export function buildNativeCanonicalTrendingCandidate(
digest: NativeTrendingDigest,
usage: { installs: number; bookmarks: number; updatedAt: number },
usage: { downloads: number; installs: number; bookmarks: number; updatedAt: number },
): CanonicalTrendingMaterializationCandidate | null {
const ownerHandle = digest.ownerHandle?.trim();
if (!ownerHandle) return null;
@@ -192,6 +195,7 @@ export function buildNativeCanonicalTrendingCandidate(
sourceFreshness: "native",
},
metrics: {
trending24hDownloads: Math.max(0, usage.downloads),
trending24hInstalls: Math.max(0, usage.installs),
trending24hBookmarks: Math.max(0, usage.bookmarks),
lifetimeInstalls,
@@ -254,6 +258,7 @@ export function buildExternalCanonicalTrendingCandidate(
sourceFreshness: "observed-only",
},
metrics: {
trending24hDownloads: null,
trending24hInstalls: null,
trending24hBookmarks: null,
lifetimeInstalls,
@@ -78,9 +78,9 @@ describe("HomeListingSection", () => {
fetchCanonicalTrendingPageMock.mockResolvedValue(canonicalPage([]));
});
it("labels canonical Trending values as downloads without changing order or values", () => {
const first = makeTrending("first", "First Skill", 17, 9000);
const second = makeTrending("second", "Second Skill", 3, 8000);
it("shows canonical Trending download totals without changing order", () => {
const first = makeTrending("first", "First Skill", 17, 9000, 71);
const second = makeTrending("second", "Second Skill", 3, 8000, 29);
render(<HomeListingSection initialListing={initialTrending([first, second])} />);
const contentTypeButtons = screen
@@ -110,8 +110,10 @@ describe("HomeListingSection", () => {
(node) => node.textContent,
),
).toEqual(["First Skill", "Second Skill"]);
expect(screen.getByText("17")).toBeTruthy();
expect(screen.getByText("3")).toBeTruthy();
expect(screen.getByText("71")).toBeTruthy();
expect(screen.getByText("29")).toBeTruthy();
expect(screen.queryByText("17")).toBeNull();
expect(screen.queryByText("3")).toBeNull();
expect(screen.getByText("24h downloads")).toBeTruthy();
expect(screen.getAllByLabelText("24-hour downloads")).toHaveLength(2);
expect(screen.queryByText("24h installs")).toBeNull();
@@ -337,7 +339,13 @@ function canonicalPage(items: ReturnType<typeof makeTrending>[], nextCursor: str
};
}
function makeTrending(slug: string, displayName: string, installs: number, lifetime: number) {
function makeTrending(
slug: string,
displayName: string,
installs: number,
lifetime: number,
downloads = installs,
) {
return {
id: `clawhub:${slug}`,
source: "clawhub" as const,
@@ -355,6 +363,7 @@ function makeTrending(slug: string, displayName: string, installs: number, lifet
official: false,
featured: false,
metrics: {
trending24hDownloads: downloads,
trending24hInstalls: installs,
trending24hBookmarks: null,
lifetimeInstalls: lifetime,
+15 -6
View File
@@ -110,11 +110,11 @@ describe("SkillsIndex", () => {
expect(screen.queryByLabelText("Skill categories")).toBeNull();
});
it("labels canonical Trending rows as downloads without changing API order or values", async () => {
it("shows canonical Trending download totals without changing API order", async () => {
fetchCanonicalTrendingPageMock.mockResolvedValue(
canonicalPage([
makeTrending("first", "First Skill", 17, 9000),
makeTrending("second", "Second Skill", 3, 8000),
makeTrending("first", "First Skill", 17, 9000, 71),
makeTrending("second", "Second Skill", 3, 8000, 29),
]),
);
@@ -126,8 +126,10 @@ describe("SkillsIndex", () => {
(node) => node.textContent,
);
expect(names).toEqual(["First Skill", "Second Skill"]);
expect(screen.getByText("17")).toBeTruthy();
expect(screen.getByText("3")).toBeTruthy();
expect(screen.getByText("71")).toBeTruthy();
expect(screen.getByText("29")).toBeTruthy();
expect(screen.queryByText("17")).toBeNull();
expect(screen.queryByText("3")).toBeNull();
expect(screen.getByText("24h downloads")).toBeTruthy();
expect(screen.getAllByLabelText("24-hour downloads")).toHaveLength(2);
expect(screen.queryByText("24h installs")).toBeNull();
@@ -460,7 +462,13 @@ function canonicalPage(items: ReturnType<typeof makeTrending>[], nextCursor: str
};
}
function makeTrending(slug: string, displayName: string, installs: number, lifetime: number) {
function makeTrending(
slug: string,
displayName: string,
installs: number,
lifetime: number,
downloads = installs,
) {
return {
id: `clawhub:${slug}`,
source: "clawhub" as const,
@@ -478,6 +486,7 @@ function makeTrending(slug: string, displayName: string, installs: number, lifet
official: false,
featured: false,
metrics: {
trending24hDownloads: downloads,
trending24hInstalls: installs,
trending24hBookmarks: null,
lifetimeInstalls: lifetime,
+4 -4
View File
@@ -219,11 +219,11 @@ function HomeListingSkillRow({ entry, showStats }: { entry: SkillPageEntry; show
{truncateText(item.summary || "Agent-ready skill pack.", 80)}
</p>
</div>
{typeof item.metrics.trending24hInstalls === "number" ? (
{typeof item.metrics.trending24hDownloads === "number" ? (
<div className="home-v2-listing-row-stats" aria-label="24-hour downloads">
<span>
<Download size={13} aria-hidden="true" />
{formatCompactStat(item.metrics.trending24hInstalls)}
{formatCompactStat(item.metrics.trending24hDownloads)}
</span>
</div>
) : null}
@@ -333,11 +333,11 @@ function HomeListingSkillCard({ entry, showStats }: { entry: SkillPageEntry; sho
<p className="home-v2-listing-card-summary">
{truncateText(item.summary || "Agent-ready skill pack.", 80)}
</p>
{typeof item.metrics.trending24hInstalls === "number" ? (
{typeof item.metrics.trending24hDownloads === "number" ? (
<div className="home-v2-listing-card-stats" aria-label="24-hour downloads">
<span>
<Download size={13} aria-hidden="true" />
{formatCompactStat(item.metrics.trending24hInstalls)}
{formatCompactStat(item.metrics.trending24hDownloads)}
</span>
</div>
) : null}
+28
View File
@@ -39,6 +39,33 @@ describe("fetchCanonicalTrendingPage", () => {
expect(url.searchParams.get("cursor")).toBe("opaque cursor");
});
it("preserves the canonical 24-hour download total separately from installs", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response(
JSON.stringify(
canonicalPage({
metrics: {
trending24hDownloads: 71,
trending24hInstalls: 17,
trending24hBookmarks: null,
lifetimeInstalls: 10,
lifetimeInstallsPeriod: "lifetime",
updatedAt: 1,
},
}),
),
{ status: 200 },
),
),
);
const page = await fetchCanonicalTrendingPage({ limit: 20 });
expect(page.items[0]?.metrics.trending24hDownloads).toBe(71);
expect(page.items[0]?.metrics.trending24hInstalls).toBe(17);
});
it("fails closed on a malformed canonical response", async () => {
vi.stubGlobal(
"fetch",
@@ -129,6 +156,7 @@ function canonicalPage(itemOverrides: Record<string, unknown>) {
official: false,
featured: false,
metrics: {
trending24hDownloads: 3,
trending24hInstalls: 3,
trending24hBookmarks: null,
lifetimeInstalls: 10,
+23 -2
View File
@@ -19,6 +19,7 @@ export type CanonicalTrendingItem = {
official: boolean;
featured: boolean;
metrics: {
trending24hDownloads: number | null;
trending24hInstalls: number | null;
trending24hBookmarks: number | null;
lifetimeInstalls: number | null;
@@ -27,6 +28,12 @@ export type CanonicalTrendingItem = {
};
};
type LegacyCanonicalTrendingItem = Omit<CanonicalTrendingItem, "metrics"> & {
metrics: Omit<CanonicalTrendingItem["metrics"], "trending24hDownloads"> & {
trending24hDownloads?: number | null;
};
};
type CanonicalTrendingPage = {
kind: "skills";
snapshotId: string;
@@ -81,7 +88,7 @@ function isCanonicalPublisher(value: unknown): value is CanonicalTrendingItem["p
);
}
function isCanonicalTrendingItem(value: unknown): value is CanonicalTrendingItem {
function isCanonicalTrendingItem(value: unknown): value is LegacyCanonicalTrendingItem {
if (!isRecord(value) || !isRecord(value.metrics)) return false;
return (
typeof value.id === "string" &&
@@ -93,6 +100,8 @@ function isCanonicalTrendingItem(value: unknown): value is CanonicalTrendingItem
isCanonicalPublisher(value.publisher) &&
typeof value.official === "boolean" &&
typeof value.featured === "boolean" &&
(value.metrics.trending24hDownloads === undefined ||
isNullableNumber(value.metrics.trending24hDownloads)) &&
isNullableNumber(value.metrics.trending24hInstalls) &&
isNullableNumber(value.metrics.trending24hBookmarks) &&
isNullableNumber(value.metrics.lifetimeInstalls) &&
@@ -118,7 +127,19 @@ function parseCanonicalTrendingPage(value: unknown): CanonicalTrendingPage {
) {
throw new Error("Invalid canonical Trending response");
}
return value as CanonicalTrendingPage;
const page = value as Omit<CanonicalTrendingPage, "items"> & {
items: LegacyCanonicalTrendingItem[];
};
return {
...page,
items: page.items.map((item) => ({
...item,
metrics: {
...item.metrics,
trending24hDownloads: item.metrics.trending24hDownloads ?? null,
},
})),
};
}
export async function fetchCanonicalTrendingPage({
+4 -4
View File
@@ -59,10 +59,10 @@ function TrendingSkillListItem({ item }: { item: TrendingSkillListEntry }) {
) : null}
</div>
<div className="skill-list-item-meta" aria-label="24-hour downloads">
{typeof trending.metrics.trending24hInstalls === "number" ? (
{typeof trending.metrics.trending24hDownloads === "number" ? (
<span className="skill-list-item-meta-item">
<Download size={14} aria-hidden="true" />
{formatCompactStat(trending.metrics.trending24hInstalls)}
{formatCompactStat(trending.metrics.trending24hDownloads)}
</span>
) : null}
</div>
@@ -94,11 +94,11 @@ function TrendingSkillCard({ item }: { item: TrendingSkillListEntry }) {
{trending.summary}
</p>
) : null}
{typeof trending.metrics.trending24hInstalls === "number" ? (
{typeof trending.metrics.trending24hDownloads === "number" ? (
<div className="skill-card-grid-meta" aria-label="24-hour downloads">
<span>
<Download size={14} aria-hidden="true" />
{formatCompactStat(trending.metrics.trending24hInstalls)}
{formatCompactStat(trending.metrics.trending24hDownloads)}
</span>
</div>
) : null}