From 078425f074da7a8899fe3555db12fc7fe045c7de Mon Sep 17 00:00:00 2001 From: Jesse Merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Tue, 16 Jun 2026 02:28:26 +1000 Subject: [PATCH] feat: add plugin install ranking (#2633) --- convex/downloadMetrics.test.ts | 24 ++++++ convex/downloadMetrics.ts | 20 ++++- convex/httpApiV1.handlers.test.ts | 13 +++- convex/httpApiV1/packagesV1.ts | 32 ++++++-- convex/packages.public.test.ts | 57 +++++++++++++- convex/packages.stats.test.ts | 78 ++++++++++++++++++- convex/packages.ts | 85 ++++++++++++++++----- convex/schema.ts | 22 ++++++ docs/api.md | 3 + docs/clawhub.md | 2 +- docs/http-api.md | 1 + src/__tests__/package-detail-route.test.tsx | 10 +-- src/__tests__/packages-route.test.tsx | 30 ++++++-- src/components/PluginListItem.tsx | 8 +- src/components/SkillHeader.test.tsx | 6 +- src/components/SkillHeader.tsx | 2 +- src/components/SkillListItem.tsx | 6 +- src/components/SkillStats.tsx | 6 +- src/lib/packageApi.test.ts | 19 +++-- src/lib/packageApi.ts | 2 +- src/routes/index.tsx | 8 +- src/routes/plugins/$name.tsx | 4 +- src/routes/plugins/index.tsx | 14 +++- 23 files changed, 376 insertions(+), 76 deletions(-) diff --git a/convex/downloadMetrics.test.ts b/convex/downloadMetrics.test.ts index 7e658a54..00f4c2ea 100644 --- a/convex/downloadMetrics.test.ts +++ b/convex/downloadMetrics.test.ts @@ -232,6 +232,30 @@ describe("download metric helpers", () => { expect(delete_).toHaveBeenCalledWith("downloadMetricDedupes:two"); }); + it("prunes stale package install metric dedupe rows after download rows", async () => { + vi.setSystemTime(30 * 86_400_000); + const { db, delete_, indexCalls } = makeDb( + {}, + { + packageInstallMetricDedupes: [ + { _id: "packageInstallMetricDedupes:one" }, + { _id: "packageInstallMetricDedupes:two" }, + ], + }, + ); + + const result = await pruneDownloadMetricDedupesHandler({ db }, {}); + + expect(result).toEqual({ deleted: 2, hasMore: false }); + expect(indexCalls.map((call) => call.table)).toEqual([ + "downloadMetricDedupes", + "packageInstallMetricDedupes", + ]); + expect(indexCalls[1]?.indexName).toBe("by_day"); + expect(delete_).toHaveBeenCalledWith("packageInstallMetricDedupes:one"); + expect(delete_).toHaveBeenCalledWith("packageInstallMetricDedupes:two"); + }); + it("reschedules stale dedupe pruning when one bounded batch fills", async () => { vi.setSystemTime(30 * 86_400_000); const rows = Array.from({ length: 200 }, (_, index) => ({ diff --git a/convex/downloadMetrics.ts b/convex/downloadMetrics.ts index f2f4a566..40a2af74 100644 --- a/convex/downloadMetrics.ts +++ b/convex/downloadMetrics.ts @@ -105,16 +105,28 @@ export const pruneDownloadMetricDedupesInternal = internalMutation({ args: {}, handler: async (ctx) => { const cutoffDayStart = getDayStart(Date.now() - DEDUPE_RETENTION_MS); - const stale = await ctx.db + const staleDownloads = await ctx.db .query("downloadMetricDedupes") .withIndex("by_day", (q) => q.lt("dayStart", cutoffDayStart)) .take(PRUNE_BATCH_SIZE); + const remainingBatchSize = PRUNE_BATCH_SIZE - staleDownloads.length; + const staleInstalls = + remainingBatchSize > 0 + ? await ctx.db + .query("packageInstallMetricDedupes") + .withIndex("by_day", (q) => q.lt("dayStart", cutoffDayStart)) + .take(remainingBatchSize) + : []; - for (const entry of stale) { + for (const entry of staleDownloads) { + await ctx.db.delete(entry._id); + } + for (const entry of staleInstalls) { await ctx.db.delete(entry._id); } - const hasMore = stale.length === PRUNE_BATCH_SIZE; + const deleted = staleDownloads.length + staleInstalls.length; + const hasMore = deleted === PRUNE_BATCH_SIZE; if (hasMore) { await ctx.scheduler.runAfter( 0, @@ -123,7 +135,7 @@ export const pruneDownloadMetricDedupesInternal = internalMutation({ ); } - return { deleted: stale.length, hasMore }; + return { deleted, hasMore }; }, }); diff --git a/convex/httpApiV1.handlers.test.ts b/convex/httpApiV1.handlers.test.ts index a6633465..2bfdcd3b 100644 --- a/convex/httpApiV1.handlers.test.ts +++ b/convex/httpApiV1.handlers.test.ts @@ -10743,9 +10743,16 @@ describe("httpApiV1 handlers", () => { ); expect(response.status).toBe(200); - expect(runMutation).toHaveBeenCalledWith(internal.packages.recordPackageInstallInternal, { - packageId: "packages:demo-plugin", - }); + expect(runMutation).toHaveBeenCalledWith( + internal.packages.recordPackageInstallInternal, + expect.objectContaining({ + packageId: "packages:demo-plugin", + identityKind: "ip", + identityHash: expect.stringMatching(/^[a-f0-9]{64}$/), + dayStart: expect.any(Number), + occurredAt: expect.any(Number), + }), + ); expect(runMutation).toHaveBeenCalledWith( internal.downloadMetrics.recordDownloadMetricInternal, expect.objectContaining({ diff --git a/convex/httpApiV1/packagesV1.ts b/convex/httpApiV1/packagesV1.ts index 15313338..8f22970b 100644 --- a/convex/httpApiV1/packagesV1.ts +++ b/convex/httpApiV1/packagesV1.ts @@ -259,7 +259,7 @@ function normalizeCapabilityTagSegment(value: string) { const PACKAGE_FAMILY_VALUES = ["skill", "code-plugin", "bundle-plugin"] as const; const PLUGIN_EXPORT_FAMILY_VALUES = ["code-plugin", "bundle-plugin"] as const; const PACKAGE_CHANNEL_VALUES = ["official", "community", "private"] as const; -const PACKAGE_LIST_SORT_VALUES = ["updated", "downloads", "recommended"] as const; +const PACKAGE_LIST_SORT_VALUES = ["updated", "downloads", "recommended", "installs"] as const; const MAX_PLUGIN_EXPORT_FILE_COUNT = 10_000; const MAX_PLUGIN_EXPORT_PAGE_LIMIT = 250; const DEFAULT_PLUGIN_EXPORT_PAGE_LIMIT = 250; @@ -707,22 +707,34 @@ async function streamClawPackRelease( const blob = await ctx.storage.get(release.clawpackStorageId); if (!blob) return text("ClawPack artifact not found", 404, rateHeaders); try { + const identity = getDownloadIdentity(request, viewerUserId ? String(viewerUserId) : null); + const now = Date.now(); + const metricArgs = identity + ? await buildDownloadMetricArgs({ + target: { kind: "package", id: pkg._id }, + identity, + now, + }) + : null; if (statKind === "install") { await runMutationRef(ctx, internalRefs.packages.recordPackageInstallInternal, { packageId: pkg._id, + ...(metricArgs + ? { + identityKind: metricArgs.identityKind, + identityHash: metricArgs.identityHash, + dayStart: metricArgs.dayStart, + occurredAt: metricArgs.occurredAt, + } + : {}), }); } - const identity = getDownloadIdentity(request, viewerUserId ? String(viewerUserId) : null); - if (identity) { + if (metricArgs) { await runMutationRef( ctx, internalRefs.downloadMetrics.recordDownloadMetricInternal, - await buildDownloadMetricArgs({ - target: { kind: "package", id: pkg._id }, - identity, - now: Date.now(), - }), + metricArgs, ); } } catch { @@ -1013,6 +1025,10 @@ function compareCatalogItemsForSort( const downloads = (b.stats?.downloads ?? 0) - (a.stats?.downloads ?? 0); if (downloads !== 0) return downloads; } + if (sort === "installs") { + const installs = (b.stats?.installs ?? 0) - (a.stats?.installs ?? 0); + if (installs !== 0) return installs; + } return compareCatalogItems(a, b); } diff --git a/convex/packages.public.test.ts b/convex/packages.public.test.ts index ead70ecd..f9ee63ea 100644 --- a/convex/packages.public.test.ts +++ b/convex/packages.public.test.ts @@ -137,7 +137,7 @@ const listPublicPageHandler = ( executesCode?: boolean; capabilityTag?: string; category?: string; - sort?: "updated" | "downloads" | "recommended"; + sort?: "updated" | "downloads" | "recommended" | "installs"; paginationOpts: { cursor: string | null; numItems: number }; }, { page: Array<{ name: string }>; isDone: boolean; continueCursor: string } @@ -152,7 +152,7 @@ const listPageForViewerInternalHandler = ( executesCode?: boolean; capabilityTag?: string; category?: string; - sort?: "updated" | "downloads" | "recommended"; + sort?: "updated" | "downloads" | "recommended" | "installs"; viewerUserId?: string; paginationOpts: { cursor: string | null; numItems: number }; }, @@ -1129,6 +1129,8 @@ function makeDigestCtx(options: { if ( indexName === "by_active_downloads" || indexName === "by_active_family_downloads" || + indexName === "by_active_installs" || + indexName === "by_active_family_installs" || indexName === "by_active_recommended_rank" || indexName === "by_active_family_recommended_rank" || indexName === "by_active_recommended_score" || @@ -2305,6 +2307,57 @@ describe("packages public queries", () => { expect(paginate).toHaveBeenCalledWith({ cursor: null, numItems: 50 }); }); + it("uses a family-scoped installs index for install-sorted family pages", async () => { + const { ctx, indexFilters, indexNames, paginate } = makeDigestCtx({ + packagePages: [ + { + page: [ + makePackageDoc({ + _id: "packages:code-plugin-a", + name: "code-plugin-a", + normalizedName: "code-plugin-a", + displayName: "Code Plugin A", + family: "code-plugin", + stats: { downloads: 100, installs: 200, stars: 0, versions: 1 }, + }), + makePackageDoc({ + _id: "packages:code-plugin-b", + name: "code-plugin-b", + normalizedName: "code-plugin-b", + displayName: "Code Plugin B", + family: "code-plugin", + stats: { downloads: 500, installs: 100, stars: 0, versions: 1 }, + }), + ], + isDone: true, + continueCursor: "", + }, + ], + }); + + const result = await listPublicPageHandler(ctx, { + family: "code-plugin", + sort: "installs", + paginationOpts: { cursor: null, numItems: 1 }, + }); + + expect(result.page.map((entry) => entry.name)).toEqual(["code-plugin-a"]); + expect(result.isDone).toBe(false); + expect(result.continueCursor.startsWith("pkgpage:")).toBe(true); + expect(indexNames).toEqual(["by_active_family_installs"]); + expect(indexFilters).toEqual([ + { + indexName: "by_active_family_installs", + filters: [ + { field: "softDeletedAt", value: undefined }, + { field: "family", value: "code-plugin" }, + ], + }, + ]); + expect(paginate).toHaveBeenCalledTimes(1); + expect(paginate).toHaveBeenCalledWith({ cursor: null, numItems: 50 }); + }); + it("uses a family-scoped weighted recommended score index after backfill", async () => { const { ctx, indexFilters, indexNames, paginate } = makeDigestCtx({ packagePages: [ diff --git a/convex/packages.stats.test.ts b/convex/packages.stats.test.ts index f4852779..a22ac97e 100644 --- a/convex/packages.stats.test.ts +++ b/convex/packages.stats.test.ts @@ -20,7 +20,16 @@ const recordDownloadHandler = ( )._handler; const recordInstallHandler = ( - recordPackageInstallInternal as unknown as WrappedHandler<{ packageId: string }, void> + recordPackageInstallInternal as unknown as WrappedHandler< + { + packageId: string; + identityKind?: "user" | "ip"; + identityHash?: string; + dayStart?: number; + occurredAt?: number; + }, + void + > )._handler; const processStatsHandler = ( @@ -99,6 +108,73 @@ describe("package stat events", () => { ); }); + it("dedupes identity-backed installs before appending stat events", async () => { + const insert = vi.fn(); + const unique = vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce({ + _id: "packageInstallMetricDedupes:existing", + }); + const queryBuilder = { + eq: vi.fn(() => queryBuilder), + }; + const withIndex = vi.fn((_indexName: string, buildQuery: (q: unknown) => unknown) => { + buildQuery(queryBuilder); + return { unique }; + }); + const ctx = { + db: { + query: vi.fn(() => ({ withIndex })), + get: vi.fn(), + normalizeId: vi.fn(), + insert, + patch: vi.fn(), + replace: vi.fn(), + delete: vi.fn(), + system: { + get: vi.fn(), + query: vi.fn(), + }, + }, + }; + const args = { + packageId: "packages:one", + identityKind: "ip" as const, + identityHash: "hash-ip", + dayStart: 86_400_000, + occurredAt: 86_500_000, + }; + + await recordInstallHandler(ctx, args); + await recordInstallHandler(ctx, args); + + expect(withIndex).toHaveBeenCalledWith("by_target_metric_identity_day", expect.any(Function)); + expect(queryBuilder.eq).toHaveBeenCalledWith("targetKind", "package"); + expect(queryBuilder.eq).toHaveBeenCalledWith("targetId", "packages:one"); + expect(queryBuilder.eq).toHaveBeenCalledWith("metricKind", "install"); + expect(queryBuilder.eq).toHaveBeenCalledWith("identityKind", "ip"); + expect(queryBuilder.eq).toHaveBeenCalledWith("identityHash", "hash-ip"); + expect(queryBuilder.eq).toHaveBeenCalledWith("dayStart", 86_400_000); + expect(insert).toHaveBeenCalledWith( + "packageInstallMetricDedupes", + expect.objectContaining({ + targetKind: "package", + targetId: "packages:one", + metricKind: "install", + identityKind: "ip", + identityHash: "hash-ip", + dayStart: 86_400_000, + }), + ); + expect(insert).toHaveBeenCalledWith( + "packageStatEvents", + expect.objectContaining({ + packageId: "packages:one", + kind: "install", + occurredAt: 86_500_000, + }), + ); + expect(insert).toHaveBeenCalledTimes(2); + }); + it("aggregates queued downloads and installs before patching package stats", async () => { const events = [ { _id: "packageStatEvents:1", packageId: "packages:one", kind: "download" }, diff --git a/convex/packages.ts b/convex/packages.ts index b9be2833..2cc5c870 100644 --- a/convex/packages.ts +++ b/convex/packages.ts @@ -2684,7 +2684,12 @@ export const listPublicPage = query({ capabilityTag: v.optional(v.string()), category: v.optional(v.string()), sort: v.optional( - v.union(v.literal("updated"), v.literal("downloads"), v.literal("recommended")), + v.union( + v.literal("updated"), + v.literal("downloads"), + v.literal("recommended"), + v.literal("installs"), + ), ), paginationOpts: paginationOptsValidator, }, @@ -3154,7 +3159,12 @@ export const listPageForViewerInternal = internalQuery({ capabilityTag: v.optional(v.string()), category: v.optional(v.string()), sort: v.optional( - v.union(v.literal("updated"), v.literal("downloads"), v.literal("recommended")), + v.union( + v.literal("updated"), + v.literal("downloads"), + v.literal("recommended"), + v.literal("installs"), + ), ), viewerUserId: v.optional(v.id("users")), paginationOpts: paginationOptsValidator, @@ -3206,7 +3216,7 @@ async function listPackagePageImpl( executesCode?: boolean; capabilityTag?: string; category?: string; - sort?: "updated" | "downloads" | "recommended"; + sort?: "updated" | "downloads" | "recommended" | "installs"; viewerUserId?: Id<"users">; paginationOpts: { cursor: string | null; numItems: number }; }, @@ -3263,23 +3273,27 @@ async function listPackagePageImpl( : await getPackageRecommendedIndexName(ctx, family) : null; - if (args.sort === "downloads" || recommendedIndexName) { + if (args.sort === "downloads" || args.sort === "installs" || recommendedIndexName) { let cursor = pageCursor; let pageOffset = offset; let pageSize: number | null = decodedCursor.pageSize ?? null; let done = decodedCursor.done; - const buildSortedQuery = () => - family - ? ctx.db - .query("packages") - .withIndex(recommendedIndexName ?? "by_active_family_downloads", (q) => - q.eq("softDeletedAt", undefined).eq("family", family), - ) - : ctx.db - .query("packages") - .withIndex(recommendedIndexName ?? "by_active_downloads", (q) => - q.eq("softDeletedAt", undefined), - ); + const buildSortedQuery = () => { + if (family) { + const indexName = + args.sort === "installs" + ? "by_active_family_installs" + : (recommendedIndexName ?? "by_active_family_downloads"); + return ctx.db + .query("packages") + .withIndex(indexName, (q) => q.eq("softDeletedAt", undefined).eq("family", family)); + } + const indexName = + args.sort === "installs" + ? "by_active_installs" + : (recommendedIndexName ?? "by_active_downloads"); + return ctx.db.query("packages").withIndex(indexName, (q) => q.eq("softDeletedAt", undefined)); + }; while ((pageOffset > 0 || !done) && collected.length < targetCount) { const scanPageSize = Math.min( @@ -3593,12 +3607,47 @@ export const recordPackageDownloadInternal = internalMutation({ }); export const recordPackageInstallInternal = internalMutation({ - args: { packageId: v.id("packages") }, + args: { + packageId: v.id("packages"), + identityKind: v.optional(v.union(v.literal("user"), v.literal("ip"))), + identityHash: v.optional(v.string()), + dayStart: v.optional(v.number()), + occurredAt: v.optional(v.number()), + }, handler: async (ctx, args) => { + const identityKind = args.identityKind; + const identityHash = args.identityHash; + const dayStart = args.dayStart; + if (identityKind && identityHash && typeof dayStart === "number") { + const existing = await ctx.db + .query("packageInstallMetricDedupes") + .withIndex("by_target_metric_identity_day", (q) => + q + .eq("targetKind", "package") + .eq("targetId", args.packageId) + .eq("metricKind", "install") + .eq("identityKind", identityKind) + .eq("identityHash", identityHash) + .eq("dayStart", dayStart), + ) + .unique(); + if (existing) return; + + await ctx.db.insert("packageInstallMetricDedupes", { + targetKind: "package", + targetId: args.packageId, + metricKind: "install", + identityKind, + identityHash, + dayStart, + createdAt: Date.now(), + }); + } + await ctx.db.insert("packageStatEvents", { packageId: args.packageId, kind: "install", - occurredAt: Date.now(), + occurredAt: args.occurredAt ?? Date.now(), processedAt: undefined, }); }, diff --git a/convex/schema.ts b/convex/schema.ts index 9f841613..c14f358d 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -1168,6 +1168,8 @@ const packages = defineTable({ .index("by_active_updated", ["softDeletedAt", "updatedAt"]) .index("by_active_downloads", ["softDeletedAt", "stats.downloads", "updatedAt"]) .index("by_active_family_downloads", ["softDeletedAt", "family", "stats.downloads", "updatedAt"]) + .index("by_active_installs", ["softDeletedAt", "stats.installs", "updatedAt"]) + .index("by_active_family_installs", ["softDeletedAt", "family", "stats.installs", "updatedAt"]) .index("by_active_recommended_rank", [ "softDeletedAt", "stats.stars", @@ -2377,6 +2379,25 @@ const downloadMetricDedupes = defineTable({ ]) .index("by_day", ["dayStart"]); +const packageInstallMetricDedupes = defineTable({ + targetKind: v.literal("package"), + targetId: v.id("packages"), + metricKind: v.literal("install"), + identityKind: downloadMetricIdentityKind, + identityHash: v.string(), + dayStart: v.number(), + createdAt: v.number(), +}) + .index("by_target_metric_identity_day", [ + "targetKind", + "targetId", + "metricKind", + "identityKind", + "identityHash", + "dayStart", + ]) + .index("by_day", ["dayStart"]); + const reservedSlugs = defineTable({ slug: v.string(), originalOwnerUserId: v.id("users"), @@ -2560,6 +2581,7 @@ export default defineSchema({ rateLimitShards, downloadDedupes, downloadMetricDedupes, + packageInstallMetricDedupes, reservedSlugs, reservedHandles, githubBackupSyncState, diff --git a/docs/api.md b/docs/api.md index c8e13a05..7fc337c1 100644 --- a/docs/api.md +++ b/docs/api.md @@ -98,6 +98,9 @@ Public read: - `GET /api/v1/skills/{slug}/file?path=&version=&tag=` - `GET /api/v1/resolve?slug=&hash=` - `GET /api/v1/download?slug=&version=&tag=` +- `GET /api/v1/plugins?limit=&cursor=&sort=` + - `sort`: `recommended` (default), `installs`, `updated`, `downloads` +- `GET /api/v1/plugins/search?q=...` - `GET /api/v1/packages/{name}/versions/{version}/artifact` - `GET /api/v1/packages/{name}/versions/{version}/security` - `GET /api/v1/packages/{name}/versions/{version}/artifact/download` diff --git a/docs/clawhub.md b/docs/clawhub.md index 0b351eeb..90883691 100644 --- a/docs/clawhub.md +++ b/docs/clawhub.md @@ -54,7 +54,7 @@ pnpm add -g clawhub | Bundle plugins | Packaged plugin bundles for OpenClaw distribution | `clawhub package publish ` | ClawHub tracks semver versions, tags such as `latest`, changelogs, files, -downloads, stars, and security scan summaries. Public pages show current registry +installs, stars, and security scan summaries. Public pages show current registry state so users can inspect a skill or plugin before installing it. ## Native OpenClaw flows diff --git a/docs/http-api.md b/docs/http-api.md index 3c84d2c4..73e24457 100644 --- a/docs/http-api.md +++ b/docs/http-api.md @@ -616,6 +616,7 @@ Query params: - `cursor` (optional): pagination cursor - `isOfficial` (optional): `true` or `false` - `executesCode` (optional): `true` or `false` +- `sort` (optional): `recommended` (default), `installs`, `updated`, `downloads` - `capabilityTag` (optional): capability filter for plugin packages - `category` (optional): plugin category filter. Current values: `channels`, `mcp-tooling`, `data`, `security`, `observability`, diff --git a/src/__tests__/package-detail-route.test.tsx b/src/__tests__/package-detail-route.test.tsx index 88be3244..4433a61a 100644 --- a/src/__tests__/package-detail-route.test.tsx +++ b/src/__tests__/package-detail-route.test.tsx @@ -656,7 +656,7 @@ describe("plugin detail route", () => { expect(screen.queryByText("Verified")).toBeNull(); }); - it("renders plugin download counts in the metadata sidebar", async () => { + it("renders plugin install counts in the metadata sidebar", async () => { loaderDataMock = { ...loaderDataMock, detail: { @@ -673,12 +673,12 @@ describe("plugin detail route", () => { render(); - const downloadsLabel = screen.getByText("Downloads"); + const installsLabel = screen.getByText("Installs"); const currentVersionLabel = screen.getByText("Current version"); - expect(downloadsLabel.compareDocumentPosition(currentVersionLabel)).toBe( + expect(installsLabel.compareDocumentPosition(currentVersionLabel)).toBe( Node.DOCUMENT_POSITION_FOLLOWING, ); - expect(screen.getByText("1.2k")).toBeTruthy(); + expect(screen.getByText("9")).toBeTruthy(); }); it("shows plugin settings when the viewer can manage the plugin", async () => { @@ -867,7 +867,7 @@ describe("plugin detail route", () => { label?.startsWith("Security audit"), ); expect(securityAuditLabelIndex).toBeGreaterThanOrEqual(0); - expect(securityAuditLabelIndex).toBeGreaterThan(sidebarLabels.indexOf("Downloads")); + expect(securityAuditLabelIndex).toBeGreaterThan(sidebarLabels.indexOf("Installs")); expect(screen.queryByRole("tab", { name: "Capabilities" })).toBeNull(); expect(screen.queryByRole("tab", { name: "Verification" })).toBeNull(); }); diff --git a/src/__tests__/packages-route.test.tsx b/src/__tests__/packages-route.test.tsx index 1e8eb572..42bd0e70 100644 --- a/src/__tests__/packages-route.test.tsx +++ b/src/__tests__/packages-route.test.tsx @@ -361,7 +361,7 @@ describe("plugins route", () => { expect(fetchPluginCatalogMock.mock.calls[0]?.[0]).not.toHaveProperty("sort"); }); - it("forwards downloads sort for plugin browse", async () => { + it("forwards explicit plugin browse sorts", async () => { fetchPluginCatalogMock.mockResolvedValue({ items: [], nextCursor: null }); const { loadPluginsPageData } = await import("../routes/plugins/index"); @@ -375,6 +375,17 @@ describe("plugins route", () => { limit: 25, }), ); + + await loadPluginsPageData({ + sort: "installs", + }); + + expect(fetchPluginCatalogMock).toHaveBeenCalledWith( + expect.objectContaining({ + sort: "installs", + limit: 25, + }), + ); }); it("forwards category through catalog loading without changing the query", async () => { @@ -433,7 +444,7 @@ describe("plugins route", () => { }); }); - it("renders plugin download counts in browse results", async () => { + it("renders plugin install counts in browse results", async () => { loaderDataMock = { items: [ { @@ -445,7 +456,7 @@ describe("plugins route", () => { executesCode: true, createdAt: 1, updatedAt: 1, - stats: { downloads: 1_234, installs: 0, stars: 0, versions: 1 }, + stats: { downloads: 1_234, installs: 9, stars: 0, versions: 1 }, }, ], nextCursor: null, @@ -457,7 +468,7 @@ describe("plugins route", () => { render(); - expect(screen.getByText("1.2k")).toBeTruthy(); + expect(screen.getByText("9")).toBeTruthy(); }); it("renders the browse shell immediately while catalog data loads", async () => { @@ -854,6 +865,9 @@ describe("plugins route", () => { expect(validateSearch({ sort: "recommended" })).toEqual( expect.objectContaining({ sort: "recommended" }), ); + expect(validateSearch({ sort: "installs" })).toEqual( + expect.objectContaining({ sort: "installs" }), + ); expect(validateSearch({ sort: "relevance" })).toEqual( expect.objectContaining({ sort: "relevance" }), ); @@ -1030,6 +1044,7 @@ describe("plugins route", () => { expect(screen.getByRole("radio", { name: "Recommended" }).getAttribute("aria-checked")).toBe( "true", ); + expect(screen.getByRole("radio", { name: "Most installed" })).toBeTruthy(); expect(screen.getByRole("radio", { name: "Recently updated" })).toBeTruthy(); expect(screen.queryByRole("radio", { name: "Relevance" })).toBeNull(); }); @@ -1120,7 +1135,12 @@ describe("plugins route", () => { const sortOptions = Array.from( screen.getByRole("radiogroup", { name: "Sort order" }).querySelectorAll('[role="radio"]'), ).map((option) => option.textContent); - expect(sortOptions).toEqual(["Recommended", "Most downloaded", "Recently updated"]); + expect(sortOptions).toEqual([ + "Recommended", + "Most installed", + "Most downloaded", + "Recently updated", + ]); expect(screen.queryByRole("radio", { name: "Newest" })).toBeNull(); expect(screen.queryByRole("radio", { name: "Name" })).toBeNull(); }); diff --git a/src/components/PluginListItem.tsx b/src/components/PluginListItem.tsx index 6c129af2..96010ca6 100644 --- a/src/components/PluginListItem.tsx +++ b/src/components/PluginListItem.tsx @@ -1,5 +1,5 @@ import { Link } from "@tanstack/react-router"; -import { ArrowDownToLine } from "lucide-react"; +import { PackageCheck } from "lucide-react"; import { formatCompactStat } from "../lib/numberFormat"; import type { PackageListItem } from "../lib/packageApi"; import { MarketplaceIcon } from "./MarketplaceIcon"; @@ -11,7 +11,7 @@ type PluginListItemProps = { }; export function PluginListItem({ item, variant = "list" }: PluginListItemProps) { - const downloads = formatCompactStat(item.stats?.downloads ?? 0); + const installs = formatCompactStat(item.stats?.installs ?? 0); if (variant === "card") { return ( @@ -40,7 +40,7 @@ export function PluginListItem({ item, variant = "list" }: PluginListItemProps) v{item.latestVersion} ) : null} - {item.ownerHandle ? `@${item.ownerHandle}` : "community"} @@ -79,7 +79,7 @@ export function PluginListItem({ item, variant = "list" }: PluginListItemProps) v{item.latestVersion} ) : null} - {item.ownerHandle ? `@${item.ownerHandle}` : "community"} diff --git a/src/components/SkillHeader.test.tsx b/src/components/SkillHeader.test.tsx index a36178f8..618e09c3 100644 --- a/src/components/SkillHeader.test.tsx +++ b/src/components/SkillHeader.test.tsx @@ -121,8 +121,8 @@ describe("SkillHeader", () => { expect(onToggleStar).not.toHaveBeenCalled(); expect(onOpenReport).not.toHaveBeenCalled(); expect(screen.getByText("Owner")).toBeTruthy(); - expect(screen.getByText("Downloads")).toBeTruthy(); - expect(screen.getByText("2")).toBeTruthy(); + expect(screen.getByText("Installs")).toBeTruthy(); + expect(screen.getByText("3")).toBeTruthy(); expect(container.querySelector('a[href="/user/local"]')).toBeTruthy(); expect( container.querySelector('nav[aria-label="Skill breadcrumbs"] a[href="/user/local"]'), @@ -174,7 +174,7 @@ describe("SkillHeader", () => { it("hides archive-only metadata for source-backed skills", () => { renderHeader({ showArchiveMetadata: false }); - expect(screen.getByText("Downloads")).toBeTruthy(); + expect(screen.getByText("Installs")).toBeTruthy(); expect(screen.getByText("Owner")).toBeTruthy(); expect(screen.getByText("Last updated")).toBeTruthy(); expect(screen.queryByText("Current version")).toBeNull(); diff --git a/src/components/SkillHeader.tsx b/src/components/SkillHeader.tsx index 9c4297a1..da07e3ad 100644 --- a/src/components/SkillHeader.tsx +++ b/src/components/SkillHeader.tsx @@ -476,7 +476,7 @@ function SkillSidebarStats({ ariaLabel="Skill metadata" density="compact" blocks={[ - { label: "Downloads", value: formattedStats.downloads, large: true }, + { label: "Installs", value: formattedStats.installsAllTime, large: true }, { label: "Repository", value: githubRepositoryLink }, { label: "Owner", diff --git a/src/components/SkillListItem.tsx b/src/components/SkillListItem.tsx index 052fe24a..f700c1cc 100644 --- a/src/components/SkillListItem.tsx +++ b/src/components/SkillListItem.tsx @@ -1,5 +1,5 @@ import { Link } from "@tanstack/react-router"; -import { ArrowDownToLine, Star } from "lucide-react"; +import { PackageCheck, Star } from "lucide-react"; import { getSkillBadges } from "../lib/badges"; import { formatCompactStat } from "../lib/numberFormat"; import type { PublicPublisher, PublicSkill } from "../lib/publicUser"; @@ -49,8 +49,8 @@ export function SkillListItem({ skill, ownerHandle, owner }: SkillListItemProps) - diff --git a/src/components/SkillStats.tsx b/src/components/SkillStats.tsx index 41032baf..b01155a0 100644 --- a/src/components/SkillStats.tsx +++ b/src/components/SkillStats.tsx @@ -1,4 +1,4 @@ -import { ArrowDownToLine, Star } from "lucide-react"; +import { PackageCheck, Star } from "lucide-react"; import { formatSkillStatsTriplet, type SkillStatsTriplet } from "../lib/numberFormat"; export function SkillStatsTripletLine({ stats }: { stats: SkillStatsTriplet }) { @@ -11,8 +11,8 @@ export function SkillStatsTripletLine({ stats }: { stats: SkillStatsTriplet }) { ยท - ); diff --git a/src/lib/packageApi.test.ts b/src/lib/packageApi.test.ts index 45586018..be68f2c3 100644 --- a/src/lib/packageApi.test.ts +++ b/src/lib/packageApi.test.ts @@ -559,13 +559,11 @@ describe("fetchPluginCatalog", () => { expect(url.searchParams.get("isOfficial")).toBe("true"); }); - it("forwards downloads sort to the dedicated plugins browse endpoint", async () => { + it("forwards explicit sort values to the dedicated plugins browse endpoint", async () => { vi.stubEnv("VITE_CONVEX_URL", "https://registry.example"); - const fetchMock = vi - .spyOn(globalThis, "fetch") - .mockResolvedValue( - new Response(JSON.stringify({ items: [], nextCursor: null }), { status: 200 }), - ); + const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async () => { + return new Response(JSON.stringify({ items: [], nextCursor: null }), { status: 200 }); + }); await fetchPluginCatalog({ sort: "downloads", @@ -575,6 +573,15 @@ describe("fetchPluginCatalog", () => { const url = new URL(fetchMock.mock.calls[0]?.[0] as string); expect(url.pathname).toBe("/api/v1/plugins"); expect(url.searchParams.get("sort")).toBe("downloads"); + + await fetchPluginCatalog({ + sort: "installs", + limit: 20, + }); + + const installsUrl = new URL(fetchMock.mock.calls[1]?.[0] as string); + expect(installsUrl.pathname).toBe("/api/v1/plugins"); + expect(installsUrl.searchParams.get("sort")).toBe("installs"); }); it("uses the dedicated plugins search endpoint for search mode", async () => { diff --git a/src/lib/packageApi.ts b/src/lib/packageApi.ts index ecd63e5b..f85b7ec2 100644 --- a/src/lib/packageApi.ts +++ b/src/lib/packageApi.ts @@ -169,7 +169,7 @@ export type PackageVersionDetail = { }; type PluginFamily = "code-plugin" | "bundle-plugin"; -type PackageCatalogSort = "updated" | "downloads" | "recommended"; +type PackageCatalogSort = "updated" | "downloads" | "recommended" | "installs"; type PluginCatalogResult = { items: PackageListItem[]; diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 8080e3df..68e76bfe 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -560,7 +560,7 @@ function SkillsHome() { {formatStat(entry.skill.stats?.stars)} - {formatStat(entry.skill.stats?.downloads)} + {formatStat(entry.skill.stats?.installsAllTime)} @@ -596,7 +596,7 @@ function SkillsHome() { {formatStat(entry.skill.stats?.stars)} - {formatStat(entry.skill.stats?.downloads)} + {formatStat(entry.skill.stats?.installsAllTime)} @@ -677,7 +677,7 @@ function SkillsHome() {
12M - downloads + installs
@@ -727,7 +727,7 @@ function SkillsHome() { {formatStat(entry.skill.stats?.stars)} - {formatStat(entry.skill.stats?.downloads)} + {formatStat(entry.skill.stats?.installsAllTime)}
diff --git a/src/routes/plugins/$name.tsx b/src/routes/plugins/$name.tsx index 9bba1491..2d6813f4 100644 --- a/src/routes/plugins/$name.tsx +++ b/src/routes/plugins/$name.tsx @@ -729,8 +729,8 @@ function PluginDetailPageContent({ name, loaderData }: PluginDetailPageProps) { density="compact" blocks={[ { - label: "Downloads", - value: formatCompactStat(pkg.stats?.downloads ?? 0), + label: "Installs", + value: formatCompactStat(pkg.stats?.installs ?? 0), large: true, }, { label: "Repository", value: sourceRepoLink }, diff --git a/src/routes/plugins/index.tsx b/src/routes/plugins/index.tsx index ae0af947..f0aa807b 100644 --- a/src/routes/plugins/index.tsx +++ b/src/routes/plugins/index.tsx @@ -16,7 +16,7 @@ import { type PackageListItem, } from "../../lib/packageApi"; -type VisiblePluginSort = "recommended" | "updated" | "downloads"; +type VisiblePluginSort = "recommended" | "updated" | "downloads" | "installs"; type PluginSort = VisiblePluginSort | "relevance"; type LegacyPluginSort = PluginSort | "newest" | "name"; @@ -39,6 +39,7 @@ type LegacyPluginView = PluginView | "cards"; const PLUGIN_SORT_OPTIONS = [ { value: "recommended", label: "Recommended" }, + { value: "installs", label: "Most installed" }, { value: "downloads", label: "Most downloaded" }, { value: "updated", label: "Recently updated" }, ]; @@ -97,6 +98,7 @@ function parsePluginSort(value: unknown): LegacyPluginSort | undefined { value === "relevance" || value === "updated" || value === "downloads" || + value === "installs" || value === "newest" || value === "name" ) { @@ -118,6 +120,9 @@ function sortPluginSearchItems(items: PackageListItem[], sort: PluginSort) { if (sort === "downloads") { return (b.stats?.downloads ?? 0) - (a.stats?.downloads ?? 0) || tieBreak(); } + if (sort === "installs") { + return (b.stats?.installs ?? 0) - (a.stats?.installs ?? 0) || tieBreak(); + } return tieBreak(); }); @@ -145,7 +150,11 @@ export async function loadPluginsPageData( featured: args.featured, isOfficial: args.official, executesCode: args.executesCode, - ...(!args.q && (args.sort === "downloads" || !args.sort || args.sort === "recommended") + ...(!args.q && + (args.sort === "downloads" || + args.sort === "installs" || + !args.sort || + args.sort === "recommended") ? { sort: args.sort ?? "recommended" } : {}), limit: PLUGINS_PAGE_SIZE, @@ -223,6 +232,7 @@ export const Route = createFileRoute("/plugins/")({ search.sort !== "recommended" && search.sort !== "updated" && search.sort !== "downloads" && + search.sort !== "installs" && !(hasQuery && search.sort === "relevance"); const staleFeatured = Boolean(search.featured); const invalidCategory = Boolean(search.category && !isPluginCategorySlug(search.category));