From ef8093f089a6114e95c45b2927c289008f53ae5a Mon Sep 17 00:00:00 2001 From: Brian Kasper Date: Sun, 1 Feb 2026 18:13:32 -0500 Subject: [PATCH 1/8] fix: initial skill sorting --- convex/skills.ts | 40 +++++++++++++++++++++++++++---------- src/routes/skills/index.tsx | 2 +- 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/convex/skills.ts b/convex/skills.ts index be38017..8a2efcf 100644 --- a/convex/skills.ts +++ b/convex/skills.ts @@ -720,23 +720,43 @@ export const listPublicPage = query({ export const listPublicPageV2 = query({ args: { paginationOpts: paginationOptsValidator, + sort: v.optional( + v.union( + v.literal('newest'), + v.literal('updated'), + v.literal('downloads'), + v.literal('installs'), + v.literal('stars'), + v.literal('name'), + ), + ), + dir: v.optional(v.union(v.literal('asc'), v.literal('desc'))), }, handler: async (ctx, args) => { - // Use the new index to filter out soft-deleted skills at query time. - // softDeletedAt === undefined means active (non-deleted) skills only. + const sort = args.sort ?? 'updated' + const dir = args.dir ?? 'desc' + + const indexName = + sort === 'downloads' + ? 'by_stats_downloads' + : sort === 'stars' + ? 'by_stats_stars' + : sort === 'installs' + ? 'by_stats_installs_all_time' + : 'by_active_updated' + + const useActiveFilter = indexName === 'by_active_updated' + const result = await paginator(ctx.db, schema) .query('skills') - .withIndex('by_active_updated', (q) => q.eq('softDeletedAt', undefined)) - .order('desc') + .withIndex(indexName, useActiveFilter ? (q) => q.eq('softDeletedAt', undefined) : (q) => q) + .order(dir) .paginate(args.paginationOpts) - // Build the public skill entries (fetch latestVersion + ownerHandle) - const items = await buildPublicSkillEntries(ctx, result.page) + const page = useActiveFilter ? result.page : result.page.filter((s) => !s.softDeletedAt) + const items = await buildPublicSkillEntries(ctx, page) - return { - ...result, - page: items, - } + return { ...result, page: items } }, }) diff --git a/src/routes/skills/index.tsx b/src/routes/skills/index.tsx index bf0a2a6..6e7049c 100644 --- a/src/routes/skills/index.tsx +++ b/src/routes/skills/index.tsx @@ -84,7 +84,7 @@ export function SkillsIndex() { results: paginatedResults, status: paginationStatus, loadMore: loadMorePaginated, - } = usePaginatedQuery(api.skills.listPublicPageV2, hasQuery ? 'skip' : {}, { + } = usePaginatedQuery(api.skills.listPublicPageV2, hasQuery ? 'skip' : { sort, dir }, { initialNumItems: pageSize, }) From fee477051ceffb35b10c1c378364a4af18c345f4 Mon Sep 17 00:00:00 2001 From: Brian Kasper Date: Sun, 1 Feb 2026 18:21:51 -0500 Subject: [PATCH 2/8] chore: update unit test --- src/__tests__/skills-index.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/__tests__/skills-index.test.tsx b/src/__tests__/skills-index.test.tsx index 769bacc..5efd7e1 100644 --- a/src/__tests__/skills-index.test.tsx +++ b/src/__tests__/skills-index.test.tsx @@ -48,10 +48,10 @@ describe('SkillsIndex', () => { it('requests the first skills page', () => { render() - // usePaginatedQuery should be called with the API endpoint and empty args + // usePaginatedQuery should be called with the API endpoint and sort/dir args expect(usePaginatedQueryMock).toHaveBeenCalledWith( expect.anything(), - {}, + { sort: 'newest', dir: 'desc' }, { initialNumItems: 25 }, ) }) From 06046c4ccb0aa33f2389f77e85d6c492d1108ecb Mon Sep 17 00:00:00 2001 From: Brian Kasper Date: Sun, 1 Feb 2026 18:37:17 -0500 Subject: [PATCH 3/8] fix: use correct indexes for skill sorting --- convex/schema.ts | 5 +++++ convex/skills.ts | 27 ++++++++++++++------------- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/convex/schema.ts b/convex/schema.ts index caf9bd2..75a5d50 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -107,6 +107,11 @@ const skills = defineTable({ .index('by_stats_installs_all_time', ['statsInstallsAllTime', 'updatedAt']) .index('by_batch', ['batch']) .index('by_active_updated', ['softDeletedAt', 'updatedAt']) + .index('by_active_created', ['softDeletedAt', 'createdAt']) + .index('by_active_name', ['softDeletedAt', 'displayName']) + .index('by_active_stats_downloads', ['softDeletedAt', 'statsDownloads', 'updatedAt']) + .index('by_active_stats_stars', ['softDeletedAt', 'statsStars', 'updatedAt']) + .index('by_active_stats_installs_all_time', ['softDeletedAt', 'statsInstallsAllTime', 'updatedAt']) const souls = defineTable({ slug: v.string(), diff --git a/convex/skills.ts b/convex/skills.ts index 8a2efcf..e14d1ae 100644 --- a/convex/skills.ts +++ b/convex/skills.ts @@ -733,28 +733,29 @@ export const listPublicPageV2 = query({ dir: v.optional(v.union(v.literal('asc'), v.literal('desc'))), }, handler: async (ctx, args) => { - const sort = args.sort ?? 'updated' + const sort = args.sort ?? 'newest' const dir = args.dir ?? 'desc' const indexName = - sort === 'downloads' - ? 'by_stats_downloads' - : sort === 'stars' - ? 'by_stats_stars' - : sort === 'installs' - ? 'by_stats_installs_all_time' - : 'by_active_updated' - - const useActiveFilter = indexName === 'by_active_updated' + sort === 'newest' + ? 'by_active_created' + : sort === 'updated' + ? 'by_active_updated' + : sort === 'name' + ? 'by_active_name' + : sort === 'downloads' + ? 'by_active_stats_downloads' + : sort === 'stars' + ? 'by_active_stats_stars' + : 'by_active_stats_installs_all_time' const result = await paginator(ctx.db, schema) .query('skills') - .withIndex(indexName, useActiveFilter ? (q) => q.eq('softDeletedAt', undefined) : (q) => q) + .withIndex(indexName, (q) => q.eq('softDeletedAt', undefined)) .order(dir) .paginate(args.paginationOpts) - const page = useActiveFilter ? result.page : result.page.filter((s) => !s.softDeletedAt) - const items = await buildPublicSkillEntries(ctx, page) + const items = await buildPublicSkillEntries(ctx, result.page) return { ...result, page: items } }, From 219b825c6c1a96c2108f16960817b7a75e5b1bbd Mon Sep 17 00:00:00 2001 From: Brian Kasper Date: Sun, 1 Feb 2026 18:43:13 -0500 Subject: [PATCH 4/8] chore: cleanup --- convex/skills.ts | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/convex/skills.ts b/convex/skills.ts index e14d1ae..bb3a719 100644 --- a/convex/skills.ts +++ b/convex/skills.ts @@ -31,6 +31,15 @@ const MAX_PUBLIC_LIST_LIMIT = 200 const MAX_LIST_BULK_LIMIT = 200 const MAX_LIST_TAKE = 1000 +const SORT_INDEXES = { + newest: 'by_active_created', + updated: 'by_active_updated', + name: 'by_active_name', + downloads: 'by_active_stats_downloads', + stars: 'by_active_stats_stars', + installs: 'by_active_stats_installs_all_time', +} as const + function isSkillVersionId( value: Id<'skillVersions'> | null | undefined, ): value is Id<'skillVersions'> { @@ -736,28 +745,21 @@ export const listPublicPageV2 = query({ const sort = args.sort ?? 'newest' const dir = args.dir ?? 'desc' - const indexName = - sort === 'newest' - ? 'by_active_created' - : sort === 'updated' - ? 'by_active_updated' - : sort === 'name' - ? 'by_active_name' - : sort === 'downloads' - ? 'by_active_stats_downloads' - : sort === 'stars' - ? 'by_active_stats_stars' - : 'by_active_stats_installs_all_time' - + // Use the index to filter out soft-deleted skills at query time. + // softDeletedAt === undefined means active (non-deleted) skills only. const result = await paginator(ctx.db, schema) .query('skills') - .withIndex(indexName, (q) => q.eq('softDeletedAt', undefined)) + .withIndex(SORT_INDEXES[sort], (q) => q.eq('softDeletedAt', undefined)) .order(dir) .paginate(args.paginationOpts) + // Build the public skill entries (fetch latestVersion + ownerHandle) const items = await buildPublicSkillEntries(ctx, result.page) - return { ...result, page: items } + return { + ...result, + page: items, + } }, }) From 53183d8127ec27450dae9cf9bbcd9fb15001f678 Mon Sep 17 00:00:00 2001 From: knox-glorang Date: Mon, 2 Feb 2026 17:03:52 +0900 Subject: [PATCH 5/8] fix(skills): preserve server order for paginated sorting --- convex/skills.ts | 5 +++-- src/routes/skills/index.tsx | 5 ++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/convex/skills.ts b/convex/skills.ts index bb3a719..d01db8e 100644 --- a/convex/skills.ts +++ b/convex/skills.ts @@ -171,7 +171,8 @@ async function hardDeleteSkill( if (related._id === skill._id) continue if (related.canonicalSkillId === skill._id || related.forkOf?.skillId === skill._id) { await ctx.db.patch(related._id, { - canonicalSkillId: related.canonicalSkillId === skill._id ? undefined : related.canonicalSkillId, + canonicalSkillId: + related.canonicalSkillId === skill._id ? undefined : related.canonicalSkillId, forkOf: related.forkOf?.skillId === skill._id ? undefined : related.forkOf, updatedAt: now, }) @@ -743,7 +744,7 @@ export const listPublicPageV2 = query({ }, handler: async (ctx, args) => { const sort = args.sort ?? 'newest' - const dir = args.dir ?? 'desc' + const dir = args.dir ?? (sort === 'name' ? 'asc' : 'desc') // Use the index to filter out soft-deleted skills at query time. // softDeletedAt === undefined means active (non-deleted) skills only. diff --git a/src/routes/skills/index.tsx b/src/routes/skills/index.tsx index 6e7049c..712289d 100644 --- a/src/routes/skills/index.tsx +++ b/src/routes/skills/index.tsx @@ -161,6 +161,9 @@ export function SkillsIndex() { ) const sorted = useMemo(() => { + if (!hasQuery) { + return filtered + } const multiplier = dir === 'asc' ? 1 : -1 const results = [...filtered] results.sort((a, b) => { @@ -186,7 +189,7 @@ export function SkillsIndex() { } }) return results - }, [dir, filtered, sort]) + }, [dir, filtered, hasQuery, sort]) const isLoadingSkills = hasQuery ? isSearching && searchResults.length === 0 : isLoadingList const canLoadMore = hasQuery From 886237e238565374d387bdf5dd7707d6832c9b88 Mon Sep 17 00:00:00 2001 From: knox-glorang Date: Mon, 2 Feb 2026 17:03:58 +0900 Subject: [PATCH 6/8] chore(lint): apply biome formatting fixes --- convex/schema.ts | 6 +++++- src/lib/og.ts | 2 +- src/lib/site.test.ts | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/convex/schema.ts b/convex/schema.ts index 75a5d50..702b924 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -111,7 +111,11 @@ const skills = defineTable({ .index('by_active_name', ['softDeletedAt', 'displayName']) .index('by_active_stats_downloads', ['softDeletedAt', 'statsDownloads', 'updatedAt']) .index('by_active_stats_stars', ['softDeletedAt', 'statsStars', 'updatedAt']) - .index('by_active_stats_installs_all_time', ['softDeletedAt', 'statsInstallsAllTime', 'updatedAt']) + .index('by_active_stats_installs_all_time', [ + 'softDeletedAt', + 'statsInstallsAllTime', + 'updatedAt', + ]) const souls = defineTable({ slug: v.string(), diff --git a/src/lib/og.ts b/src/lib/og.ts index a66631d..31dd79f 100644 --- a/src/lib/og.ts +++ b/src/lib/og.ts @@ -1,4 +1,4 @@ -import { getOnlyCrabsSiteUrl, getClawHubSiteUrl } from './site' +import { getClawHubSiteUrl, getOnlyCrabsSiteUrl } from './site' type SkillMetaSource = { slug: string diff --git a/src/lib/site.test.ts b/src/lib/site.test.ts index fd099d5..46b0378 100644 --- a/src/lib/site.test.ts +++ b/src/lib/site.test.ts @@ -5,9 +5,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { detectSiteMode, detectSiteModeFromUrl, + getClawHubSiteUrl, getOnlyCrabsHost, getOnlyCrabsSiteUrl, - getClawHubSiteUrl, getSiteDescription, getSiteMode, getSiteName, From f3787b86617bbbd02e5c26a13e01b1f46fa96ab6 Mon Sep 17 00:00:00 2001 From: knox-glorang Date: Mon, 2 Feb 2026 17:25:20 +0900 Subject: [PATCH 7/8] chore(convex): bump tsconfig lib to ES2022 --- convex/tsconfig.json | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/convex/tsconfig.json b/convex/tsconfig.json index 6907537..407b78e 100644 --- a/convex/tsconfig.json +++ b/convex/tsconfig.json @@ -1,7 +1,25 @@ { - "extends": "../tsconfig.json", + /* This TypeScript project config describes the environment that + * Convex functions run in and is used to typecheck them. + * You can modify it, but some settings are required to use Convex. + */ "compilerOptions": { + /* These settings are not required by Convex and can be modified. */ + "allowJs": true, + "strict": true, "moduleResolution": "Bundler", - "skipLibCheck": true - } + "jsx": "react-jsx", + "skipLibCheck": true, + "allowSyntheticDefaultImports": true, + + /* These compiler options are required by Convex */ + "target": "ESNext", + "lib": ["ES2022", "dom"], + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "isolatedModules": true, + "noEmit": true + }, + "include": ["./**/*"], + "exclude": ["./_generated"] } From d27a1a0107f3f6f8ff5a5aa5d2f98bc13659f482 Mon Sep 17 00:00:00 2001 From: knox-glorang Date: Mon, 2 Feb 2026 17:40:32 +0900 Subject: [PATCH 8/8] fix(skills): add deterministic tie-breaker for search sorting --- src/__tests__/skills-index.test.tsx | 55 +++++++++++++++++++++++++++++ src/routes/skills/index.tsx | 21 ++++++++--- 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/src/__tests__/skills-index.test.tsx b/src/__tests__/skills-index.test.tsx index 5efd7e1..c8a6626 100644 --- a/src/__tests__/skills-index.test.tsx +++ b/src/__tests__/skills-index.test.tsx @@ -118,6 +118,32 @@ describe('SkillsIndex', () => { limit: 50, }) }) + + it('sorts search results by stars and breaks ties by updatedAt', async () => { + searchMock = { q: 'remind', sort: 'stars', dir: 'desc' } + const actionFn = vi + .fn() + .mockResolvedValue([ + makeSearchEntry({ slug: 'skill-a', displayName: 'Skill A', stars: 5, updatedAt: 100 }), + makeSearchEntry({ slug: 'skill-b', displayName: 'Skill B', stars: 5, updatedAt: 200 }), + makeSearchEntry({ slug: 'skill-c', displayName: 'Skill C', stars: 4, updatedAt: 999 }), + ]) + useActionMock.mockReturnValue(actionFn) + vi.useFakeTimers() + + render() + await act(async () => { + await vi.runAllTimersAsync() + }) + await act(async () => { + await vi.runAllTimersAsync() + }) + + const links = screen.getAllByRole('link') + expect(links[0]?.textContent).toContain('Skill B') + expect(links[1]?.textContent).toContain('Skill A') + expect(links[2]?.textContent).toContain('Skill C') + }) }) function makeSearchResults(count: number) { @@ -143,3 +169,32 @@ function makeSearchResults(count: number) { version: null, })) } + +function makeSearchEntry(params: { + slug: string + displayName: string + stars: number + updatedAt: number +}) { + return { + score: 0.9, + skill: { + _id: `skill_${params.slug}`, + slug: params.slug, + displayName: params.displayName, + summary: `Summary ${params.slug}`, + tags: {}, + stats: { + downloads: 0, + installsCurrent: 0, + installsAllTime: 0, + stars: params.stars, + versions: 1, + comments: 0, + }, + createdAt: 0, + updatedAt: params.updatedAt, + }, + version: null, + } +} diff --git a/src/routes/skills/index.tsx b/src/routes/skills/index.tsx index 712289d..024811c 100644 --- a/src/routes/skills/index.tsx +++ b/src/routes/skills/index.tsx @@ -167,25 +167,36 @@ export function SkillsIndex() { const multiplier = dir === 'asc' ? 1 : -1 const results = [...filtered] results.sort((a, b) => { + const tieBreak = () => { + const updated = (a.skill.updatedAt - b.skill.updatedAt) * multiplier + if (updated !== 0) return updated + return a.skill.slug.localeCompare(b.skill.slug) + } switch (sort) { case 'downloads': - return (a.skill.stats.downloads - b.skill.stats.downloads) * multiplier + return (a.skill.stats.downloads - b.skill.stats.downloads) * multiplier || tieBreak() case 'installs': return ( ((a.skill.stats.installsAllTime ?? 0) - (b.skill.stats.installsAllTime ?? 0)) * - multiplier + multiplier || tieBreak() ) case 'stars': - return (a.skill.stats.stars - b.skill.stats.stars) * multiplier + return (a.skill.stats.stars - b.skill.stats.stars) * multiplier || tieBreak() case 'updated': - return (a.skill.updatedAt - b.skill.updatedAt) * multiplier + return ( + (a.skill.updatedAt - b.skill.updatedAt) * multiplier || + a.skill.slug.localeCompare(b.skill.slug) + ) case 'name': return ( (a.skill.displayName.localeCompare(b.skill.displayName) || a.skill.slug.localeCompare(b.skill.slug)) * multiplier ) default: - return (a.skill.createdAt - b.skill.createdAt) * multiplier + return ( + (a.skill.createdAt - b.skill.createdAt) * multiplier || + a.skill.slug.localeCompare(b.skill.slug) + ) } }) return results