diff --git a/CHANGELOG.md b/CHANGELOG.md index 26abdfd92..e0b9ec4c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,8 @@ - Skills: keep global sorting across pagination on `/skills` (thanks @CodeBBakGoSu, #98). - Skills: allow updating skill description/summary from frontmatter on subsequent publishes (#312) (thanks @ianalloway). - Skills/Web: prevent filtered pagination dead-ends and loading-state flicker on `/skills`; move highlighted browse filtering into server list query (#339) (thanks @Marvae). +- Web: align `/skills` total count with public visibility and format header count (thanks @rknoche6, #76). +- Skills/Web: centralize public visibility checks and keep `globalStats` skill counts in sync incrementally; remove duplicate `/skills` default-sort fallback and share browse test mocks (thanks @rknoche6, #76). ## 0.6.1 - 2026-02-13 diff --git a/convex/crons.ts b/convex/crons.ts index ed22aece7..f9f8743f6 100644 --- a/convex/crons.ts +++ b/convex/crons.ts @@ -31,6 +31,13 @@ crons.interval( {}, ) +crons.interval( + 'global-stats-update', + { minutes: 60 }, + internal.statsMaintenance.updateGlobalStatsInternal, + {}, +) + crons.interval('vt-pending-scans', { minutes: 5 }, internal.vt.pollPendingScans, { batchSize: 100 }) crons.interval('vt-cache-backfill', { minutes: 30 }, internal.vt.backfillActiveSkillsVTCache, { diff --git a/convex/lib/globalStats.ts b/convex/lib/globalStats.ts new file mode 100644 index 000000000..8b8cd3458 --- /dev/null +++ b/convex/lib/globalStats.ts @@ -0,0 +1,153 @@ +import type { Doc } from '../_generated/dataModel' +import type { MutationCtx, QueryCtx } from '../_generated/server' + +export const GLOBAL_STATS_KEY = 'default' +const GLOBAL_STATS_PAGE_SIZE = 500 + +type SkillVisibilityFields = Pick< + Doc<'skills'>, + 'softDeletedAt' | 'moderationStatus' | 'moderationFlags' +> + +type DbCtx = Pick + +export function isPublicSkillDoc(skill: SkillVisibilityFields | null | undefined) { + if (!skill || skill.softDeletedAt) return false + if (skill.moderationStatus && skill.moderationStatus !== 'active') return false + if (skill.moderationFlags?.includes('blocked.malware')) return false + return true +} + +export function getPublicSkillVisibilityDelta( + before: SkillVisibilityFields | null | undefined, + after: SkillVisibilityFields | null | undefined, +) { + const beforePublic = isPublicSkillDoc(before) + const afterPublic = isPublicSkillDoc(after) + if (beforePublic === afterPublic) return 0 + return afterPublic ? 1 : -1 +} + +function getErrorMessage(error: unknown) { + if (typeof error === 'string') return error + if (error && typeof error === 'object' && 'message' in error) { + const message = (error as { message?: unknown }).message + if (typeof message === 'string') return message + } + return '' +} + +export function isGlobalStatsStorageNotReadyError(error: unknown) { + const message = getErrorMessage(error).toLowerCase() + if (!message) return false + const referencesGlobalStats = message.includes('globalstats') || message.includes('by_key') + if (!referencesGlobalStats) return false + return ( + message.includes('table') || + message.includes('index') || + message.includes('schema') || + message.includes('not found') || + message.includes('does not exist') || + message.includes('unknown') + ) +} + +export async function countPublicSkillsForGlobalStats(ctx: DbCtx) { + let count = 0 + let cursor: string | null = null + + while (true) { + const { page, isDone, continueCursor } = await ctx.db + .query('skills') + .withIndex('by_active_updated', (q) => q.eq('softDeletedAt', undefined)) + .order('asc') + .paginate({ cursor, numItems: GLOBAL_STATS_PAGE_SIZE }) + + for (const skill of page) { + if (isPublicSkillDoc(skill)) { + count += 1 + } + } + + if (isDone) break + cursor = continueCursor + } + + return count +} + +export async function setGlobalPublicSkillsCount( + ctx: DbCtx, + count: number, + now = Date.now(), +) { + const normalizedCount = Math.max(0, Math.trunc(Number.isFinite(count) ? count : 0)) + try { + const existing = await ctx.db + .query('globalStats') + .withIndex('by_key', (q) => q.eq('key', GLOBAL_STATS_KEY)) + .unique() + + if (existing) { + await ctx.db.patch(existing._id, { activeSkillsCount: normalizedCount, updatedAt: now }) + } else { + await ctx.db.insert('globalStats', { + key: GLOBAL_STATS_KEY, + activeSkillsCount: normalizedCount, + updatedAt: now, + }) + } + } catch (error) { + if (isGlobalStatsStorageNotReadyError(error)) return + throw error + } +} + +export async function adjustGlobalPublicSkillsCount( + ctx: DbCtx, + delta: number, + now = Date.now(), +) { + const normalizedDelta = Math.trunc(Number.isFinite(delta) ? delta : 0) + if (normalizedDelta === 0) return + + let existing: + | { + _id: Doc<'globalStats'>['_id'] + activeSkillsCount: number + } + | null + | undefined + try { + existing = await ctx.db + .query('globalStats') + .withIndex('by_key', (q) => q.eq('key', GLOBAL_STATS_KEY)) + .unique() + } catch (error) { + if (isGlobalStatsStorageNotReadyError(error)) return + throw error + } + + if (!existing) { + // No baseline yet (e.g. fresh deploy). Initialize via full recount once. + const count = await countPublicSkillsForGlobalStats(ctx) + await setGlobalPublicSkillsCount(ctx, count, now) + return + } + + const nextCount = Math.max(0, existing.activeSkillsCount + normalizedDelta) + await ctx.db.patch(existing._id, { activeSkillsCount: nextCount, updatedAt: now }) +} + +export async function readGlobalPublicSkillsCount(ctx: DbCtx) { + try { + const stats = await ctx.db + .query('globalStats') + .withIndex('by_key', (q) => q.eq('key', GLOBAL_STATS_KEY)) + .unique() + return stats?.activeSkillsCount ?? null + } catch (error) { + if (isGlobalStatsStorageNotReadyError(error)) return null + throw error + } +} diff --git a/convex/lib/public.ts b/convex/lib/public.ts index 9cf9d8390..d33681d00 100644 --- a/convex/lib/public.ts +++ b/convex/lib/public.ts @@ -1,4 +1,5 @@ import type { Doc } from '../_generated/dataModel' +import { isPublicSkillDoc } from './globalStats' export type PublicUser = Pick< Doc<'users'>, @@ -52,9 +53,7 @@ export function toPublicUser(user: Doc<'users'> | null | undefined): PublicUser } export function toPublicSkill(skill: Doc<'skills'> | null | undefined): PublicSkill | null { - if (!skill || skill.softDeletedAt) return null - if (skill.moderationStatus && skill.moderationStatus !== 'active') return null - if (skill.moderationFlags?.includes('blocked.malware')) return null + if (!isPublicSkillDoc(skill)) return null const stats = { downloads: typeof skill.statsDownloads === 'number' diff --git a/convex/schema.ts b/convex/schema.ts index d86de7286..a8993da53 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -342,6 +342,12 @@ const skillStatBackfillState = defineTable({ updatedAt: v.number(), }).index('by_key', ['key']) +const globalStats = defineTable({ + key: v.string(), + activeSkillsCount: v.number(), + updatedAt: v.number(), +}).index('by_key', ['key']) + const skillStatEvents = defineTable({ skillId: v.id('skills'), kind: v.union( @@ -574,6 +580,7 @@ export default defineSchema({ skillDailyStats, skillLeaderboards, skillStatBackfillState, + globalStats, skillStatEvents, skillStatUpdateCursors, comments, diff --git a/convex/skills.countPublicSkills.test.ts b/convex/skills.countPublicSkills.test.ts new file mode 100644 index 000000000..2531ea116 --- /dev/null +++ b/convex/skills.countPublicSkills.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, vi } from 'vitest' +import { countPublicSkills } from './skills' + +type WrappedHandler = { + _handler: (ctx: unknown, args: TArgs) => Promise +} + +const countPublicSkillsHandler = ( + countPublicSkills as unknown as WrappedHandler, number> +)._handler + +function makeSkillsQuery(skills: Array<{ softDeletedAt?: number; moderationStatus?: string | null }>) { + return { + withIndex: (name: string) => { + if (name !== 'by_active_updated') throw new Error(`unexpected skills index ${name}`) + return { + order: (dir: string) => { + if (dir !== 'asc') throw new Error(`unexpected skills order ${dir}`) + return { + paginate: async () => ({ + page: skills, + isDone: true, + continueCursor: null, + pageStatus: null, + splitCursor: null, + }), + } + }, + } + }, + } +} + +describe('skills.countPublicSkills', () => { + it('returns precomputed global stats count when available', async () => { + const ctx = { + db: { + query: vi.fn((table: string) => { + if (table === 'globalStats') { + return { + withIndex: () => ({ + unique: async () => ({ _id: 'globalStats:1', activeSkillsCount: 123 }), + }), + } + } + if (table === 'skills') { + return makeSkillsQuery([]) + } + throw new Error(`unexpected table ${table}`) + }), + }, + } + + const result = await countPublicSkillsHandler(ctx, {}) + expect(result).toBe(123) + }) + + it('falls back to live count when global stats row is missing', async () => { + const ctx = { + db: { + query: vi.fn((table: string) => { + if (table === 'globalStats') { + return { + withIndex: () => ({ + unique: async () => null, + }), + } + } + if (table === 'skills') { + return makeSkillsQuery([ + { softDeletedAt: undefined, moderationStatus: 'active' }, + { softDeletedAt: undefined, moderationStatus: 'hidden' }, + { softDeletedAt: undefined, moderationStatus: 'active' }, + ]) + } + throw new Error(`unexpected table ${table}`) + }), + }, + } + + const result = await countPublicSkillsHandler(ctx, {}) + expect(result).toBe(2) + }) + + it('falls back to live count when globalStats table is unavailable', async () => { + const ctx = { + db: { + query: vi.fn((table: string) => { + if (table === 'globalStats') { + throw new Error('unexpected table globalStats') + } + if (table === 'skills') { + return makeSkillsQuery([ + { softDeletedAt: undefined, moderationStatus: 'active' }, + { softDeletedAt: undefined, moderationStatus: 'active' }, + ]) + } + throw new Error(`unexpected table ${table}`) + }), + }, + } + + const result = await countPublicSkillsHandler(ctx, {}) + expect(result).toBe(2) + }) +}) diff --git a/convex/skills.rateLimit.test.ts b/convex/skills.rateLimit.test.ts index cb1be423f..7bd8ebbb7 100644 --- a/convex/skills.rateLimit.test.ts +++ b/convex/skills.rateLimit.test.ts @@ -22,6 +22,21 @@ const clearOwnerSuspiciousFlagsHandler = ( clearOwnerSuspiciousFlagsInternal as unknown as WrappedHandler> )._handler +function buildGlobalStatsQuery(table: string) { + if (table !== 'globalStats') return null + return { + withIndex: (name: string) => { + if (name !== 'by_key') throw new Error(`unexpected globalStats index ${name}`) + return { + unique: async () => ({ + _id: 'globalStats:1', + activeSkillsCount: 100, + }), + } + }, + } +} + function createPublishArgs(overrides?: Partial>) { return { userId: 'users:owner', @@ -67,6 +82,8 @@ describe('skills anti-spam guards', () => { deletedAt: undefined, })), query: vi.fn((table: string) => { + const globalStatsQuery = buildGlobalStatsQuery(table) + if (globalStatsQuery) return globalStatsQuery if (table === 'skills') { return { withIndex: (name: string) => { @@ -127,6 +144,8 @@ describe('skills anti-spam guards', () => { return null }), query: vi.fn((table: string) => { + const globalStatsQuery = buildGlobalStatsQuery(table) + if (globalStatsQuery) return globalStatsQuery if (table === 'skillVersions') { return { withIndex: () => ({ @@ -197,6 +216,8 @@ describe('skills anti-spam guards', () => { return null }), query: vi.fn((table: string) => { + const globalStatsQuery = buildGlobalStatsQuery(table) + if (globalStatsQuery) return globalStatsQuery if (table === 'skillVersions') { return { withIndex: () => ({ @@ -265,6 +286,8 @@ describe('skills anti-spam guards', () => { return null }), query: vi.fn((table: string) => { + const globalStatsQuery = buildGlobalStatsQuery(table) + if (globalStatsQuery) return globalStatsQuery if (table === 'skillVersions') { return { withIndex: () => ({ @@ -324,6 +347,8 @@ describe('skills anti-spam guards', () => { return null }), query: vi.fn((table: string) => { + const globalStatsQuery = buildGlobalStatsQuery(table) + if (globalStatsQuery) return globalStatsQuery if (table === 'skills') { return { withIndex: (name: string) => { diff --git a/convex/skills.ts b/convex/skills.ts index 0a2f95e42..f98b710a5 100644 --- a/convex/skills.ts +++ b/convex/skills.ts @@ -24,6 +24,12 @@ import { canHealSkillOwnershipByGitHubProviderAccountId, getGitHubProviderAccountId, } from './lib/githubIdentity' +import { + adjustGlobalPublicSkillsCount, + countPublicSkillsForGlobalStats, + getPublicSkillVisibilityDelta, + readGlobalPublicSkillsCount, +} from './lib/globalStats' import { buildTrendingLeaderboard } from './lib/leaderboards' import { deriveModerationFlags } from './lib/moderation' import { toPublicSkill, toPublicUser } from './lib/public' @@ -117,6 +123,16 @@ function normalizeScannerSuspiciousReason(reason: string | undefined) { return `${reason.slice(0, -'.suspicious'.length)}.clean` } +async function adjustGlobalPublicCountForSkillChange( + ctx: MutationCtx, + previousSkill: Doc<'skills'> | null | undefined, + nextSkill: Doc<'skills'> | null | undefined, +) { + const delta = getPublicSkillVisibilityDelta(previousSkill, nextSkill) + if (delta === 0) return + await adjustGlobalPublicSkillsCount(ctx, delta) +} + async function getOwnerTrustSignals( ctx: QueryCtx | MutationCtx, owner: Doc<'users'>, @@ -219,7 +235,9 @@ async function hardDeleteSkillStep( if (Object.keys(patch).length) { patch.lastReviewedAt = now patch.updatedAt = now + const nextSkill = { ...skill, ...patch } await ctx.db.patch(skill._id, patch) + await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill) } switch (phase) { @@ -887,7 +905,9 @@ export const clearOwnerSuspiciousFlagsInternal = internalMutation({ patch.moderationStatus = 'active' } + const nextSkill = { ...skill, ...patch } await ctx.db.patch(skill._id, patch) + await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill) updated += 1 } @@ -1462,7 +1482,9 @@ export const report = mutation({ }) } + const nextSkill = { ...skill, ...updates } await ctx.db.patch(skill._id, updates) + await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill) if (shouldAutoHide) { await setSkillEmbeddingsSoftDeleted(ctx, skill._id, true, now) @@ -1632,6 +1654,16 @@ function isCursorParseError(error: unknown) { return false } +export const countPublicSkills = query({ + args: {}, + handler: async (ctx) => { + const statsCount = await readGlobalPublicSkillsCount(ctx) + if (typeof statsCount === 'number') return statsCount + // Fallback for uninitialized/missing globalStats storage. + return countPublicSkillsForGlobalStats(ctx) + }, +}) + function sortToIndex( sort: 'downloads' | 'stars' | 'installsCurrent' | 'installsAllTime', ): @@ -2171,9 +2203,13 @@ export const getSkillsWithNullModerationStatusInternal = internalQuery({ export const setSkillModerationStatusActiveInternal = internalMutation({ args: { skillId: v.id('skills') }, handler: async (ctx, args) => { - await ctx.db.patch(args.skillId, { - moderationStatus: 'active', - }) + const skill = await ctx.db.get(args.skillId) + if (!skill) return + + const patch: Partial> = { moderationStatus: 'active' } + const nextSkill = { ...skill, ...patch } + await ctx.db.patch(args.skillId, patch) + await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill) }, }) @@ -2279,7 +2315,9 @@ export const applyBanToOwnedSkillsBatchInternal = internalMutation({ hiddenCount += 1 } + const nextSkill = { ...skill, ...patch } await ctx.db.patch(skill._id, patch) + await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill) await setSkillEmbeddingsSoftDeleted(ctx, skill._id, true, args.bannedAt) } @@ -2319,7 +2357,7 @@ export const restoreOwnedSkillsForUnbanBatchInternal = internalMutation({ continue } - await ctx.db.patch(skill._id, { + const patch: Partial> = { softDeletedAt: undefined, moderationStatus: 'active', moderationReason: 'restored.unban', @@ -2327,7 +2365,10 @@ export const restoreOwnedSkillsForUnbanBatchInternal = internalMutation({ hiddenBy: undefined, lastReviewedAt: now, updatedAt: now, - }) + } + const nextSkill = { ...skill, ...patch } + await ctx.db.patch(skill._id, patch) + await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill) await setSkillEmbeddingsSoftDeleted(ctx, skill._id, false, now) restoredCount += 1 @@ -2595,7 +2636,7 @@ export const approveSkillByHashInternal = internalMutation({ 'Quality gate quarantine is still active. Manual moderation review required.') : undefined - await ctx.db.patch(skill._id, { + const patch: Partial> = { moderationStatus: nextModerationStatus, moderationReason: nextModerationReason, moderationFlags: newFlags, @@ -2604,7 +2645,10 @@ export const approveSkillByHashInternal = internalMutation({ hiddenBy: undefined, lastReviewedAt: nextModerationStatus === 'hidden' ? now : undefined, updatedAt: now, - }) + } + const nextSkill = { ...skill, ...patch } + await ctx.db.patch(skill._id, patch) + await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill) // Auto-ban authors of malicious skills (skips moderators/admins) if (isMalicious && skill.ownerUserId) { @@ -2657,7 +2701,7 @@ export const escalateByVtInternal = internalMutation({ newFlags = ['flagged.suspicious'] } - const patch: Record = { + const patch: Partial> = { moderationFlags: newFlags.length ? newFlags : undefined, updatedAt: Date.now(), } @@ -2672,7 +2716,9 @@ export const escalateByVtInternal = internalMutation({ patch.moderationStatus = 'hidden' } + const nextSkill = { ...skill, ...patch } await ctx.db.patch(skill._id, patch) + await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill) // Auto-ban authors of malicious skills if (isMalicious && skill.ownerUserId) { @@ -2962,14 +3008,17 @@ export const setSoftDeleted = mutation({ if (!skill) throw new Error('Skill not found') const now = Date.now() - await ctx.db.patch(skill._id, { + const patch: Partial> = { softDeletedAt: args.deleted ? now : undefined, moderationStatus: args.deleted ? 'hidden' : 'active', hiddenAt: args.deleted ? now : undefined, hiddenBy: args.deleted ? user._id : undefined, lastReviewedAt: now, updatedAt: now, - }) + } + const nextSkill = { ...skill, ...patch } + await ctx.db.patch(skill._id, patch) + await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill) await setSkillEmbeddingsSoftDeleted(ctx, skill._id, args.deleted, now) @@ -3513,6 +3562,9 @@ export const insertVersion = internalMutation({ updatedAt: now, }) skill = await ctx.db.get(skillId) + if (skill) { + await adjustGlobalPublicCountForSkillChange(ctx, null, skill) + } } if (!skill) throw new Error('Skill creation failed') @@ -3554,7 +3606,7 @@ export const insertVersion = internalMutation({ files: args.files, }) - await ctx.db.patch(skill._id, { + const patch: Partial> = { displayName: args.displayName, summary: nextSummary ?? undefined, latestVersionId: versionId, @@ -3567,7 +3619,10 @@ export const insertVersion = internalMutation({ quality: qualityRecord ?? skill.quality, moderationFlags: moderationFlags.length ? moderationFlags : undefined, updatedAt: now, - }) + } + const nextSkill = { ...skill, ...patch } + await ctx.db.patch(skill._id, patch) + await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill) const badgeMap = await getSkillBadgeMap(ctx, skill._id) const isApproved = Boolean(badgeMap.redactionApproved) @@ -3632,14 +3687,17 @@ export const setSkillSoftDeletedInternal = internalMutation({ } const now = Date.now() - await ctx.db.patch(skill._id, { + const patch: Partial> = { softDeletedAt: args.deleted ? now : undefined, moderationStatus: args.deleted ? 'hidden' : 'active', hiddenAt: args.deleted ? now : undefined, hiddenBy: args.deleted ? args.userId : undefined, lastReviewedAt: now, updatedAt: now, - }) + } + const nextSkill = { ...skill, ...patch } + await ctx.db.patch(skill._id, patch) + await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill) await setSkillEmbeddingsSoftDeleted(ctx, skill._id, args.deleted, now) diff --git a/convex/statsMaintenance.ts b/convex/statsMaintenance.ts index b80e4012b..09e64c36b 100644 --- a/convex/statsMaintenance.ts +++ b/convex/statsMaintenance.ts @@ -3,6 +3,10 @@ import { internal } from './_generated/api' import type { Doc } from './_generated/dataModel' import type { ActionCtx } from './_generated/server' import { internalAction, internalMutation, internalQuery } from './_generated/server' +import { + countPublicSkillsForGlobalStats, + setGlobalPublicSkillsCount, +} from './lib/globalStats' const DEFAULT_BATCH_SIZE = 200 const MAX_BATCH_SIZE = 1000 @@ -299,3 +303,11 @@ export const runReconcileSkillStarCountsInternal = internalAction({ function clampInt(value: number, min: number, max: number) { return Math.min(Math.max(value, min), max) } + +export const updateGlobalStatsInternal = internalMutation({ + args: {}, + handler: async (ctx) => { + const count = await countPublicSkillsForGlobalStats(ctx) + await setGlobalPublicSkillsCount(ctx, count) + }, +}) diff --git a/src/__tests__/helpers/convexReactMocks.ts b/src/__tests__/helpers/convexReactMocks.ts new file mode 100644 index 000000000..6c94192ed --- /dev/null +++ b/src/__tests__/helpers/convexReactMocks.ts @@ -0,0 +1,18 @@ +import { vi } from 'vitest' + +export const convexReactMocks = { + useAction: vi.fn(), + useQuery: vi.fn(), + usePaginatedQuery: vi.fn(), +} + +export function resetConvexReactMocks() { + convexReactMocks.useAction.mockReset() + convexReactMocks.useQuery.mockReset() + convexReactMocks.usePaginatedQuery.mockReset() +} + +export function setupDefaultConvexReactMocks() { + convexReactMocks.useAction.mockReturnValue(() => Promise.resolve([])) + convexReactMocks.useQuery.mockReturnValue(null) +} diff --git a/src/__tests__/skills-index-load-more.test.tsx b/src/__tests__/skills-index-load-more.test.tsx index 0de5562bb..30a292340 100644 --- a/src/__tests__/skills-index-load-more.test.tsx +++ b/src/__tests__/skills-index-load-more.test.tsx @@ -2,12 +2,15 @@ import { act, render } from '@testing-library/react' import type { ReactNode } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + convexReactMocks, + resetConvexReactMocks, + setupDefaultConvexReactMocks, +} from './helpers/convexReactMocks' import { SkillsIndex } from '../routes/skills/index' const navigateMock = vi.fn() -const useActionMock = vi.fn() -const usePaginatedQueryMock = vi.fn() let searchMock: Record = {} vi.mock('@tanstack/react-router', () => ({ @@ -20,17 +23,17 @@ vi.mock('@tanstack/react-router', () => ({ })) vi.mock('convex/react', () => ({ - useAction: (...args: unknown[]) => useActionMock(...args), - usePaginatedQuery: (...args: unknown[]) => usePaginatedQueryMock(...args), + useAction: (...args: unknown[]) => convexReactMocks.useAction(...args), + useQuery: (...args: unknown[]) => convexReactMocks.useQuery(...args), + usePaginatedQuery: (...args: unknown[]) => convexReactMocks.usePaginatedQuery(...args), })) describe('SkillsIndex load-more observer', () => { beforeEach(() => { - usePaginatedQueryMock.mockReset() - useActionMock.mockReset() + resetConvexReactMocks() navigateMock.mockReset() searchMock = {} - useActionMock.mockReturnValue(() => Promise.resolve([])) + setupDefaultConvexReactMocks() }) afterEach(() => { @@ -39,7 +42,7 @@ describe('SkillsIndex load-more observer', () => { it('triggers one request for repeated intersection callbacks', async () => { const loadMorePaginated = vi.fn() - usePaginatedQueryMock.mockReturnValue({ + convexReactMocks.usePaginatedQuery.mockReturnValue({ results: [makeListResult('skill-0', 'Skill 0')], status: 'CanLoadMore', loadMore: loadMorePaginated, diff --git a/src/__tests__/skills-index.test.tsx b/src/__tests__/skills-index.test.tsx index 0f62d46dd..2c9202286 100644 --- a/src/__tests__/skills-index.test.tsx +++ b/src/__tests__/skills-index.test.tsx @@ -2,12 +2,15 @@ import { act, fireEvent, render, screen } from '@testing-library/react' import type { ReactNode } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + convexReactMocks, + resetConvexReactMocks, + setupDefaultConvexReactMocks, +} from './helpers/convexReactMocks' import { SkillsIndex } from '../routes/skills/index' const navigateMock = vi.fn() -const useActionMock = vi.fn() -const usePaginatedQueryMock = vi.fn() let searchMock: Record = {} vi.mock('@tanstack/react-router', () => ({ @@ -20,19 +23,19 @@ vi.mock('@tanstack/react-router', () => ({ })) vi.mock('convex/react', () => ({ - useAction: (...args: unknown[]) => useActionMock(...args), - usePaginatedQuery: (...args: unknown[]) => usePaginatedQueryMock(...args), + useAction: (...args: unknown[]) => convexReactMocks.useAction(...args), + useQuery: (...args: unknown[]) => convexReactMocks.useQuery(...args), + usePaginatedQuery: (...args: unknown[]) => convexReactMocks.usePaginatedQuery(...args), })) describe('SkillsIndex', () => { beforeEach(() => { - usePaginatedQueryMock.mockReset() - useActionMock.mockReset() + resetConvexReactMocks() navigateMock.mockReset() searchMock = {} - useActionMock.mockReturnValue(() => Promise.resolve([])) + setupDefaultConvexReactMocks() // Default: return empty results with Exhausted status - usePaginatedQueryMock.mockReturnValue({ + convexReactMocks.usePaginatedQuery.mockReturnValue({ results: [], status: 'Exhausted', loadMore: vi.fn(), @@ -47,7 +50,7 @@ describe('SkillsIndex', () => { it('requests the first skills page', () => { render() // usePaginatedQuery should be called with the API endpoint and sort/dir args - expect(usePaginatedQueryMock).toHaveBeenCalledWith( + expect(convexReactMocks.usePaginatedQuery).toHaveBeenCalledWith( expect.anything(), { sort: 'downloads', dir: 'desc', highlightedOnly: false, nonSuspiciousOnly: false }, { initialNumItems: 25 }, @@ -61,7 +64,7 @@ describe('SkillsIndex', () => { it('shows loading state instead of empty state when pagination is not exhausted', () => { // When status is not 'Exhausted', we should show loading, not "No skills match" - usePaginatedQueryMock.mockReturnValue({ + convexReactMocks.usePaginatedQuery.mockReturnValue({ results: [], status: 'CanLoadMore', loadMore: vi.fn(), @@ -72,7 +75,7 @@ describe('SkillsIndex', () => { }) it('keeps load-more reachable when results are empty but pagination can continue', () => { - usePaginatedQueryMock.mockReturnValue({ + convexReactMocks.usePaginatedQuery.mockReturnValue({ results: [], status: 'CanLoadMore', loadMore: vi.fn(), @@ -94,7 +97,7 @@ describe('SkillsIndex', () => { owner: null, ownerHandle: null, } - usePaginatedQueryMock.mockReturnValue({ + convexReactMocks.usePaginatedQuery.mockReturnValue({ results: [mockEntry], status: 'LoadingMore', loadMore: vi.fn(), @@ -106,7 +109,7 @@ describe('SkillsIndex', () => { it('handles LoadingMore with empty results gracefully', () => { // Edge case: user changes filter while loading more, results become empty - usePaginatedQueryMock.mockReturnValue({ + convexReactMocks.usePaginatedQuery.mockReturnValue({ results: [], status: 'LoadingMore', loadMore: vi.fn(), @@ -124,9 +127,9 @@ describe('SkillsIndex', () => { // This tests the hasQuery condition in the empty state logic searchMock = { q: 'nonexistent-skill-xyz' } const actionFn = vi.fn().mockResolvedValue([]) - useActionMock.mockReturnValue(actionFn) + convexReactMocks.useAction.mockReturnValue(actionFn) // Pagination is skipped in search mode, so status stays 'LoadingFirstPage' - usePaginatedQueryMock.mockReturnValue({ + convexReactMocks.usePaginatedQuery.mockReturnValue({ results: [], status: 'LoadingFirstPage', loadMore: vi.fn(), @@ -146,13 +149,13 @@ describe('SkillsIndex', () => { it('skips list query and calls search when query is set', async () => { searchMock = { q: 'remind' } const actionFn = vi.fn().mockResolvedValue([]) - useActionMock.mockReturnValue(actionFn) + convexReactMocks.useAction.mockReturnValue(actionFn) vi.useFakeTimers() render() // usePaginatedQuery should be called with 'skip' when there's a search query - expect(usePaginatedQueryMock).toHaveBeenCalledWith(expect.anything(), 'skip', { + expect(convexReactMocks.usePaginatedQuery).toHaveBeenCalledWith(expect.anything(), 'skip', { initialNumItems: 25, }) await act(async () => { @@ -182,7 +185,7 @@ describe('SkillsIndex', () => { .fn() .mockResolvedValueOnce(makeSearchResults(25)) .mockResolvedValueOnce(makeSearchResults(50)) - useActionMock.mockReturnValue(actionFn) + convexReactMocks.useAction.mockReturnValue(actionFn) vi.useFakeTimers() render() @@ -213,7 +216,7 @@ describe('SkillsIndex', () => { 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) + convexReactMocks.useAction.mockReturnValue(actionFn) vi.useFakeTimers() render() @@ -238,7 +241,7 @@ describe('SkillsIndex', () => { makeSearchResult('newer-low-score', 'Newer Low Score', 0.1, 2000), makeSearchResult('older-high-score', 'Older High Score', 0.9, 1000), ]) - useActionMock.mockReturnValue(actionFn) + convexReactMocks.useAction.mockReturnValue(actionFn) vi.useFakeTimers() render() @@ -258,7 +261,7 @@ describe('SkillsIndex', () => { searchMock = { nonSuspicious: true } render() - expect(usePaginatedQueryMock).toHaveBeenCalledWith( + expect(convexReactMocks.usePaginatedQuery).toHaveBeenCalledWith( expect.anything(), { sort: 'downloads', dir: 'desc', highlightedOnly: false, nonSuspiciousOnly: true }, { initialNumItems: 25 }, @@ -269,7 +272,7 @@ describe('SkillsIndex', () => { searchMock = { highlighted: true } render() - expect(usePaginatedQueryMock).toHaveBeenCalledWith( + expect(convexReactMocks.usePaginatedQuery).toHaveBeenCalledWith( expect.anything(), { sort: 'downloads', dir: 'desc', highlightedOnly: true, nonSuspiciousOnly: false }, { initialNumItems: 25 }, diff --git a/src/routes/skills/-useSkillsBrowseModel.ts b/src/routes/skills/-useSkillsBrowseModel.ts index 17e3e51a1..6b279e318 100644 --- a/src/routes/skills/-useSkillsBrowseModel.ts +++ b/src/routes/skills/-useSkillsBrowseModel.ts @@ -77,17 +77,6 @@ export function useSkillsBrowseModel({ setQuery(search.q ?? '') }, [search.q]) - useEffect(() => { - if (hasQuery || search.sort) return - void navigate({ - search: (prev) => ({ - ...prev, - sort: 'downloads', - }), - replace: true, - }) - }, [hasQuery, navigate, search.sort]) - useEffect(() => { if (search.focus === 'search' && searchInputRef.current) { searchInputRef.current.focus() diff --git a/src/routes/skills/index.tsx b/src/routes/skills/index.tsx index 85129dab6..9abb0d6fc 100644 --- a/src/routes/skills/index.tsx +++ b/src/routes/skills/index.tsx @@ -1,5 +1,7 @@ import { createFileRoute, redirect } from '@tanstack/react-router' +import { useQuery } from 'convex/react' import { useRef } from 'react' +import { api } from '../../../convex/_generated/api' import { parseSort } from './-params' import { SkillsResults } from './-SkillsResults' import { SkillsToolbar } from './-SkillsToolbar' @@ -49,6 +51,9 @@ export function SkillsIndex() { const navigate = Route.useNavigate() const search = Route.useSearch() const searchInputRef = useRef(null) + const totalSkills = useQuery(api.skills.countPublicSkills) + const totalSkillsText = + typeof totalSkills === 'number' ? totalSkills.toLocaleString('en-US') : null const model = useSkillsBrowseModel({ navigate, @@ -61,6 +66,7 @@ export function SkillsIndex() {

Skills + {totalSkillsText && {` (${totalSkillsText})`}}

{model.isLoadingSkills