mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
fix: show actual downloads in Trending (#3354)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user