From 11d70e3f8809a1b1694c0496d244a986333042e1 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 25 Jun 2026 15:11:24 +0800 Subject: [PATCH] feat(search): add freshness-aware discovery ranking feat(search): add freshness-aware discovery ranking --- CHANGELOG.md | 1 + convex/_generated/api.d.ts | 2 + convex/crons.test.ts | 4 + convex/crons.ts | 14 ++ convex/httpApiV1/packagesV1.ts | 41 +++++- convex/lib/recommendationScore.test.ts | 28 ++++ convex/lib/recommendationScore.ts | 42 +++++- convex/lib/retentionPolicy.ts | 4 + convex/lib/skillSearchDigest.ts | 16 ++- convex/packageLeaderboards.test.ts | 72 ++++++++++ convex/packageLeaderboards.ts | 133 ++++++++++++++++++ convex/packages.public.test.ts | 3 +- convex/packages.ts | 150 ++++++++++++++++----- convex/schema.ts | 20 ++- convex/search.ts | 10 +- convex/skills.listPublicPageV4.test.ts | 2 +- convex/skills.ts | 32 ++++- convex/statsMaintenance.ts | 32 +++-- docs/http-api.md | 8 +- specs/search-relevance.md | 16 +++ src/__tests__/packages-route.test.tsx | 26 ++-- src/__tests__/skills-index.test.tsx | 4 +- src/lib/packageApi.ts | 2 +- src/routes/plugins/index.tsx | 26 ++-- src/routes/skills/-params.ts | 7 +- src/routes/skills/-useSkillsBrowseModel.ts | 17 ++- src/routes/skills/index.tsx | 22 ++- 27 files changed, 633 insertions(+), 101 deletions(-) create mode 100644 convex/packageLeaderboards.test.ts create mode 100644 convex/packageLeaderboards.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 49c70ffb..c83790b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Changes - Web: organization publishers can upload durable PNG, JPEG, or WebP logos from settings instead of relying on hotlinked image URLs. +- Web/API: make default skill and plugin discovery freshness-aware, add seven-day trending views for both catalogs, and use verified status plus usage as search tie-breakers within direct matches. ## 0.23.0 - 2026-06-23 diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 74909acd..78a5ce2a 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -133,6 +133,7 @@ import type * as packageInspectorHttp from "../packageInspectorHttp.js"; import type * as packageInspectorNode from "../packageInspectorNode.js"; import type * as packagePublishTokens from "../packagePublishTokens.js"; import type * as packages from "../packages.js"; +import type * as packageLeaderboards from "../packageLeaderboards.js"; import type * as publisherAbuse from "../publisherAbuse.js"; import type * as publisherAbuseDevSeed from "../publisherAbuseDevSeed.js"; import type * as publishers from "../publishers.js"; @@ -287,6 +288,7 @@ declare const fullApi: ApiFromModules<{ packageInspectorNode: typeof packageInspectorNode; packagePublishTokens: typeof packagePublishTokens; packages: typeof packages; + packageLeaderboards: typeof packageLeaderboards; publisherAbuse: typeof publisherAbuse; publisherAbuseDevSeed: typeof publisherAbuseDevSeed; publishers: typeof publishers; diff --git a/convex/crons.test.ts b/convex/crons.test.ts index 589f4cb5..997e9109 100644 --- a/convex/crons.test.ts +++ b/convex/crons.test.ts @@ -32,8 +32,12 @@ vi.mock("./_generated/api", () => ({ internal: { githubSkillSyncNode: { syncGitHubSkillSourcesInternal: mocks.githubSkillSyncRef }, leaderboards: { rebuildTrendingLeaderboardAction: Symbol("trending-leaderboard") }, + packageLeaderboards: { + rebuildTrendingLeaderboardAction: Symbol("package-trending-leaderboard"), + }, statsMaintenance: { runSkillStatBackfillInternal: Symbol("skill-stats-backfill"), + runRecommendationScoreBackfillInternal: Symbol("recommendation-score-refresh"), updateGlobalStatsAction: Symbol("global-stats-update"), }, skillStatEvents: { diff --git a/convex/crons.ts b/convex/crons.ts index 2850b5cd..9595b23c 100644 --- a/convex/crons.ts +++ b/convex/crons.ts @@ -19,6 +19,20 @@ if (process.env.CLAWHUB_DISABLE_CRONS !== "1") { { limit: 200 }, ); + crons.interval( + "package-trending-leaderboard", + { minutes: 60 }, + internal.packageLeaderboards.rebuildTrendingLeaderboardAction, + { limit: 200 }, + ); + + crons.interval( + "recommendation-score-refresh", + { hours: 6 }, + internal.statsMaintenance.runRecommendationScoreBackfillInternal, + { batchSize: 500, maxBatches: 50 }, + ); + crons.interval( "skill-stats-backfill", { hours: 6 }, diff --git a/convex/httpApiV1/packagesV1.ts b/convex/httpApiV1/packagesV1.ts index 169eca42..5362d411 100644 --- a/convex/httpApiV1/packagesV1.ts +++ b/convex/httpApiV1/packagesV1.ts @@ -310,7 +310,13 @@ async function getOptionalViewerUserIdForRequest(ctx: ActionCtx, request: Reques 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", "recommended", "downloads", "installs"] as const; +const PACKAGE_LIST_SORT_VALUES = [ + "updated", + "recommended", + "downloads", + "installs", + "trending", +] as const; const PACKAGE_SCAN_STATUS_VALUES = [ "clean", "suspicious", @@ -1587,6 +1593,13 @@ async function listPackages( rate.headers, ); } + if (effectiveSort === "trending" && includeSkills) { + return text( + "Trending sort is only supported for plugin package endpoints; use /api/v1/skills?sort=trending for skills.", + 400, + rate.headers, + ); + } if (effectiveFamily === "skill") { const cursor = isLegacyInstallSortRequest @@ -1737,6 +1750,32 @@ async function listPackages( ); } + if (!effectiveFamily && options?.pluginFamilies?.length && effectiveSort === "trending") { + const result = await runQueryRef<{ + page: CatalogListItem[]; + isDone: boolean; + continueCursor: string | null; + }>(ctx, internalRefs.packages.listPageForViewerInternal, { + channel: channelParam.value, + isOfficial: isOfficial.value, + highlightedOnly: highlightedOnly || undefined, + category, + topic, + excludedScanStatuses: excludedScanStatuses.value, + sort: "trending", + viewerUserId: viewerUserId ?? undefined, + paginationOpts: { cursor: rawCursor, numItems: limit }, + }); + return json( + { + items: result.page, + nextCursor: result.isDone ? null : result.continueCursor, + }, + 200, + rate.headers, + ); + } + if (!effectiveFamily && options?.pluginFamilies?.length) { const shouldMarkDefaultDownloadCursor = !sortParam.value && pluginDefaultSort === RECOMMENDED_FALLBACK_SORT; diff --git a/convex/lib/recommendationScore.test.ts b/convex/lib/recommendationScore.test.ts index fddbb463..d82660fc 100644 --- a/convex/lib/recommendationScore.test.ts +++ b/convex/lib/recommendationScore.test.ts @@ -44,4 +44,32 @@ describe("recommendationScore", () => { expect(secondThousand - firstThousand).toBeLessThan(firstThousand); }); + + it("gives fresh items a bounded discovery boost", () => { + const now = Date.UTC(2026, 5, 25); + const fresh = computeRecommendationScore( + { downloads: 0, installs: 0, stars: 0 }, + { createdAt: now, updatedAt: now, now }, + ); + const stale = computeRecommendationScore( + { downloads: 0, installs: 0, stars: 0 }, + { createdAt: now - 365 * 86_400_000, updatedAt: now - 365 * 86_400_000, now }, + ); + + expect(fresh).toBeGreaterThan(stale); + expect(fresh).toBeLessThan(200); + }); + + it("reduces historical download dominance", () => { + const recentInstall = computeRecommendationScore( + { downloads: 100, installs: 100, stars: 5 }, + { updatedAt: Date.UTC(2026, 5, 25), now: Date.UTC(2026, 5, 25) }, + ); + const staleDownload = computeRecommendationScore( + { downloads: 1_000, installs: 0, stars: 0 }, + { updatedAt: Date.UTC(2024, 0, 1), now: Date.UTC(2026, 5, 25) }, + ); + + expect(recentInstall).toBeGreaterThan(staleDownload); + }); }); diff --git a/convex/lib/recommendationScore.ts b/convex/lib/recommendationScore.ts index b75b0f7c..927b5109 100644 --- a/convex/lib/recommendationScore.ts +++ b/convex/lib/recommendationScore.ts @@ -4,23 +4,53 @@ export type RecommendationStats = { stars: number; }; -const DOWNLOAD_WEIGHT = 100; -const INSTALL_WEIGHT = 160; -const STAR_WEIGHT = 120; +export type RecommendationContext = { + createdAt?: number; + updatedAt?: number; + now?: number; +}; + +const DOWNLOAD_WEIGHT = 55; +const INSTALL_WEIGHT = 150; +const STAR_WEIGHT = 115; +const FRESHNESS_WEIGHT = 70; +const NOVELTY_WEIGHT = 100; +const FRESHNESS_HALF_LIFE_DAYS = 120; +const NOVELTY_WINDOW_DAYS = 45; +const DAY_MS = 24 * 60 * 60 * 1_000; // Bump this when changing weights, then run statsMaintenance:runRecommendationScoreBackfillInternal. -export const RECOMMENDATION_SCORE_VERSION = 3; +export const RECOMMENDATION_SCORE_VERSION = 4; function safeCount(value: number) { if (!Number.isFinite(value) || value <= 0) return 0; return value; } -export function computeRecommendationScore(stats: RecommendationStats) { +function getAgeDays(timestamp: number | undefined, now: number) { + if (!Number.isFinite(timestamp)) return null; + return Math.max(0, (now - (timestamp as number)) / DAY_MS); +} + +export function computeRecommendationScore( + stats: RecommendationStats, + context: RecommendationContext = {}, +) { const downloads = Math.sqrt(safeCount(stats.downloads)) * DOWNLOAD_WEIGHT; const installs = Math.sqrt(safeCount(stats.installs)) * INSTALL_WEIGHT; const stars = Math.sqrt(safeCount(stats.stars)) * STAR_WEIGHT; - return Math.round(downloads + installs + stars); + const now = Number.isFinite(context.now) ? (context.now as number) : Date.now(); + const updatedAgeDays = getAgeDays(context.updatedAt, now); + const createdAgeDays = getAgeDays(context.createdAt, now); + const freshness = + updatedAgeDays === null + ? 0 + : Math.exp((-Math.LN2 * updatedAgeDays) / FRESHNESS_HALF_LIFE_DAYS) * FRESHNESS_WEIGHT; + const novelty = + createdAgeDays === null || createdAgeDays > NOVELTY_WINDOW_DAYS + ? 0 + : (1 - createdAgeDays / NOVELTY_WINDOW_DAYS) * NOVELTY_WEIGHT; + return Math.round(downloads + installs + stars + freshness + novelty); } export function compareRecommendationStats(a: RecommendationStats, b: RecommendationStats) { diff --git a/convex/lib/retentionPolicy.ts b/convex/lib/retentionPolicy.ts index 85fb8f58..096c4835 100644 --- a/convex/lib/retentionPolicy.ts +++ b/convex/lib/retentionPolicy.ts @@ -134,6 +134,10 @@ export const RETENTION_POLICIES = { retention: "Processed and older than 7 days.", }), packageDailyStats: permanent("Daily aggregate package stats are product analytics."), + packageLeaderboards: derived( + "Package trending snapshots can be rebuilt from packageDailyStats.", + "packageDailyStats", + ), packageTrustedPublishers: permanent("Trusted publishing configuration."), packagePublishTokens: ephemeral("Package publish tokens expire and can be revoked.", { expirationField: "expiresAt", diff --git a/convex/lib/skillSearchDigest.ts b/convex/lib/skillSearchDigest.ts index 477e24c4..cd56b000 100644 --- a/convex/lib/skillSearchDigest.ts +++ b/convex/lib/skillSearchDigest.ts @@ -93,11 +93,17 @@ export function extractDigestFields(skill: Doc<"skills">): SkillSearchDigestFiel statsStars, statsInstallsCurrent, statsInstallsAllTime, - recommendedScore: computeRecommendationScore({ - downloads: statsDownloads, - installs: statsInstallsAllTime, - stars: statsStars, - }), + recommendedScore: computeRecommendationScore( + { + downloads: statsDownloads, + installs: statsInstallsAllTime, + stars: statsStars, + }, + { + createdAt: skill.createdAt, + updatedAt: skill.updatedAt, + }, + ), recommendedScoreVersion: RECOMMENDATION_SCORE_VERSION, skillId: skill._id, normalizedSlug: normalizeSkillSearchText(skill.slug), diff --git a/convex/packageLeaderboards.test.ts b/convex/packageLeaderboards.test.ts new file mode 100644 index 00000000..88eb7c8e --- /dev/null +++ b/convex/packageLeaderboards.test.ts @@ -0,0 +1,72 @@ +/* @vitest-environment node */ +import { describe, expect, it, vi } from "vitest"; +import { + rebuildTrendingLeaderboardAction, + rebuildTrendingLeaderboardInternal, +} from "./packageLeaderboards"; + +const mutationHandler = ( + rebuildTrendingLeaderboardInternal as unknown as { + _handler: (ctx: unknown, args: { limit?: number }) => Promise; + } +)._handler; +const actionHandler = ( + rebuildTrendingLeaderboardAction as unknown as { + _handler: (ctx: unknown, args: { limit?: number }) => Promise; + } +)._handler; + +describe("packageLeaderboards", () => { + it("schedules a bounded leaderboard rebuild", async () => { + const runAfter = vi.fn().mockResolvedValue("job-1"); + const result = await mutationHandler( + { + db: { + get: vi.fn(), + insert: vi.fn(), + normalizeId: vi.fn(), + patch: vi.fn(), + query: vi.fn(), + replace: vi.fn(), + system: { get: vi.fn(), query: vi.fn() }, + delete: vi.fn(), + }, + scheduler: { runAfter }, + }, + { limit: 500 }, + ); + + expect(runAfter).toHaveBeenCalledWith(0, expect.anything(), { limit: 200 }); + expect(result).toEqual({ ok: true, count: 0, scheduled: true, days: 7 }); + }); + + it("aggregates recent installs and downloads into a weighted top list", async () => { + const runQuery = vi.fn(async (_ref: unknown, args: Record) => { + if (args.day === Math.floor(Date.now() / 86_400_000)) { + return { + rows: [ + { packageId: "packages:one", installs: 2, downloads: 1 }, + { packageId: "packages:two", installs: 0, downloads: 8 }, + ], + isDone: true, + continueCursor: "", + }; + } + return { rows: [], isDone: true, continueCursor: "" }; + }); + const runMutation = vi.fn().mockResolvedValue({ ok: true }); + + const result = await actionHandler({ runQuery, runMutation }, { limit: 5 }); + + expect(result).toEqual({ ok: true, count: 2 }); + expect(runMutation).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + items: [ + expect.objectContaining({ packageId: "packages:two", score: 8 }), + expect.objectContaining({ packageId: "packages:one", score: 7 }), + ], + }), + ); + }); +}); diff --git a/convex/packageLeaderboards.ts b/convex/packageLeaderboards.ts new file mode 100644 index 00000000..1da46f77 --- /dev/null +++ b/convex/packageLeaderboards.ts @@ -0,0 +1,133 @@ +import { v } from "convex/values"; +import { internal } from "./_generated/api"; +import type { Id } from "./_generated/dataModel"; +import { internalAction, internalMutation, internalQuery } from "./functions"; +import { getTrendingRange, topN, TRENDING_DAYS } from "./lib/leaderboards"; + +const MAX_TRENDING_LIMIT = 200; +const DAILY_STATS_PAGE_SIZE = 1_000; +const KEEP_LEADERBOARD_ENTRIES = 3; +export const PACKAGE_TRENDING_LEADERBOARD_KIND = "package_trending"; + +export const getDailyStatsPage = internalQuery({ + args: { + day: v.number(), + cursor: v.union(v.string(), v.null()), + limit: v.optional(v.number()), + }, + handler: async (ctx, { day, cursor, limit }) => { + const page = await ctx.db + .query("packageDailyStats") + .withIndex("by_day", (q) => q.eq("day", day)) + .paginate({ + cursor, + numItems: Math.min(limit ?? DAILY_STATS_PAGE_SIZE, DAILY_STATS_PAGE_SIZE), + }); + + return { + rows: page.page.map((row) => ({ + packageId: row.packageId, + installs: row.installs, + downloads: row.downloads, + })), + isDone: page.isDone, + continueCursor: page.continueCursor, + }; + }, +}); + +export const writeTrendingLeaderboard = internalMutation({ + args: { + items: v.array( + v.object({ + packageId: v.id("packages"), + score: v.number(), + installs: v.number(), + downloads: v.number(), + }), + ), + startDay: v.number(), + endDay: v.number(), + }, + handler: async (ctx, { items, startDay, endDay }) => { + await ctx.db.insert("packageLeaderboards", { + kind: PACKAGE_TRENDING_LEADERBOARD_KIND, + generatedAt: Date.now(), + rangeStartDay: startDay, + rangeEndDay: endDay, + items, + }); + + const recent = await ctx.db + .query("packageLeaderboards") + .withIndex("by_kind", (q) => q.eq("kind", PACKAGE_TRENDING_LEADERBOARD_KIND)) + .order("desc") + .take(KEEP_LEADERBOARD_ENTRIES + 5); + for (const entry of recent.slice(KEEP_LEADERBOARD_ENTRIES)) { + await ctx.db.delete(entry._id); + } + return { ok: true as const, count: items.length }; + }, +}); + +export const rebuildTrendingLeaderboardAction = internalAction({ + args: { limit: v.optional(v.number()) }, + handler: async (ctx, args): Promise<{ ok: true; count: number }> => { + const limit = Math.min(Math.max(args.limit ?? MAX_TRENDING_LIMIT, 1), MAX_TRENDING_LIMIT); + const now = Date.now(); + const { startDay, endDay } = getTrendingRange(now); + const totals = new Map, { installs: number; downloads: number }>(); + + for (let day = startDay; day <= endDay; day += 1) { + let cursor: string | null = null; + let isDone = false; + while (!isDone) { + const page = (await ctx.runQuery(internal.packageLeaderboards.getDailyStatsPage, { + day, + cursor, + limit: DAILY_STATS_PAGE_SIZE, + })) as { + rows: Array<{ packageId: Id<"packages">; installs: number; downloads: number }>; + isDone: boolean; + continueCursor: string; + }; + for (const row of page.rows) { + const current = totals.get(row.packageId) ?? { installs: 0, downloads: 0 }; + current.installs += row.installs; + current.downloads += row.downloads; + totals.set(row.packageId, current); + } + cursor = page.continueCursor; + isDone = page.isDone; + } + } + + const entries = Array.from(totals, ([packageId, entry]) => ({ + packageId, + installs: entry.installs, + downloads: entry.downloads, + score: entry.installs * 3 + entry.downloads, + })).sort((a, b) => b.score - a.score || b.downloads - a.downloads || b.installs - a.installs); + + await ctx.runMutation(internal.packageLeaderboards.writeTrendingLeaderboard, { + items: topN( + entries, + limit, + (a, b) => a.score - b.score || a.downloads - b.downloads || a.installs - b.installs, + ).sort((a, b) => b.score - a.score || b.downloads - a.downloads || b.installs - a.installs), + startDay, + endDay, + }); + return { ok: true as const, count: Math.min(entries.length, limit) }; + }, +}); + +export const rebuildTrendingLeaderboardInternal = internalMutation({ + args: { limit: v.optional(v.number()) }, + handler: async (ctx, args) => { + await ctx.scheduler.runAfter(0, internal.packageLeaderboards.rebuildTrendingLeaderboardAction, { + limit: Math.min(Math.max(args.limit ?? MAX_TRENDING_LIMIT, 1), MAX_TRENDING_LIMIT), + }); + return { ok: true as const, count: 0, scheduled: true as const, days: TRENDING_DAYS }; + }, +}); diff --git a/convex/packages.public.test.ts b/convex/packages.public.test.ts index 57b81159..77e92032 100644 --- a/convex/packages.public.test.ts +++ b/convex/packages.public.test.ts @@ -6446,7 +6446,8 @@ describe("packages public queries", () => { isOfficial: false, tags: {}, stats: { downloads: 0, installs: 0, stars: 0, versions: 0 }, - recommendedScore: 0, + recommendedScore: 170, + recommendedScoreVersion: 4, }), ); expect(insert).not.toHaveBeenCalledWith("packageReleases", expect.anything()); diff --git a/convex/packages.ts b/convex/packages.ts index 72f338eb..e0a50c53 100644 --- a/convex/packages.ts +++ b/convex/packages.ts @@ -117,6 +117,7 @@ import { matchesAllTokens, matchesExploratoryTokenPrefixes, tokenize } from "./l import { hashSkillFiles } from "./lib/skills"; import { buildDeterministicPackageZip } from "./lib/skillZip"; import { runStaticPublishScan } from "./lib/staticPublishScan"; +import { PACKAGE_TRENDING_LEADERBOARD_KIND } from "./packageLeaderboards"; import schema from "./schema"; const MAX_PUBLIC_LIST_PAGE_SIZE = 200; @@ -150,17 +151,26 @@ const INITIAL_PACKAGE_VT_SCAN_DELAY_MS = 30_000; const PLUGIN_EXPORT_FAMILIES = ["code-plugin", "bundle-plugin"] as const; const GET_PAGE_TIEBREAKER_FIELD_COUNT = 2; -function computePackageRecommendationScore(stats: Doc<"packages">["stats"]) { - return computeRecommendationScore({ - downloads: stats.downloads, - installs: stats.installs, - stars: stats.stars, - }); +function computePackageRecommendationScore( + stats: Doc<"packages">["stats"], + context?: { createdAt?: number; updatedAt?: number; now?: number }, +) { + return computeRecommendationScore( + { + downloads: stats.downloads, + installs: stats.installs, + stars: stats.stars, + }, + context, + ); } -function computePackageRecommendationPatch(stats: Doc<"packages">["stats"]) { +function computePackageRecommendationPatch( + stats: Doc<"packages">["stats"], + context?: { createdAt?: number; updatedAt?: number; now?: number }, +) { return { - recommendedScore: computePackageRecommendationScore(stats), + recommendedScore: computePackageRecommendationScore(stats, context), recommendedScoreVersion: RECOMMENDATION_SCORE_VERSION, }; } @@ -901,7 +911,7 @@ type PublicPageCursorState = { pageSize: number | null; done: boolean; mode?: "packages" | "digest"; - sort?: "updated" | "downloads" | "recommended" | "installs"; + sort?: "updated" | "downloads" | "recommended" | "installs" | "trending"; packageIndex?: "family-official-downloads"; }; const PUBLIC_PAGE_CURSOR_PREFIX = "pkgpage:"; @@ -1548,7 +1558,8 @@ function decodePublicPageCursor(raw: string | null | undefined): PublicPageCurso parsed.sort === "updated" || parsed.sort === "downloads" || parsed.sort === "recommended" || - parsed.sort === "installs" + parsed.sort === "installs" || + parsed.sort === "trending" ? parsed.sort : undefined, packageIndex: @@ -1683,12 +1694,30 @@ function packageSearchMatch( } function comparePackageSearchMatches< - T extends PackageSearchMatch & { package: Pick }, + T extends PackageSearchMatch & { + package: { + isOfficial: boolean; + updatedAt: number; + verificationTier?: PackageDigestLike["verificationTier"] | null; + stats?: { downloads: number; installs: number; stars: number } | null; + }; + }, >(a: T, b: T) { + const verificationRank = (tier: PackageDigestLike["verificationTier"] | null) => { + if (tier === "rebuild-verified") return 4; + if (tier === "provenance-verified") return 3; + if (tier === "source-linked") return 2; + if (tier === "structural") return 1; + return 0; + }; return ( a.rankTier - b.rankTier || b.score - a.score || Number(b.package.isOfficial) - Number(a.package.isOfficial) || + verificationRank(b.package.verificationTier) - verificationRank(a.package.verificationTier) || + (b.package.stats?.stars ?? 0) - (a.package.stats?.stars ?? 0) || + (b.package.stats?.installs ?? 0) - (a.package.stats?.installs ?? 0) || + (b.package.stats?.downloads ?? 0) - (a.package.stats?.downloads ?? 0) || b.package.updatedAt - a.package.updatedAt ); } @@ -1888,7 +1917,7 @@ function buildPackagePluginCategoryDigestQuery( family?: PackageFamily; channel?: PackageChannel; isOfficial?: boolean; - sort?: "updated" | "downloads" | "recommended" | "installs"; + sort?: "updated" | "downloads" | "recommended" | "installs" | "trending"; }, ) { const family = args.family; @@ -2114,7 +2143,7 @@ function buildPackageTopicDigestQuery( family?: PackageFamily; channel?: PackageChannel; isOfficial?: boolean; - sort?: "updated" | "downloads" | "recommended" | "installs"; + sort?: "updated" | "downloads" | "recommended" | "installs" | "trending"; }, ) { const family = args.family; @@ -2291,7 +2320,7 @@ async function mayHaveVisiblePackageCategoryDigest( category: PluginCategorySlug; topic?: string; excludedScanStatuses?: PackageListScanStatus[]; - sort?: "updated" | "downloads" | "recommended" | "installs"; + sort?: "updated" | "downloads" | "recommended" | "installs" | "trending"; viewerUserId?: Id<"users">; }, ) { @@ -2337,7 +2366,7 @@ async function takeVisiblePackageCategoryDigestPage( category: PluginCategorySlug; topic?: string; excludedScanStatuses?: PackageListScanStatus[]; - sort?: "updated" | "downloads" | "recommended" | "installs"; + sort?: "updated" | "downloads" | "recommended" | "installs" | "trending"; viewerUserId?: Id<"users">; numItems: number; }, @@ -2440,7 +2469,7 @@ async function fetchHighlightedPackagePage( category?: string; topic?: string; officialFirst?: boolean; - sort?: "updated" | "downloads" | "recommended" | "installs"; + sort?: "updated" | "downloads" | "recommended" | "installs" | "trending"; viewerUserId?: Id<"users">; numItems: number; }, @@ -3062,6 +3091,7 @@ export const listPublicPage = query({ v.literal("downloads"), v.literal("recommended"), v.literal("installs"), + v.literal("trending"), ), ), paginationOpts: paginationOptsValidator, @@ -3539,6 +3569,7 @@ export const listPageForViewerInternal = internalQuery({ v.literal("downloads"), v.literal("recommended"), v.literal("installs"), + v.literal("trending"), ), ), viewerUserId: v.optional(v.id("users")), @@ -3592,7 +3623,7 @@ async function listPackagePageImpl( topic?: string; officialFirst?: boolean; excludedScanStatuses?: PackageListScanStatus[]; - sort?: "updated" | "downloads" | "recommended" | "installs"; + sort?: "updated" | "downloads" | "recommended" | "installs" | "trending"; viewerUserId?: Id<"users">; paginationOpts: { cursor: string | null; numItems: number }; }, @@ -3615,6 +3646,45 @@ async function listPackagePageImpl( return { page: [], isDone: true, continueCursor: "" }; } + if (args.sort === "trending") { + const leaderboard = await ctx.db + .query("packageLeaderboards") + .withIndex("by_kind", (q) => q.eq("kind", PACKAGE_TRENDING_LEADERBOARD_KIND)) + .order("desc") + .first(); + if (!leaderboard) return { page: [], isDone: true, continueCursor: "" }; + + const cursorState = decodePublicPageCursor(args.paginationOpts.cursor); + const startIndex = cursorState.sort === "trending" ? cursorState.offset : 0; + const page: PublicPackageListItem[] = []; + let nextOffset = startIndex; + for (let index = startIndex; index < leaderboard.items.length; index += 1) { + const entry = leaderboard.items[index]; + nextOffset = index + 1; + const pkg = await ctx.db.get(entry.packageId); + if (!pkg || pkg.softDeletedAt) continue; + if (!(await canViewerReadPackage(ctx, pkg, viewerUserId, membershipCache))) continue; + if (!packageMatchesListFilters(pkg, { ...args, category, topic })) continue; + page.push(await toPublicPackageListItemFromPackage(ctx, pkg)); + if (page.length >= targetCount) break; + } + const isDone = nextOffset >= leaderboard.items.length; + return { + page, + isDone, + continueCursor: isDone + ? "" + : encodePublicPageCursor({ + cursor: null, + offset: nextOffset, + pageSize: targetCount, + done: false, + mode: "packages", + sort: "trending", + }), + }; + } + if (args.officialFirst && category && typeof args.isOfficial !== "boolean") { return await listOfficialFirstPackageCategoryPage(ctx, { ...args, @@ -3910,7 +3980,7 @@ async function listOfficialFirstPackageCategoryPage( category: PluginCategorySlug; topic?: string; excludedScanStatuses?: PackageListScanStatus[]; - sort?: "updated" | "downloads" | "recommended" | "installs"; + sort?: "updated" | "downloads" | "recommended" | "installs" | "trending"; viewerUserId?: Id<"users">; paginationOpts: { cursor: string | null; numItems: number }; }, @@ -4420,7 +4490,11 @@ export const processPackageStatEventsInternal = internalMutation({ }; await ctx.db.patch(pkg._id, { stats: nextStats, - ...computePackageRecommendationPatch(nextStats), + ...computePackageRecommendationPatch(nextStats, { + createdAt: pkg.createdAt ?? pkg._creationTime, + updatedAt: pkg.updatedAt, + now, + }), }); packagesUpdated += 1; } @@ -7827,12 +7901,19 @@ export const reservePackageNameInternal = internalMutation({ isOfficial: false, tags: {}, stats: { downloads: 0, installs: 0, stars: 0, versions: 0 }, - ...computePackageRecommendationPatch({ - downloads: 0, - installs: 0, - stars: 0, - versions: 0, - }), + ...computePackageRecommendationPatch( + { + downloads: 0, + installs: 0, + stars: 0, + versions: 0, + }, + { + createdAt: now, + updatedAt: now, + now, + }, + ), createdAt: now, updatedAt: now, }); @@ -8902,12 +8983,19 @@ export const insertReleaseInternal = internalMutation({ verification: args.verification, scanStatus: args.verification?.scanStatus, stats: { downloads: 0, installs: 0, stars: 0, versions: 0 }, - ...computePackageRecommendationPatch({ - downloads: 0, - installs: 0, - stars: 0, - versions: 0, - }), + ...computePackageRecommendationPatch( + { + downloads: 0, + installs: 0, + stars: 0, + versions: 0, + }, + { + createdAt: now, + updatedAt: now, + now, + }, + ), createdAt: now, updatedAt: now, })); diff --git a/convex/schema.ts b/convex/schema.ts index b6f303c5..fd86ac34 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -1806,7 +1806,24 @@ const packageDailyStats = defineTable({ downloads: v.number(), installs: v.number(), updatedAt: v.number(), -}).index("by_package_day", ["packageId", "day"]); +}) + .index("by_package_day", ["packageId", "day"]) + .index("by_day", ["day"]); + +const packageLeaderboards = defineTable({ + kind: v.string(), + generatedAt: v.number(), + rangeStartDay: v.number(), + rangeEndDay: v.number(), + items: v.array( + v.object({ + packageId: v.id("packages"), + score: v.number(), + installs: v.number(), + downloads: v.number(), + }), + ), +}).index("by_kind", ["kind", "generatedAt"]); const packageTrustedPublishers = defineTable({ packageId: v.id("packages"), @@ -2914,6 +2931,7 @@ export default defineSchema({ skillCardGenerationJobs, packageStatEvents, packageDailyStats, + packageLeaderboards, packageTrustedPublishers, packagePublishTokens, packagePublishUploadTickets, diff --git a/convex/search.ts b/convex/search.ts index e2e49fe7..1de3c3fa 100644 --- a/convex/search.ts +++ b/convex/search.ts @@ -228,6 +228,14 @@ function comparePopularityStats(a: PopularityStats, b: PopularityStats) { return b.stars - a.stars || (b.installsAllTime ?? 0) - (a.installsAllTime ?? 0); } +function compareSkillTrustAndUsage(a: SkillSearchEntry, b: SkillSearchEntry) { + return ( + Number(Boolean(b.owner?.official)) - Number(Boolean(a.owner?.official)) || + comparePopularityStats(a.skill.stats, b.skill.stats) || + (b.skill.stats.downloads ?? 0) - (a.skill.stats.downloads ?? 0) + ); +} + function mergeUniqueBySkillId(primary: SkillSearchEntry[], fallback: SkillSearchEntry[]) { if (fallback.length === 0) return primary; const out = [...primary]; @@ -488,7 +496,7 @@ export const searchSkills: ReturnType = action({ (a, b) => a.rankTier - b.rankTier || b.score - a.score || - comparePopularityStats(a.skill.stats, b.skill.stats) || + compareSkillTrustAndUsage(a, b) || b.skill.updatedAt - a.skill.updatedAt, ) .slice(0, limit); diff --git a/convex/skills.listPublicPageV4.test.ts b/convex/skills.listPublicPageV4.test.ts index 69e91b43..059a147a 100644 --- a/convex/skills.listPublicPageV4.test.ts +++ b/convex/skills.listPublicPageV4.test.ts @@ -146,8 +146,8 @@ describe("skills.listPublicPageV4", () => { expect(result.page.map((entry) => entry.skill.slug)).toEqual([ "downloads-skill", - "updated-skill", "installs-skill", + "updated-skill", "stars-skill", ]); }); diff --git a/convex/skills.ts b/convex/skills.ts index 2f94e802..283bfc90 100644 --- a/convex/skills.ts +++ b/convex/skills.ts @@ -5848,9 +5848,23 @@ export const listPublicTrendingPage = query({ args: { limit: v.optional(v.number()), nonSuspiciousOnly: v.optional(v.boolean()), + categorySlug: v.optional(v.string()), + topic: v.optional(v.string()), }, handler: async (ctx, args) => { const limit = clampInt(args.limit ?? 25, 1, MAX_PUBLIC_LIST_LIMIT); + const normalizedCategorySlug = args.categorySlug?.trim().toLowerCase(); + const categorySlug = + args.categorySlug === undefined + ? undefined + : normalizedCategorySlug && isSkillCategorySlug(normalizedCategorySlug) + ? normalizedCategorySlug + : null; + if (args.categorySlug !== undefined && categorySlug === null) { + return { items: [], nextCursor: null }; + } + const topic = args.topic === undefined ? undefined : normalizeCatalogTopic(args.topic); + if (args.topic !== undefined && !topic) return { items: [], nextCursor: null }; const kind = args.nonSuspiciousOnly ? TRENDING_NON_SUSPICIOUS_LEADERBOARD_KIND : TRENDING_LEADERBOARD_KIND; @@ -5870,6 +5884,8 @@ export const listPublicTrendingPage = query({ .unique(); if (!digest) continue; if (args.nonSuspiciousOnly && digest.isSuspicious) continue; + if (categorySlug && !resolveStoredSkillCategories(digest).includes(categorySlug)) continue; + if (topic && !getCatalogTopicSlugs(digest.topics).includes(topic)) continue; const item = await buildPublicSkillEntryFromDigest(ctx, digest); if (!item) continue; items.push(item); @@ -6965,11 +6981,17 @@ function readDigestRecommendationScore(digest: Doc<"skillSearchDigest">): number (digest.recommendedScoreVersion === RECOMMENDATION_SCORE_VERSION ? digest.recommendedScore : undefined) ?? - computeRecommendationScore({ - downloads: readDigestRankStat(digest, "downloads"), - installs: readDigestRankStat(digest, "installsAllTime"), - stars: readDigestRankStat(digest, "stars"), - }) + computeRecommendationScore( + { + downloads: readDigestRankStat(digest, "downloads"), + installs: readDigestRankStat(digest, "installsAllTime"), + stars: readDigestRankStat(digest, "stars"), + }, + { + createdAt: digest.createdAt, + updatedAt: digest.updatedAt, + }, + ) ); } diff --git a/convex/statsMaintenance.ts b/convex/statsMaintenance.ts index b4117bf3..c47831cb 100644 --- a/convex/statsMaintenance.ts +++ b/convex/statsMaintenance.ts @@ -408,19 +408,31 @@ function buildSkillStatPatch(skill: Doc<"skills">) { } function computeSkillDigestRecommendationScore(digest: Doc<"skillSearchDigest">) { - return computeRecommendationScore({ - downloads: digest.statsDownloads ?? digest.stats.downloads, - installs: digest.statsInstallsAllTime ?? digest.stats.installsAllTime ?? 0, - stars: digest.statsStars ?? digest.stats.stars, - }); + return computeRecommendationScore( + { + downloads: digest.statsDownloads ?? digest.stats.downloads, + installs: digest.statsInstallsAllTime ?? digest.stats.installsAllTime ?? 0, + stars: digest.statsStars ?? digest.stats.stars, + }, + { + createdAt: digest.createdAt, + updatedAt: digest.updatedAt, + }, + ); } function computePackageRecommendationScore(pkg: Doc<"packages">) { - return computeRecommendationScore({ - downloads: pkg.stats.downloads, - installs: pkg.stats.installs, - stars: pkg.stats.stars, - }); + return computeRecommendationScore( + { + downloads: pkg.stats.downloads, + installs: pkg.stats.installs, + stars: pkg.stats.stars, + }, + { + createdAt: pkg.createdAt ?? pkg._creationTime, + updatedAt: pkg.updatedAt, + }, + ); } /** diff --git a/docs/http-api.md b/docs/http-api.md index cfe33127..f53a68d7 100644 --- a/docs/http-api.md +++ b/docs/http-api.md @@ -548,7 +548,7 @@ Query params: - `family` (optional): `skill`, `code-plugin`, or `bundle-plugin` - `channel` (optional): `official`, `community`, or `private` - `isOfficial` (optional): `true` or `false` -- `sort` (optional): `updated` (default), `recommended`, `downloads`, legacy alias `installs` +- `sort` (optional): `updated` (default), `recommended`, `trending`, `downloads`, legacy alias `installs` - `category` (optional): plugin category filter. Supported only when the request is scoped to plugin packages (`/api/v1/plugins`, `/api/v1/code-plugins`, `/api/v1/bundle-plugins`, or package endpoints with @@ -598,7 +598,7 @@ Query params: - `limit` (optional): integer (1-100) - `cursor` (optional): pagination cursor - `isOfficial` (optional): `true` or `false` -- `sort` (optional): `recommended` (default), `downloads`, `updated`, legacy alias `installs` +- `sort` (optional): `recommended` (default), `trending`, `downloads`, `updated`, legacy alias `installs` - `category` (optional): plugin category filter. Current values: `channels`, `models`, `memory`, `context`, `voice`, `media`, `web`, `tools`, `runtime`, `gateway`, `security`, `other`. @@ -609,6 +609,10 @@ Legacy v1 filter aliases remain accepted on read endpoints: - `observability` and `deployment` resolve to `gateway`. - `dev-tools` resolves to `runtime`. +`trending` is a seven-day install/download leaderboard and does not use all-time totals. +On the unified `/api/v1/packages` endpoint it is plugin-only; use +`/api/v1/skills?sort=trending` for the skill catalog. + Legacy aliases are not accepted as stored or author-declared category values. ### `GET /api/v1/skills/export` diff --git a/specs/search-relevance.md b/specs/search-relevance.md index a0610a27..14697a05 100644 --- a/specs/search-relevance.md +++ b/specs/search-relevance.md @@ -27,3 +27,19 @@ only category or topic filter because limited global results can under-fill scop stop at an explicit safety scan budget, but the result limit applies after scoped matches are found. Search result counts in the web UI should describe what is known from the current request. Do not label a page-size-limited result length as a total corpus count. Prefer `N+`, "shown", or no count unless an indexed/materialized total is available. + +## Browse Discovery Ranking + +Browse defaults are a discovery surface, not a proxy for lifetime downloads. The materialized +recommendation score combines sublinear installs, downloads, and stars with a decaying freshness +signal and a bounded boost for newly published items. Download weight is intentionally lower than +install, star, and freshness signals so a large historical footprint cannot permanently occupy the +default page. Recommendation scores are refreshed by maintenance jobs because freshness changes even +when an item receives no new events. + +Trending is a separate seven-day activity leaderboard built from daily install and download +aggregates. It must not be derived from all-time totals. Skills and plugins expose the same +trending concept; suspicious or unavailable items are filtered before public display. + +Publisher diversity is a product follow-up for browse ranking. The current contract guarantees +freshness and bounded novelty, while preserving stable cursor pagination and trust filters. diff --git a/src/__tests__/packages-route.test.tsx b/src/__tests__/packages-route.test.tsx index 61727ef4..12226bc8 100644 --- a/src/__tests__/packages-route.test.tsx +++ b/src/__tests__/packages-route.test.tsx @@ -408,13 +408,13 @@ describe("plugins route", () => { expect.objectContaining({ cursor: "cursor:current", limit: 25, - sort: "downloads", + sort: "recommended", }), ); expect(fetchPluginCatalogMock.mock.calls[0]?.[0]).not.toHaveProperty("family"); }); - it("uses downloads as the plugin browse ranking", async () => { + it("uses recommendation ranking as the plugin browse default", async () => { fetchPluginCatalogMock.mockResolvedValue({ items: [], nextCursor: null }); const { loadPluginsPageData } = await import("../routes/plugins/index"); @@ -422,7 +422,7 @@ describe("plugins route", () => { expect(fetchPluginCatalogMock).toHaveBeenCalledWith( expect.objectContaining({ - sort: "downloads", + sort: "recommended", limit: 25, }), ); @@ -434,7 +434,7 @@ describe("plugins route", () => { await loadPluginsPageData({ q: "security", - sort: "downloads", + sort: "recommended", cursor: "cursor:search", }); @@ -453,12 +453,12 @@ describe("plugins route", () => { const { loadPluginsPageData } = await import("../routes/plugins/index"); await loadPluginsPageData({ - sort: "downloads", + sort: "recommended", }); expect(fetchPluginCatalogMock).toHaveBeenCalledWith( expect.objectContaining({ - sort: "downloads", + sort: "recommended", limit: 25, }), ); @@ -578,7 +578,7 @@ describe("plugins route", () => { expect.objectContaining({ category: "security", cursor: "cursor:next", - sort: "downloads", + sort: "recommended", }), ); expect(navigateMock).not.toHaveBeenCalled(); @@ -927,7 +927,7 @@ describe("plugins route", () => { expect(fetchPluginCatalogMock).toHaveBeenCalledWith( expect.objectContaining({ isOfficial: true, - sort: "downloads", + sort: "recommended", limit: 25, }), ); @@ -959,7 +959,7 @@ describe("plugins route", () => { family: undefined, cursor: undefined, featured: true, - sort: "downloads", + sort: "recommended", }); }); @@ -982,7 +982,7 @@ describe("plugins route", () => { cursor: undefined, family: undefined, featured: undefined, - sort: "downloads", + sort: "recommended", }); }); @@ -1318,7 +1318,7 @@ describe("plugins route", () => { featured: true, cursor: undefined, family: undefined, - sort: "downloads", + sort: "recommended", }); }); @@ -1339,7 +1339,7 @@ describe("plugins route", () => { cursor: undefined, family: undefined, featured: undefined, - sort: "downloads", + sort: undefined, }); }); @@ -1443,7 +1443,7 @@ describe("plugins route", () => { const sortOptions = Array.from( screen.getByRole("radiogroup", { name: "Sort order" }).querySelectorAll('[role="radio"]'), ).map((option) => option.textContent); - expect(sortOptions).toEqual(["All", "Verified", "Updated"]); + expect(sortOptions).toEqual(["All", "Trending", "Verified", "Updated"]); expect(screen.queryByRole("radio", { name: "Most downloaded" })).toBeNull(); expect(screen.queryByRole("radio", { name: "Newest" })).toBeNull(); expect(screen.queryByRole("radio", { name: "Name" })).toBeNull(); diff --git a/src/__tests__/skills-index.test.tsx b/src/__tests__/skills-index.test.tsx index dc585ec3..8a60a5ef 100644 --- a/src/__tests__/skills-index.test.tsx +++ b/src/__tests__/skills-index.test.tsx @@ -87,7 +87,7 @@ describe("SkillsIndex", () => { const sortOptions = Array.from( screen.getByRole("radiogroup", { name: "Skill view" }).querySelectorAll('[role="radio"]'), ).map((option) => option.textContent); - expect(sortOptions).toEqual(["All", "Top", "Most starred", "Featured"]); + expect(sortOptions).toEqual(["All", "Trending", "Top", "Most starred", "Featured"]); }); it("offers Top without exposing downloads as a browse view", async () => { @@ -108,7 +108,7 @@ describe("SkillsIndex", () => { fireEvent.click(screen.getByRole("combobox", { name: "Sort" })); const sortOptions = screen.getAllByRole("option").map((option) => option.textContent); - expect(views).toEqual(["All", "Top", "Most starred", "Featured"]); + expect(views).toEqual(["All", "Trending", "Top", "Most starred", "Featured"]); expect(sortOptions).toEqual(["Recently updated", "Newest", "Name"]); }); diff --git a/src/lib/packageApi.ts b/src/lib/packageApi.ts index b176897d..5794ab80 100644 --- a/src/lib/packageApi.ts +++ b/src/lib/packageApi.ts @@ -170,7 +170,7 @@ export type PackageVersionDetail = { }; type PluginFamily = "code-plugin" | "bundle-plugin"; -type PackageCatalogSort = "updated" | "recommended" | "downloads"; +type PackageCatalogSort = "updated" | "recommended" | "downloads" | "trending"; type PluginCatalogResult = { items: PackageListItem[]; diff --git a/src/routes/plugins/index.tsx b/src/routes/plugins/index.tsx index edfce933..6695bda6 100644 --- a/src/routes/plugins/index.tsx +++ b/src/routes/plugins/index.tsx @@ -29,7 +29,7 @@ import { } from "../../lib/packageApi"; import { useMediaQuery } from "../../lib/useMediaQuery"; -type VisiblePluginSort = "recommended" | "updated" | "downloads"; +type VisiblePluginSort = "recommended" | "updated" | "downloads" | "trending"; type PluginSort = VisiblePluginSort | "relevance"; type LegacyPluginSort = PluginSort | "newest" | "name" | "installs"; type PluginBrowseTab = VisiblePluginSort | "official"; @@ -52,7 +52,8 @@ type PluginView = "list" | "grid"; type LegacyPluginView = PluginView | "cards"; const PLUGIN_BROWSE_TABS = [ - { value: "downloads", label: "All" }, + { value: "recommended", label: "All" }, + { value: "trending", label: "Trending" }, { value: "official", label: "Verified", @@ -115,6 +116,7 @@ function parsePluginSort(value: unknown): LegacyPluginSort | undefined { value === "relevance" || value === "updated" || value === "downloads" || + value === "trending" || value === "installs" || value === "newest" || value === "name" @@ -151,7 +153,7 @@ function normalizeActivePluginSort(sort: LegacyPluginSort | undefined): PluginSo function getDefaultPluginBrowseSort( _args: Pick, ): VisiblePluginSort { - return "downloads"; + return "recommended"; } function hasPersistentPluginBrowseFilter( @@ -179,6 +181,7 @@ export async function loadPluginsPageData( ...(!args.q && (args.sort === "downloads" || args.sort === "updated" || + args.sort === "trending" || !args.sort || args.sort === "recommended") ? { sort: args.sort ?? getDefaultPluginBrowseSort(args) } @@ -271,6 +274,7 @@ export const Route = createFileRoute("/plugins/")({ search.sort !== "recommended" && search.sort !== "updated" && search.sort !== "downloads" && + search.sort !== "trending" && !(hasQuery && search.sort === "relevance"); const staleFeatured = Boolean(hasQuery && search.featured); if (incompatibleSort || staleFeatured) { @@ -300,7 +304,7 @@ function PluginsIndexPending() { {}} /> @@ -430,11 +434,7 @@ function PluginsIndex() { : search.sort === "relevance" || search.sort === "newest" || search.sort === "name" ? "recommended" : (search.sort ?? (hasQuery ? "recommended" : getDefaultPluginBrowseSort(search))); - const activeBrowseTab: PluginBrowseTab = search.official - ? "official" - : activeSort === "recommended" - ? "downloads" - : activeSort; + const activeBrowseTab: PluginBrowseTab = search.official ? "official" : activeSort; const visibleItems = useMemo(() => { return hasQuery ? sortPluginSearchItems(items, activeSort) : items; }, [activeSort, hasQuery, items]); @@ -453,11 +453,11 @@ function PluginsIndex() { return; } - handleSortChange(value ?? "downloads"); + handleSortChange(value ?? "recommended"); }; const handleSortChange = (value: string) => { - const nextSort = parsePluginSort(value) ?? "downloads"; + const nextSort = parsePluginSort(value) ?? "recommended"; void navigate({ search: (prev: PluginSearchState) => { @@ -466,8 +466,8 @@ function PluginsIndex() { const sort = isExplicitFilteredRecommendation || nextSort === "downloads" ? nextSort - : nextSort === "updated" - ? "updated" + : nextSort === "updated" || nextSort === "trending" + ? nextSort : undefined; const nextSearch: PluginSearchState = { ...prev, diff --git a/src/routes/skills/-params.ts b/src/routes/skills/-params.ts index 0507a3e4..ac53b7db 100644 --- a/src/routes/skills/-params.ts +++ b/src/routes/skills/-params.ts @@ -7,10 +7,11 @@ export const sortKeys = [ "stars", "name", "updated", + "trending", ] as const; export type SortKey = (typeof sortKeys)[number]; -export type ListSortKey = Exclude; +export type ListSortKey = Exclude; export type SortDir = "asc" | "desc"; export function parseSort(value: unknown): SortKey { @@ -27,5 +28,7 @@ export function parseDir(value: unknown, sort: SortKey): SortDir { } export function toListSort(sort: SortKey): ListSortKey | undefined { - return sort === "relevance" || sort === "recommended" || sort === "default" ? undefined : sort; + return sort === "relevance" || sort === "recommended" || sort === "default" || sort === "trending" + ? undefined + : sort; } diff --git a/src/routes/skills/-useSkillsBrowseModel.ts b/src/routes/skills/-useSkillsBrowseModel.ts index ddf18678..5800cf6c 100644 --- a/src/routes/skills/-useSkillsBrowseModel.ts +++ b/src/routes/skills/-useSkillsBrowseModel.ts @@ -87,7 +87,7 @@ export function useSkillsBrowseModel({ : requestedSort === "recommended" && hasQuery ? "relevance" : (requestedSort ?? (hasQuery ? "relevance" : "recommended")); - const listSort = toListSort(sort); + const listSort = sort === "trending" ? undefined : toListSort(sort); const dir = sort === "relevance" ? "desc" : parseDir(search.dir, sort); const searchKey = hasQuery ? `${trimmedQuery}::${featuredOnly ? "1" : "0"}::${activeCategory?.slug ?? ""}::${activeTopic ?? ""}` @@ -105,6 +105,20 @@ export function useSkillsBrowseModel({ let pageCursor = cursor; let consecutiveEmptyPages = 0; try { + if (sort === "trending") { + const result = await convexHttp.query(api.skills.listPublicTrendingPage, { + limit: pageSize, + nonSuspiciousOnly: true, + categorySlug: activeCategory?.slug, + topic: activeTopic, + }); + if (generation !== fetchGeneration.current) return; + setListResults(result.items); + setListCursor(null); + setListAutoLoadPaused(false); + setListStatus("done"); + return; + } while (true) { const result = await convexHttp.query(api.skills.listPublicPageV4, { cursor: pageCursor ?? undefined, @@ -158,6 +172,7 @@ export function useSkillsBrowseModel({ excludeCategoryKeywords, featuredOnly, listSort, + sort, ], ); diff --git a/src/routes/skills/index.tsx b/src/routes/skills/index.tsx index a20fe63f..c0aca553 100644 --- a/src/routes/skills/index.tsx +++ b/src/routes/skills/index.tsx @@ -30,6 +30,7 @@ import { const SKILLS_VIEW_OPTIONS = [ { value: "all", label: "All" }, + { value: "trending", label: "Trending" }, { value: "top", label: "Top" }, { value: "stars", label: "Most starred" }, { value: "featured", label: "Featured" }, @@ -86,11 +87,13 @@ export function SkillsIndex() { const activeView = model.featuredOnly ? "featured" - : model.sort === "downloads" - ? "top" - : model.sort === "stars" - ? "stars" - : "all"; + : model.sort === "trending" + ? "trending" + : model.sort === "downloads" + ? "top" + : model.sort === "stars" + ? "stars" + : "all"; const activeSort = ["updated", "newest", "name"].includes(model.sort) ? model.sort : undefined; const hasActiveFilters = model.hasQuery || Boolean(model.activeCategory) || model.featuredOnly; const totalSkillsCount = useQuery(api.skills.countPublicSkills, {}); @@ -109,6 +112,15 @@ export function SkillsIndex() { (value: string) => { void navigate({ search: (prev: SkillsSearchState) => { + if (value === "trending") { + return { + ...prev, + sort: "trending", + dir: "desc", + featured: undefined, + highlighted: undefined, + }; + } if (value === "top" || value === "stars") { const sort = value === "top" ? "downloads" : "stars"; return {