diff --git a/convex/schema.ts b/convex/schema.ts
index 96d85d4..f81ac8e 100644
--- a/convex/schema.ts
+++ b/convex/schema.ts
@@ -109,6 +109,15 @@ 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',
+ ])
.index('by_canonical', ['canonicalSkillId'])
.index('by_fork_of', ['forkOf.skillId'])
diff --git a/convex/skills.ts b/convex/skills.ts
index 44be105..05c037e 100644
--- a/convex/skills.ts
+++ b/convex/skills.ts
@@ -37,6 +37,15 @@ const MAX_ACTIVE_REPORTS_PER_USER = 20
const AUTO_HIDE_REPORT_THRESHOLD = 3
const MAX_REPORT_REASON_SAMPLE = 5
+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'> {
@@ -1033,14 +1042,28 @@ 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.
+ const sort = args.sort ?? 'newest'
+ 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.
const result = await paginator(ctx.db, schema)
.query('skills')
- .withIndex('by_active_updated', (q) => q.eq('softDeletedAt', undefined))
- .order('desc')
+ .withIndex(SORT_INDEXES[sort], (q) => q.eq('softDeletedAt', undefined))
+ .order(dir)
.paginate(args.paginationOpts)
// Build the public skill entries (fetch latestVersion + ownerHandle)
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"]
}
diff --git a/src/__tests__/skills-index.test.tsx b/src/__tests__/skills-index.test.tsx
index 769bacc..c8a6626 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 },
)
})
@@ -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 bf0a2a6..024811c 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,
})
@@ -161,32 +161,46 @@ export function SkillsIndex() {
)
const sorted = useMemo(() => {
+ if (!hasQuery) {
+ return filtered
+ }
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
- }, [dir, filtered, sort])
+ }, [dir, filtered, hasQuery, sort])
const isLoadingSkills = hasQuery ? isSearching && searchResults.length === 0 : isLoadingList
const canLoadMore = hasQuery