diff --git a/convex/canonicalTrending.test.ts b/convex/canonicalTrending.test.ts index 3c8f5dc2..139a016e 100644 --- a/convex/canonicalTrending.test.ts +++ b/convex/canonicalTrending.test.ts @@ -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, diff --git a/convex/canonicalTrending.ts b/convex/canonicalTrending.ts index 0f05ce86..88782710 100644 --- a/convex/canonicalTrending.ts +++ b/convex/canonicalTrending.ts @@ -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, })), diff --git a/convex/lib/canonicalTrending.test.ts b/convex/lib/canonicalTrending.test.ts index 7f0b3e36..6f132571 100644 --- a/convex/lib/canonicalTrending.test.ts +++ b/convex/lib/canonicalTrending.test.ts @@ -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, diff --git a/convex/lib/canonicalTrending.ts b/convex/lib/canonicalTrending.ts index 52b1021e..61110630 100644 --- a/convex/lib/canonicalTrending.ts +++ b/convex/lib/canonicalTrending.ts @@ -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, diff --git a/src/__tests__/home-listing-section.claw591.test.tsx b/src/__tests__/home-listing-section.claw591.test.tsx index e906557b..532a6f6b 100644 --- a/src/__tests__/home-listing-section.claw591.test.tsx +++ b/src/__tests__/home-listing-section.claw591.test.tsx @@ -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(); 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[], 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, diff --git a/src/__tests__/skills-index.claw591.test.tsx b/src/__tests__/skills-index.claw591.test.tsx index 5436f379..aafbcfdd 100644 --- a/src/__tests__/skills-index.claw591.test.tsx +++ b/src/__tests__/skills-index.claw591.test.tsx @@ -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[], 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, diff --git a/src/components/HomeListingSection.tsx b/src/components/HomeListingSection.tsx index d6439a61..3e11f7d1 100644 --- a/src/components/HomeListingSection.tsx +++ b/src/components/HomeListingSection.tsx @@ -219,11 +219,11 @@ function HomeListingSkillRow({ entry, showStats }: { entry: SkillPageEntry; show {truncateText(item.summary || "Agent-ready skill pack.", 80)}

- {typeof item.metrics.trending24hInstalls === "number" ? ( + {typeof item.metrics.trending24hDownloads === "number" ? (
) : null} @@ -333,11 +333,11 @@ function HomeListingSkillCard({ entry, showStats }: { entry: SkillPageEntry; sho

{truncateText(item.summary || "Agent-ready skill pack.", 80)}

- {typeof item.metrics.trending24hInstalls === "number" ? ( + {typeof item.metrics.trending24hDownloads === "number" ? (
) : null} diff --git a/src/lib/trendingApi.test.ts b/src/lib/trendingApi.test.ts index f1fb4944..0b5ffa3b 100644 --- a/src/lib/trendingApi.test.ts +++ b/src/lib/trendingApi.test.ts @@ -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) { official: false, featured: false, metrics: { + trending24hDownloads: 3, trending24hInstalls: 3, trending24hBookmarks: null, lifetimeInstalls: 10, diff --git a/src/lib/trendingApi.ts b/src/lib/trendingApi.ts index 547a3793..bdd30ca3 100644 --- a/src/lib/trendingApi.ts +++ b/src/lib/trendingApi.ts @@ -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 & { + metrics: Omit & { + 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 & { + items: LegacyCanonicalTrendingItem[]; + }; + return { + ...page, + items: page.items.map((item) => ({ + ...item, + metrics: { + ...item.metrics, + trending24hDownloads: item.metrics.trending24hDownloads ?? null, + }, + })), + }; } export async function fetchCanonicalTrendingPage({ diff --git a/src/routes/skills/-SkillsResults.tsx b/src/routes/skills/-SkillsResults.tsx index 0dfc86ef..4382801b 100644 --- a/src/routes/skills/-SkillsResults.tsx +++ b/src/routes/skills/-SkillsResults.tsx @@ -59,10 +59,10 @@ function TrendingSkillListItem({ item }: { item: TrendingSkillListEntry }) { ) : null}
- {typeof trending.metrics.trending24hInstalls === "number" ? ( + {typeof trending.metrics.trending24hDownloads === "number" ? ( ) : null}
@@ -94,11 +94,11 @@ function TrendingSkillCard({ item }: { item: TrendingSkillListEntry }) { {trending.summary}

) : null} - {typeof trending.metrics.trending24hInstalls === "number" ? ( + {typeof trending.metrics.trending24hDownloads === "number" ? (
) : null}