Skip to content

Commit 31e9a57

Browse files
committed
feat: add installs/trending sorts
1 parent 7680cc4 commit 31e9a57

File tree

22 files changed

+776
-60
lines changed

22 files changed

+776
-60
lines changed

CHANGELOG.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
# Changelog
22

3-
## 0.2.1 - Unreleased
3+
## 0.3.0 - 2026-01-19
44

55
### Added
66
- CLI: add `explore` command for latest updates, with limit clamping + tests/docs (thanks @jdrhyne, #14).
7+
- CLI: `explore --json` output + new sorts (`installs`, `installsAllTime`, `trending`) and limit up to 200.
8+
- API: `/api/v1/skills` supports installs + trending sorts (7-day installs).
9+
- API: idempotent `POST/DELETE /api/v1/stars/{slug}` endpoints.
10+
- Registry: trending leaderboard + daily stats backfill for installs-based sorts.
711

812
### Fixed
913
- Web: keep search mode navigation and state in sync (thanks @NACC96, #12).

convex/_generated/api.d.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,16 +21,19 @@ import type * as githubSoulBackupsNode from "../githubSoulBackupsNode.js";
2121
import type * as http from "../http.js";
2222
import type * as httpApi from "../httpApi.js";
2323
import type * as httpApiV1 from "../httpApiV1.js";
24+
import type * as leaderboards from "../leaderboards.js";
2425
import type * as lib_access from "../lib/access.js";
2526
import type * as lib_apiTokenAuth from "../lib/apiTokenAuth.js";
2627
import type * as lib_changelog from "../lib/changelog.js";
2728
import type * as lib_embeddings from "../lib/embeddings.js";
2829
import type * as lib_githubBackup from "../lib/githubBackup.js";
2930
import type * as lib_githubImport from "../lib/githubImport.js";
3031
import type * as lib_githubSoulBackup from "../lib/githubSoulBackup.js";
32+
import type * as lib_leaderboards from "../lib/leaderboards.js";
3133
import type * as lib_searchText from "../lib/searchText.js";
3234
import type * as lib_skillBackfill from "../lib/skillBackfill.js";
3335
import type * as lib_skillPublish from "../lib/skillPublish.js";
36+
import type * as lib_skillStats from "../lib/skillStats.js";
3437
import type * as lib_skills from "../lib/skills.js";
3538
import type * as lib_soulChangelog from "../lib/soulChangelog.js";
3639
import type * as lib_soulPublish from "../lib/soulPublish.js";
@@ -47,6 +50,7 @@ import type * as soulDownloads from "../soulDownloads.js";
4750
import type * as soulStars from "../soulStars.js";
4851
import type * as souls from "../souls.js";
4952
import type * as stars from "../stars.js";
53+
import type * as statsMaintenance from "../statsMaintenance.js";
5054
import type * as telemetry from "../telemetry.js";
5155
import type * as tokens from "../tokens.js";
5256
import type * as uploads from "../uploads.js";
@@ -73,16 +77,19 @@ declare const fullApi: ApiFromModules<{
7377
http: typeof http;
7478
httpApi: typeof httpApi;
7579
httpApiV1: typeof httpApiV1;
80+
leaderboards: typeof leaderboards;
7681
"lib/access": typeof lib_access;
7782
"lib/apiTokenAuth": typeof lib_apiTokenAuth;
7883
"lib/changelog": typeof lib_changelog;
7984
"lib/embeddings": typeof lib_embeddings;
8085
"lib/githubBackup": typeof lib_githubBackup;
8186
"lib/githubImport": typeof lib_githubImport;
8287
"lib/githubSoulBackup": typeof lib_githubSoulBackup;
88+
"lib/leaderboards": typeof lib_leaderboards;
8389
"lib/searchText": typeof lib_searchText;
8490
"lib/skillBackfill": typeof lib_skillBackfill;
8591
"lib/skillPublish": typeof lib_skillPublish;
92+
"lib/skillStats": typeof lib_skillStats;
8693
"lib/skills": typeof lib_skills;
8794
"lib/soulChangelog": typeof lib_soulChangelog;
8895
"lib/soulPublish": typeof lib_soulPublish;
@@ -99,6 +106,7 @@ declare const fullApi: ApiFromModules<{
99106
soulStars: typeof soulStars;
100107
souls: typeof souls;
101108
stars: typeof stars;
109+
statsMaintenance: typeof statsMaintenance;
102110
telemetry: typeof telemetry;
103111
tokens: typeof tokens;
104112
uploads: typeof uploads;

convex/crons.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,18 @@ crons.interval(
1010
{ batchSize: 50, maxBatches: 5 },
1111
)
1212

13+
crons.interval(
14+
'trending-leaderboard',
15+
{ minutes: 60 },
16+
internal.leaderboards.rebuildTrendingLeaderboardInternal,
17+
{ limit: 200 },
18+
)
19+
20+
crons.interval(
21+
'skill-stats-backfill',
22+
{ minutes: 10 },
23+
internal.statsMaintenance.runSkillStatBackfillInternal,
24+
{ batchSize: 200, maxBatches: 5 },
25+
)
26+
1327
export default crons

convex/devSeed.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,10 @@ export const seedSkillMutation = internalMutation({
364364
tags: {},
365365
softDeletedAt: undefined,
366366
badges: { redactionApproved: undefined },
367+
statsDownloads: 0,
368+
statsStars: 0,
369+
statsInstallsCurrent: 0,
370+
statsInstallsAllTime: 0,
367371
stats: {
368372
downloads: 0,
369373
installsCurrent: 0,
@@ -413,6 +417,10 @@ export const seedSkillMutation = internalMutation({
413417
await ctx.db.patch(skillId, {
414418
latestVersionId: versionId,
415419
tags: { latest: versionId },
420+
statsDownloads: 0,
421+
statsStars: 0,
422+
statsInstallsCurrent: 0,
423+
statsInstallsAllTime: 0,
416424
stats: {
417425
downloads: 0,
418426
installsCurrent: 0,

convex/downloads.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { v } from 'convex/values'
22
import { zipSync } from 'fflate'
33
import { api } from './_generated/api'
44
import { httpAction, mutation } from './_generated/server'
5+
import { applySkillStatDeltas, bumpDailySkillStats } from './lib/skillStats'
56

67
export const downloadZip = httpAction(async (ctx, request) => {
78
const url = new URL(request.url)
@@ -69,9 +70,12 @@ export const increment = mutation({
6970
handler: async (ctx, args) => {
7071
const skill = await ctx.db.get(args.skillId)
7172
if (!skill) return
73+
const now = Date.now()
74+
const patch = applySkillStatDeltas(skill, { downloads: 1 })
7275
await ctx.db.patch(skill._id, {
73-
stats: { ...skill.stats, downloads: skill.stats.downloads + 1 },
74-
updatedAt: Date.now(),
76+
...patch,
77+
updatedAt: now,
7578
})
79+
await bumpDailySkillStats(ctx, { skillId: skill._id, now, downloads: 1 })
7680
},
7781
})

convex/httpApiV1.handlers.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,31 @@ describe('httpApiV1 handlers', () => {
158158
expect(json.items[0].tags.latest).toBe('1.0.0')
159159
})
160160

161+
it('lists skills supports sort aliases', async () => {
162+
const checks: Array<[string, string]> = [
163+
['rating', 'stars'],
164+
['installs', 'installsCurrent'],
165+
['installs-all-time', 'installsAllTime'],
166+
['trending', 'trending'],
167+
]
168+
169+
for (const [input, expected] of checks) {
170+
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
171+
if ('sort' in args || 'cursor' in args || 'limit' in args) {
172+
expect(args.sort).toBe(expected)
173+
return { items: [], nextCursor: null }
174+
}
175+
return null
176+
})
177+
const runMutation = vi.fn().mockResolvedValue(okRate())
178+
const response = await __handlers.listSkillsV1Handler(
179+
makeCtx({ runQuery, runMutation }),
180+
new Request(`https://example.com/api/v1/skills?sort=${input}`),
181+
)
182+
expect(response.status).toBe(200)
183+
}
184+
})
185+
161186
it('get skill returns 404 when missing', async () => {
162187
const runQuery = vi.fn().mockResolvedValue(null)
163188
const runMutation = vi.fn().mockResolvedValue(okRate())

convex/httpApiV1.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,11 +191,14 @@ async function listSkillsV1Handler(ctx: ActionCtx, request: Request) {
191191

192192
const url = new URL(request.url)
193193
const limit = toOptionalNumber(url.searchParams.get('limit'))
194-
const cursor = url.searchParams.get('cursor')?.trim() || undefined
194+
const rawCursor = url.searchParams.get('cursor')?.trim() || undefined
195+
const sort = parseListSort(url.searchParams.get('sort'))
196+
const cursor = sort === 'updated' ? rawCursor : undefined
195197

196198
const result = (await ctx.runQuery(api.skills.listPublicPage, {
197199
limit,
198200
cursor,
201+
sort,
199202
})) as ListSkillsResult
200203

201204
const items = await Promise.all(
@@ -753,6 +756,33 @@ function toOptionalNumber(value: string | null) {
753756
return Number.isFinite(parsed) ? parsed : undefined
754757
}
755758

759+
type SkillListSort =
760+
| 'updated'
761+
| 'downloads'
762+
| 'stars'
763+
| 'installsCurrent'
764+
| 'installsAllTime'
765+
| 'trending'
766+
767+
function parseListSort(value: string | null): SkillListSort {
768+
const normalized = value?.trim().toLowerCase()
769+
if (normalized === 'downloads') return 'downloads'
770+
if (normalized === 'stars' || normalized === 'rating') return 'stars'
771+
if (
772+
normalized === 'installs' ||
773+
normalized === 'install' ||
774+
normalized === 'installscurrent' ||
775+
normalized === 'installs-current'
776+
) {
777+
return 'installsCurrent'
778+
}
779+
if (normalized === 'installsalltime' || normalized === 'installs-all-time') {
780+
return 'installsAllTime'
781+
}
782+
if (normalized === 'trending') return 'trending'
783+
return 'updated'
784+
}
785+
756786
async function sha256Hex(bytes: Uint8Array) {
757787
const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)
758788
const digest = await crypto.subtle.digest('SHA-256', buffer)

convex/leaderboards.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { v } from 'convex/values'
2+
import { internalMutation } from './_generated/server'
3+
import { buildTrendingLeaderboard } from './lib/leaderboards'
4+
5+
const MAX_TRENDING_LIMIT = 200
6+
const KEEP_LEADERBOARD_ENTRIES = 3
7+
8+
export const rebuildTrendingLeaderboardInternal = internalMutation({
9+
args: { limit: v.optional(v.number()) },
10+
handler: async (ctx, args) => {
11+
const limit = clampInt(args.limit ?? MAX_TRENDING_LIMIT, 1, MAX_TRENDING_LIMIT)
12+
const now = Date.now()
13+
const { startDay, endDay, items } = await buildTrendingLeaderboard(ctx, { limit, now })
14+
15+
await ctx.db.insert('skillLeaderboards', {
16+
kind: 'trending',
17+
generatedAt: now,
18+
rangeStartDay: startDay,
19+
rangeEndDay: endDay,
20+
items,
21+
})
22+
23+
const recent = await ctx.db
24+
.query('skillLeaderboards')
25+
.withIndex('by_kind', (q) => q.eq('kind', 'trending'))
26+
.order('desc')
27+
.take(KEEP_LEADERBOARD_ENTRIES + 5)
28+
29+
for (const entry of recent.slice(KEEP_LEADERBOARD_ENTRIES)) {
30+
await ctx.db.delete(entry._id)
31+
}
32+
33+
return { ok: true as const, count: items.length }
34+
},
35+
})
36+
37+
function clampInt(value: number, min: number, max: number) {
38+
return Math.min(Math.max(value, min), max)
39+
}

convex/lib/leaderboards.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import type { Id } from '../_generated/dataModel'
2+
import type { MutationCtx, QueryCtx } from '../_generated/server'
3+
4+
const DAY_MS = 24 * 60 * 60 * 1000
5+
export const TRENDING_DAYS = 7
6+
7+
type LeaderboardEntry = {
8+
skillId: Id<'skills'>
9+
score: number
10+
installs: number
11+
downloads: number
12+
}
13+
14+
export function toDayKey(timestamp: number) {
15+
return Math.floor(timestamp / DAY_MS)
16+
}
17+
18+
export function getTrendingRange(now: number) {
19+
const endDay = toDayKey(now)
20+
const startDay = endDay - (TRENDING_DAYS - 1)
21+
return { startDay, endDay }
22+
}
23+
24+
export async function buildTrendingLeaderboard(
25+
ctx: QueryCtx | MutationCtx,
26+
params: { limit: number; now?: number },
27+
) {
28+
const now = params.now ?? Date.now()
29+
const { startDay, endDay } = getTrendingRange(now)
30+
const rows = await ctx.db
31+
.query('skillDailyStats')
32+
.withIndex('by_day', (q) => q.gte('day', startDay).lte('day', endDay))
33+
.collect()
34+
35+
const totals = new Map<Id<'skills'>, { installs: number; downloads: number }>()
36+
for (const row of rows) {
37+
const current = totals.get(row.skillId) ?? { installs: 0, downloads: 0 }
38+
current.installs += row.installs
39+
current.downloads += row.downloads
40+
totals.set(row.skillId, current)
41+
}
42+
43+
const entries = Array.from(totals, ([skillId, totalsEntry]) => ({
44+
skillId,
45+
installs: totalsEntry.installs,
46+
downloads: totalsEntry.downloads,
47+
score: totalsEntry.installs,
48+
}))
49+
50+
const items = topN(entries, params.limit, compareTrendingEntries).sort((a, b) =>
51+
compareTrendingEntries(b, a),
52+
)
53+
54+
return { startDay, endDay, items }
55+
}
56+
57+
function compareTrendingEntries(a: LeaderboardEntry, b: LeaderboardEntry) {
58+
if (a.score !== b.score) return a.score - b.score
59+
if (a.downloads !== b.downloads) return a.downloads - b.downloads
60+
return 0
61+
}
62+
63+
function topN<T>(entries: T[], limit: number, compare: (a: T, b: T) => number) {
64+
if (entries.length <= limit) return entries.slice()
65+
66+
const heap: T[] = []
67+
for (const entry of entries) {
68+
if (heap.length < limit) {
69+
heap.push(entry)
70+
siftUp(heap, heap.length - 1, compare)
71+
continue
72+
}
73+
if (compare(entry, heap[0]) <= 0) continue
74+
heap[0] = entry
75+
siftDown(heap, 0, compare)
76+
}
77+
return heap
78+
}
79+
80+
function siftUp<T>(heap: T[], index: number, compare: (a: T, b: T) => number) {
81+
let current = index
82+
while (current > 0) {
83+
const parent = Math.floor((current - 1) / 2)
84+
if (compare(heap[current], heap[parent]) >= 0) break
85+
;[heap[current], heap[parent]] = [heap[parent], heap[current]]
86+
current = parent
87+
}
88+
}
89+
90+
function siftDown<T>(heap: T[], index: number, compare: (a: T, b: T) => number) {
91+
let current = index
92+
const length = heap.length
93+
while (true) {
94+
const left = current * 2 + 1
95+
const right = current * 2 + 2
96+
let smallest = current
97+
if (left < length && compare(heap[left], heap[smallest]) < 0) smallest = left
98+
if (right < length && compare(heap[right], heap[smallest]) < 0) smallest = right
99+
if (smallest === current) break
100+
;[heap[current], heap[smallest]] = [heap[smallest], heap[current]]
101+
current = smallest
102+
}
103+
}

0 commit comments

Comments
 (0)