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" ? (
- {formatCompactStat(item.metrics.trending24hInstalls)}
+ {formatCompactStat(item.metrics.trending24hDownloads)}
) : 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" ? (
- {formatCompactStat(item.metrics.trending24hInstalls)}
+ {formatCompactStat(item.metrics.trending24hDownloads)}
) : 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" ? (
- {formatCompactStat(trending.metrics.trending24hInstalls)}
+ {formatCompactStat(trending.metrics.trending24hDownloads)}
) : null}
@@ -94,11 +94,11 @@ function TrendingSkillCard({ item }: { item: TrendingSkillListEntry }) {
{trending.summary}
) : null}
- {typeof trending.metrics.trending24hInstalls === "number" ? (
+ {typeof trending.metrics.trending24hDownloads === "number" ? (
- {formatCompactStat(trending.metrics.trending24hInstalls)}
+ {formatCompactStat(trending.metrics.trending24hDownloads)}
) : null}