diff --git a/bun.lock b/bun.lock index bd4ee54c..0d59e815 100644 --- a/bun.lock +++ b/bun.lock @@ -24,7 +24,6 @@ "clawdhub-schema": "workspace:*", "clsx": "^2.1.1", "convex": "^1.31.5", - "convex-helpers": "^0.1.111", "fflate": "^0.8.2", "h3": "2.0.1-rc.8", "lucide-react": "^0.562.0", @@ -742,8 +741,6 @@ "convex": ["convex@1.31.5", "", { "dependencies": { "esbuild": "0.27.0", "prettier": "^3.0.0" }, "peerDependencies": { "@auth0/auth0-react": "^2.0.1", "@clerk/clerk-react": "^4.12.8 || ^5.0.0", "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" }, "optionalPeers": ["@auth0/auth0-react", "@clerk/clerk-react", "react"], "bin": { "convex": "bin/main.js" } }, "sha512-E1IuJKFwMCHDToNGukBPs6c7RFaarR3t8chLF9n98TM5/Tgmj8lM6l7sKM1aJ3VwqGaB4wbeUAPY8osbCOXBhQ=="], - "convex-helpers": ["convex-helpers@0.1.111", "", { "peerDependencies": { "@standard-schema/spec": "^1.0.0", "convex": "^1.25.4", "hono": "^4.0.5", "react": "^17.0.2 || ^18.0.0 || ^19.0.0", "typescript": "^5.5", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@standard-schema/spec", "hono", "react", "typescript", "zod"], "bin": { "convex-helpers": "bin.cjs" } }, "sha512-0O59Ohi8HVc3+KULxSC6JHsw8cQJyc8gZ7OAfNRVX7T5Wy6LhPx3l8veYN9avKg7UiPlO7m1eBiQMHKclIyXyQ=="], - "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], "cookie-es": ["cookie-es@2.0.0", "", {}, "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg=="], diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 2f7e30f3..cca45e9c 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -12,7 +12,6 @@ import type * as auth from "../auth.js"; import type * as comments from "../comments.js"; import type * as crons from "../crons.js"; import type * as devSeed from "../devSeed.js"; -import type * as devSeedExtra from "../devSeedExtra.js"; import type * as downloads from "../downloads.js"; import type * as githubBackups from "../githubBackups.js"; import type * as githubBackupsNode from "../githubBackupsNode.js"; @@ -45,7 +44,6 @@ import type * as rateLimits from "../rateLimits.js"; import type * as search from "../search.js"; import type * as seed from "../seed.js"; import type * as seedSouls from "../seedSouls.js"; -import type * as skillStatEvents from "../skillStatEvents.js"; import type * as skills from "../skills.js"; import type * as soulComments from "../soulComments.js"; import type * as soulDownloads from "../soulDownloads.js"; @@ -70,7 +68,6 @@ declare const fullApi: ApiFromModules<{ comments: typeof comments; crons: typeof crons; devSeed: typeof devSeed; - devSeedExtra: typeof devSeedExtra; downloads: typeof downloads; githubBackups: typeof githubBackups; githubBackupsNode: typeof githubBackupsNode; @@ -103,7 +100,6 @@ declare const fullApi: ApiFromModules<{ search: typeof search; seed: typeof seed; seedSouls: typeof seedSouls; - skillStatEvents: typeof skillStatEvents; skills: typeof skills; soulComments: typeof soulComments; soulDownloads: typeof soulDownloads; diff --git a/convex/crons.ts b/convex/crons.ts index e36dd97b..1a2de382 100644 --- a/convex/crons.ts +++ b/convex/crons.ts @@ -24,11 +24,4 @@ crons.interval( { batchSize: 200, maxBatches: 5 }, ) -crons.interval( - 'skill-stat-events', - { minutes: 5 }, - internal.skillStatEvents.processSkillStatEventsInternal, - { batchSize: 100 }, -) - export default crons diff --git a/convex/devSeedExtra.ts b/convex/devSeedExtra.ts deleted file mode 100644 index 8b7bdf55..00000000 --- a/convex/devSeedExtra.ts +++ /dev/null @@ -1,541 +0,0 @@ -/** - * Extra seed skills for pagination testing. - * - * This file contains 50 placeholder skills to test pagination behavior. - * Run with: bunx convex run internal.devSeedExtra.seedExtraSkillsInternal - * Or with reset: bunx convex run internal.devSeedExtra.seedExtraSkillsInternal '{"reset": true}' - */ - -import { v } from 'convex/values' -import { internal } from './_generated/api' -import type { Id } from './_generated/dataModel' -import type { ActionCtx } from './_generated/server' -import { internalAction, internalMutation } from './_generated/server' -import { parseClawdisMetadata, parseFrontmatter } from './lib/skills' - -type SeedSkillSpec = { - slug: string - displayName: string - summary: string - version: string - metadata: Record - rawSkillMd: string -} - -function makeSkill( - slug: string, - displayName: string, - summary: string, - envVars: string[] = [], - commands: string[] = ['help', 'status', 'run'], -): SeedSkillSpec { - const cliHelp = `${slug} - ${summary} - -Usage: - ${slug} [command] - -Commands: -${commands.map((cmd) => ` ${cmd.padEnd(12)} Run ${cmd} operation`).join('\n')} - -Flags: - -h, --help Show help - --json Output as JSON -` - - const rawSkillMd = `--- -name: ${slug} -description: ${summary} ---- - -# ${displayName} - -## CLI - -\`\`\`bash -${commands.map((cmd) => `${slug} ${cmd}`).join('\n')} -\`\`\` - -## Usage - -Use this skill to ${summary.toLowerCase()}. -` - - return { - slug, - displayName, - summary, - version: '0.1.0', - metadata: { - clawdbot: { - nix: { - plugin: `github:example/${slug}`, - systems: ['aarch64-darwin', 'x86_64-linux'], - }, - config: { - requiredEnv: envVars, - }, - cliHelp, - }, - }, - rawSkillMd, - } -} - -// 50 placeholder skills for pagination testing -const EXTRA_SEED_SKILLS: SeedSkillSpec[] = [ - // DevOps & Infrastructure (10) - makeSkill( - 'kubectl-helper', - 'Kubectl Helper', - 'Simplified kubectl commands for common Kubernetes operations.', - ['KUBECONFIG'], - ['pods', 'logs', 'exec', 'describe', 'apply'], - ), - makeSkill( - 'terraform-runner', - 'Terraform Runner', - 'Execute Terraform plans and applies with safety checks.', - ['TF_VAR_region', 'AWS_PROFILE'], - ['plan', 'apply', 'destroy', 'output', 'state'], - ), - makeSkill( - 'ansible-exec', - 'Ansible Exec', - 'Run Ansible playbooks and ad-hoc commands.', - ['ANSIBLE_INVENTORY'], - ['playbook', 'adhoc', 'inventory', 'facts', 'vault'], - ), - makeSkill( - 'docker-compose-mgr', - 'Docker Compose Manager', - 'Manage Docker Compose stacks and services.', - ['DOCKER_HOST'], - ['up', 'down', 'logs', 'ps', 'restart'], - ), - makeSkill( - 'k9s-wrapper', - 'K9s Wrapper', - 'Interactive Kubernetes cluster management via K9s.', - ['KUBECONFIG'], - ['launch', 'contexts', 'namespaces', 'pods', 'logs'], - ), - makeSkill( - 'helm-charts', - 'Helm Charts', - 'Manage Helm chart deployments and releases.', - ['KUBECONFIG', 'HELM_REPO'], - ['install', 'upgrade', 'rollback', 'list', 'search'], - ), - makeSkill( - 'prometheus-alerts', - 'Prometheus Alerts', - 'Query Prometheus metrics and manage alerting rules.', - ['PROMETHEUS_URL'], - ['query', 'alerts', 'rules', 'targets', 'status'], - ), - makeSkill( - 'grafana-dash', - 'Grafana Dashboards', - 'Create and manage Grafana dashboards programmatically.', - ['GRAFANA_URL', 'GRAFANA_API_KEY'], - ['list', 'export', 'import', 'create', 'delete'], - ), - makeSkill( - 'nginx-config', - 'Nginx Config', - 'Generate and validate Nginx configuration files.', - ['NGINX_CONF_DIR'], - ['generate', 'validate', 'reload', 'test', 'sites'], - ), - makeSkill( - 'jenkins-jobs', - 'Jenkins Jobs', - 'Manage Jenkins jobs and pipelines.', - ['JENKINS_URL', 'JENKINS_TOKEN'], - ['list', 'build', 'status', 'logs', 'config'], - ), - - // Productivity (8) - makeSkill( - 'todoist-sync', - 'Todoist Sync', - 'Sync and manage Todoist tasks from the command line.', - ['TODOIST_API_TOKEN'], - ['list', 'add', 'complete', 'projects', 'labels'], - ), - makeSkill( - 'notion-backup', - 'Notion Backup', - 'Export and backup Notion workspaces.', - ['NOTION_TOKEN'], - ['export', 'backup', 'restore', 'pages', 'databases'], - ), - makeSkill( - 'gcal-manager', - 'Google Calendar Manager', - 'Manage Google Calendar events and schedules.', - ['GOOGLE_CREDENTIALS_FILE'], - ['events', 'create', 'delete', 'calendars', 'reminders'], - ), - makeSkill( - 'time-tracker', - 'Time Tracker', - 'Track time spent on projects and tasks.', - ['TIMETRACK_DB'], - ['start', 'stop', 'status', 'report', 'projects'], - ), - makeSkill( - 'email-digest', - 'Email Digest', - 'Generate email digests and summaries.', - ['IMAP_SERVER', 'IMAP_USER'], - ['fetch', 'digest', 'search', 'folders', 'unread'], - ), - makeSkill( - 'habit-tracker', - 'Habit Tracker', - 'Track daily habits and streaks.', - ['HABITS_DB'], - ['log', 'streak', 'stats', 'habits', 'remind'], - ), - makeSkill( - 'bookmark-sync', - 'Bookmark Sync', - 'Sync bookmarks across browsers and devices.', - ['BOOKMARKS_DIR'], - ['sync', 'export', 'import', 'search', 'tags'], - ), - makeSkill( - 'notes-export', - 'Notes Export', - 'Export notes to various formats.', - ['NOTES_DIR'], - ['export', 'convert', 'search', 'list', 'tags'], - ), - - // Media & Entertainment (6) - makeSkill( - 'spotify-ctl', - 'Spotify Control', - 'Control Spotify playback from the terminal.', - ['SPOTIFY_CLIENT_ID', 'SPOTIFY_CLIENT_SECRET'], - ['play', 'pause', 'next', 'prev', 'search'], - ), - makeSkill( - 'plex-manager', - 'Plex Manager', - 'Manage Plex media libraries and playback.', - ['PLEX_URL', 'PLEX_TOKEN'], - ['libraries', 'scan', 'search', 'play', 'sessions'], - ), - makeSkill( - 'ytdl-wrapper', - 'YouTube Downloader', - 'Download videos from YouTube and other platforms.', - ['YTDL_OUTPUT_DIR'], - ['download', 'info', 'playlist', 'audio', 'formats'], - ), - makeSkill( - 'podcast-dl', - 'Podcast Downloader', - 'Download and manage podcast episodes.', - ['PODCAST_DIR'], - ['subscribe', 'download', 'list', 'play', 'search'], - ), - makeSkill( - 'audiobook-player', - 'Audiobook Player', - 'Manage and play audiobook collections.', - ['AUDIOBOOK_DIR'], - ['play', 'pause', 'bookmark', 'list', 'progress'], - ), - makeSkill( - 'music-lib', - 'Music Library', - 'Organize and query local music libraries.', - ['MUSIC_DIR'], - ['scan', 'search', 'play', 'playlist', 'stats'], - ), - - // Smart Home (8) - makeSkill( - 'hass-control', - 'Home Assistant Control', - 'Control Home Assistant entities and automations.', - ['HASS_URL', 'HASS_TOKEN'], - ['entities', 'services', 'automations', 'scenes', 'history'], - ), - makeSkill( - 'zigbee-mqtt', - 'Zigbee2MQTT', - 'Manage Zigbee devices via MQTT.', - ['MQTT_BROKER', 'ZIGBEE_TOPIC'], - ['devices', 'pair', 'remove', 'rename', 'groups'], - ), - makeSkill( - 'tasmota-ctl', - 'Tasmota Control', - 'Control Tasmota-flashed devices.', - ['TASMOTA_HOSTS'], - ['status', 'power', 'config', 'update', 'backup'], - ), - makeSkill( - 'esphome-mgr', - 'ESPHome Manager', - 'Manage ESPHome device configurations.', - ['ESPHOME_DIR'], - ['compile', 'upload', 'logs', 'dashboard', 'config'], - ), - makeSkill( - 'mqtt-broker', - 'MQTT Broker', - 'Interact with MQTT brokers for IoT messaging.', - ['MQTT_BROKER', 'MQTT_USER'], - ['pub', 'sub', 'topics', 'clients', 'stats'], - ), - makeSkill( - 'hue-lights', - 'Philips Hue', - 'Control Philips Hue lights and scenes.', - ['HUE_BRIDGE_IP', 'HUE_API_KEY'], - ['lights', 'scenes', 'groups', 'schedules', 'sensors'], - ), - makeSkill( - 'smart-thermo', - 'Smart Thermostat', - 'Control smart thermostats and HVAC systems.', - ['THERMOSTAT_API_KEY'], - ['status', 'set', 'schedule', 'history', 'zones'], - ), - makeSkill( - 'cam-viewer', - 'Camera Viewer', - 'View and manage security camera feeds.', - ['CAMERA_URLS'], - ['list', 'snapshot', 'stream', 'record', 'events'], - ), - - // Finance (5) - makeSkill( - 'budget-track', - 'Budget Tracker', - 'Track budgets and spending across categories.', - ['BUDGET_DB'], - ['summary', 'add', 'categories', 'report', 'goals'], - ), - makeSkill( - 'crypto-watch', - 'Crypto Watcher', - 'Monitor cryptocurrency prices and portfolios.', - ['CRYPTO_API_KEY'], - ['prices', 'portfolio', 'alerts', 'history', 'convert'], - ), - makeSkill( - 'stock-alerts', - 'Stock Alerts', - 'Set up stock price alerts and notifications.', - ['STOCK_API_KEY'], - ['quote', 'watch', 'alerts', 'portfolio', 'news'], - ), - makeSkill( - 'expense-cat', - 'Expense Categorizer', - 'Automatically categorize expenses.', - ['EXPENSE_DB'], - ['import', 'categorize', 'report', 'rules', 'export'], - ), - makeSkill( - 'invoice-gen', - 'Invoice Generator', - 'Generate and manage invoices.', - ['INVOICE_DIR', 'COMPANY_INFO'], - ['create', 'list', 'send', 'paid', 'overdue'], - ), - - // Communication (5) - makeSkill( - 'slack-bot', - 'Slack Bot', - 'Interact with Slack channels and messages.', - ['SLACK_TOKEN'], - ['send', 'channels', 'users', 'search', 'files'], - ), - makeSkill( - 'discord-mgr', - 'Discord Manager', - 'Manage Discord servers and messages.', - ['DISCORD_TOKEN'], - ['send', 'servers', 'channels', 'members', 'roles'], - ), - makeSkill( - 'telegram-bot', - 'Telegram Bot', - 'Send and receive Telegram messages.', - ['TELEGRAM_BOT_TOKEN'], - ['send', 'receive', 'chats', 'files', 'inline'], - ), - makeSkill( - 'matrix-cli', - 'Matrix CLI', - 'Interact with Matrix chat rooms.', - ['MATRIX_HOMESERVER', 'MATRIX_TOKEN'], - ['send', 'rooms', 'join', 'leave', 'sync'], - ), - makeSkill( - 'irc-bridge', - 'IRC Bridge', - 'Bridge IRC channels to other platforms.', - ['IRC_SERVER', 'IRC_NICK'], - ['connect', 'join', 'send', 'channels', 'users'], - ), - - // Data & Analytics (5) - makeSkill( - 'pg-queries', - 'PostgreSQL Queries', - 'Execute PostgreSQL queries and manage databases.', - ['DATABASE_URL'], - ['query', 'tables', 'schema', 'backup', 'restore'], - ), - makeSkill( - 'clickhouse-ql', - 'ClickHouse Queries', - 'Run ClickHouse analytics queries.', - ['CLICKHOUSE_URL'], - ['query', 'tables', 'insert', 'system', 'optimize'], - ), - makeSkill( - 'redis-cli', - 'Redis CLI', - 'Interact with Redis cache and data structures.', - ['REDIS_URL'], - ['get', 'set', 'keys', 'info', 'flush'], - ), - makeSkill( - 'elastic-search', - 'Elasticsearch', - 'Search and manage Elasticsearch indices.', - ['ELASTICSEARCH_URL'], - ['search', 'index', 'mapping', 'cluster', 'aliases'], - ), - makeSkill( - 'mongo-shell', - 'MongoDB Shell', - 'Query and manage MongoDB collections.', - ['MONGODB_URI'], - ['find', 'insert', 'update', 'delete', 'aggregate'], - ), - - // Security (3) - makeSkill( - 'vault-secrets', - 'Vault Secrets', - 'Manage secrets in HashiCorp Vault.', - ['VAULT_ADDR', 'VAULT_TOKEN'], - ['read', 'write', 'list', 'delete', 'seal'], - ), - makeSkill( - 'gpg-keys', - 'GPG Keys', - 'Manage GPG keys and encryption.', - ['GNUPGHOME'], - ['list', 'generate', 'export', 'import', 'encrypt'], - ), - makeSkill( - 'ssh-rotate', - 'SSH Key Rotator', - 'Rotate and manage SSH keys.', - ['SSH_KEY_DIR'], - ['generate', 'rotate', 'deploy', 'list', 'revoke'], - ), -] - -function injectMetadata(rawSkillMd: string, metadata: Record) { - const frontmatterEnd = rawSkillMd.indexOf('\n---', 3) - if (frontmatterEnd === -1) return rawSkillMd - return `${rawSkillMd.slice(0, frontmatterEnd)}\nmetadata: ${JSON.stringify( - metadata, - )}${rawSkillMd.slice(frontmatterEnd)}` -} - -function randomStats() { - return { - downloads: Math.floor(Math.random() * 5000), - stars: Math.floor(Math.random() * 500), - installsCurrent: Math.floor(Math.random() * 200), - installsAllTime: Math.floor(Math.random() * 1000), - } -} - -export const applyRandomStats = internalMutation({ - args: { - skillId: v.id('skills'), - stats: v.object({ - downloads: v.number(), - stars: v.number(), - installsCurrent: v.number(), - installsAllTime: v.number(), - }), - }, - handler: async (ctx, args) => { - await ctx.db.patch(args.skillId, { - statsDownloads: args.stats.downloads, - statsStars: args.stats.stars, - statsInstallsCurrent: args.stats.installsCurrent, - statsInstallsAllTime: args.stats.installsAllTime, - stats: { - downloads: args.stats.downloads, - stars: args.stats.stars, - installsCurrent: args.stats.installsCurrent, - installsAllTime: args.stats.installsAllTime, - versions: 1, - comments: 0, - }, - }) - }, -}) - -export const seedExtraSkillsInternal = internalAction({ - args: { - reset: v.optional(v.boolean()), - }, - handler: async (ctx: ActionCtx, args) => { - const results: Array<{ slug: string; ok: boolean; skipped?: boolean }> = [] - - for (const spec of EXTRA_SEED_SKILLS) { - const skillMd = injectMetadata(spec.rawSkillMd, spec.metadata) - const frontmatter = parseFrontmatter(skillMd) - const clawdis = parseClawdisMetadata(frontmatter) - const storageId = await ctx.storage.store(new Blob([skillMd], { type: 'text/markdown' })) - - const result = (await ctx.runMutation(internal.devSeed.seedSkillMutation, { - reset: args.reset, - storageId, - metadata: spec.metadata, - frontmatter, - clawdis, - skillMd, - slug: spec.slug, - displayName: spec.displayName, - summary: spec.summary, - version: spec.version, - })) as { ok: boolean; skipped?: boolean; skillId?: string } - - // Apply random stats after creation (only if not skipped) - if (result.skillId && !result.skipped) { - const stats = randomStats() - await ctx.runMutation(internal.devSeedExtra.applyRandomStats, { - skillId: result.skillId as Id<'skills'>, - stats, - }) - } - - results.push({ slug: spec.slug, ok: result.ok, skipped: result.skipped }) - } - - const created = results.filter((r) => !r.skipped).length - const skipped = results.filter((r) => r.skipped).length - - return { ok: true, total: results.length, created, skipped } - }, -}) diff --git a/convex/downloads.ts b/convex/downloads.ts index ac03a28b..e8556a94 100644 --- a/convex/downloads.ts +++ b/convex/downloads.ts @@ -2,7 +2,7 @@ import { v } from 'convex/values' import { zipSync } from 'fflate' import { api } from './_generated/api' import { httpAction, mutation } from './_generated/server' -import { insertStatEvent } from './skillStatEvents' +import { applySkillStatDeltas, bumpDailySkillStats } from './lib/skillStats' export const downloadZip = httpAction(async (ctx, request) => { const url = new URL(request.url) @@ -70,9 +70,12 @@ export const increment = mutation({ handler: async (ctx, args) => { const skill = await ctx.db.get(args.skillId) if (!skill) return - await insertStatEvent(ctx, { - skillId: skill._id, - kind: 'download', + const now = Date.now() + const patch = applySkillStatDeltas(skill, { downloads: 1 }) + await ctx.db.patch(skill._id, { + ...patch, + updatedAt: now, }) + await bumpDailySkillStats(ctx, { skillId: skill._id, now, downloads: 1 }) }, }) diff --git a/convex/schema.ts b/convex/schema.ts index 76f5aef4..3485e61a 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -72,7 +72,6 @@ const skills = defineTable({ .index('by_stats_installs_current', ['statsInstallsCurrent', 'updatedAt']) .index('by_stats_installs_all_time', ['statsInstallsAllTime', 'updatedAt']) .index('by_batch', ['batch']) - .index('by_active_updated', ['softDeletedAt', 'updatedAt']) const souls = defineTable({ slug: v.string(), @@ -218,29 +217,6 @@ const skillStatBackfillState = defineTable({ updatedAt: v.number(), }).index('by_key', ['key']) -const skillStatEvents = defineTable({ - skillId: v.id('skills'), - kind: v.union( - v.literal('download'), - v.literal('star'), - v.literal('unstar'), - v.literal('install_new'), - v.literal('install_reactivate'), - v.literal('install_deactivate'), - v.literal('install_clear'), - ), - delta: v.optional( - v.object({ - allTime: v.number(), - current: v.number(), - }), - ), - occurredAt: v.number(), - processedAt: v.optional(v.number()), -}) - .index('by_unprocessed', ['processedAt']) - .index('by_skill', ['skillId']) - const soulEmbeddings = defineTable({ soulId: v.id('souls'), versionId: v.id('soulVersions'), @@ -390,7 +366,6 @@ export default defineSchema({ skillDailyStats, skillLeaderboards, skillStatBackfillState, - skillStatEvents, comments, soulComments, stars, diff --git a/convex/search.ts b/convex/search.ts index 11541dd7..c5f9234a 100644 --- a/convex/search.ts +++ b/convex/search.ts @@ -7,7 +7,7 @@ import { matchesExactTokens, tokenize } from './lib/searchText' type HydratedEntry = { embeddingId: Id<'skillEmbeddings'> - skill: Doc<'skills'> + skill: Doc<'skills'> | null version: Doc<'skillVersions'> | null ownerHandle: string | null } diff --git a/convex/skillStatEvents.ts b/convex/skillStatEvents.ts deleted file mode 100644 index 3846c84a..00000000 --- a/convex/skillStatEvents.ts +++ /dev/null @@ -1,280 +0,0 @@ -/** - * Skill Stat Events - Event-sourced stats processing for skills - * - * Instead of updating skill stats synchronously in the hot path (which can cause - * contention when multiple users download/star/install the same skill), we insert - * lightweight event records and process them in batches via a cron job. - * - * Flow: - * 1. User action (download, star, install) → insertStatEvent() writes to skillStatEvents table - * 2. Cron job runs every 5 minutes → processSkillStatEventsInternal() processes batches - * 3. Events are aggregated per-skill to minimize database operations - * 4. Stats are applied to skill documents and daily stats tables - * 5. Events are marked as processed (kept forever for auditing) - */ - -import { v } from 'convex/values' -import { internal } from './_generated/api' -import type { Doc, Id } from './_generated/dataModel' -import type { MutationCtx } from './_generated/server' -import { internalMutation } from './_generated/server' -import { applySkillStatDeltas, bumpDailySkillStats } from './lib/skillStats' - -/** - * Event types that affect skill stats: - * - * - download: User downloaded skill as zip (+1 downloads) - * - star: User starred the skill (+1 stars) - * - unstar: User removed their star (-1 stars) - * - install_new: First time this user installed this skill (+1 installsAllTime, +1 installsCurrent) - * - install_reactivate: User re-added skill after removing it (+1 installsCurrent only) - * - install_deactivate: User removed skill from all projects (-1 installsCurrent) - * - install_clear: User cleared all telemetry data (custom delta for both allTime and current) - */ -export type StatEventKind = - | 'download' - | 'star' - | 'unstar' - | 'install_new' - | 'install_reactivate' - | 'install_deactivate' - | 'install_clear' - -/** - * Insert a stat event to be processed later by the cron job. - * - * This is called from the hot path (downloads, stars, telemetry) instead of - * directly updating skill stats. It's a single insert with no read-modify-write - * cycle, so it's fast and doesn't contend with other operations on the same skill. - * - * @param ctx - Mutation context - * @param params.skillId - The skill being affected - * @param params.kind - Type of event (download, star, install_new, etc.) - * @param params.occurredAt - When the event happened (defaults to now). Important for - * daily stats bucketing - we want downloads at 11:55 PM Monday - * to count toward Monday's stats even if processed on Tuesday. - * @param params.delta - Only used for install_clear events, specifies exact delta amounts - */ -export async function insertStatEvent( - ctx: MutationCtx, - params: { - skillId: Id<'skills'> - kind: StatEventKind - occurredAt?: number - delta?: { allTime: number; current: number } - }, -) { - await ctx.db.insert('skillStatEvents', { - skillId: params.skillId, - kind: params.kind, - delta: params.delta, - occurredAt: params.occurredAt ?? Date.now(), - processedAt: undefined, - }) -} - -/** - * Aggregated deltas for a single skill after processing multiple events. - * - * When we process a batch of 100 events, many might be for the same skill. - * Instead of updating the skill document once per event, we aggregate all - * events for each skill and apply a single update. - * - * The downloadEvents and installNewEvents arrays store the original timestamps - * so we can update daily stats with the correct day bucket for each event. - */ -type AggregatedDeltas = { - downloads: number - stars: number - installsAllTime: number - installsCurrent: number - /** Original timestamps for each download event (for daily stats bucketing) */ - downloadEvents: number[] - /** Original timestamps for each new install event (for daily stats bucketing) */ - installNewEvents: number[] -} - -/** - * Aggregate multiple events for a single skill into net deltas. - * - * Example: If a skill has these events in the batch: - * - download (Mon 11pm) - * - download (Tue 1am) - * - star - * - unstar - * - star - * - * The result would be: - * - downloads: 2 - * - stars: 1 (net: +1 -1 +1 = +1) - * - downloadEvents: [, ] - * - * This aggregation reduces the number of database operations from N events - * to 1 skill update + N daily stat updates (which themselves may coalesce - * if multiple events fall on the same day). - */ -function aggregateEvents(events: Doc<'skillStatEvents'>[]): AggregatedDeltas { - const result: AggregatedDeltas = { - downloads: 0, - stars: 0, - installsAllTime: 0, - installsCurrent: 0, - downloadEvents: [], - installNewEvents: [], - } - - for (const event of events) { - switch (event.kind) { - case 'download': - result.downloads += 1 - result.downloadEvents.push(event.occurredAt) - break - case 'star': - result.stars += 1 - break - case 'unstar': - result.stars -= 1 - break - case 'install_new': - // New user installing for the first time: count toward both lifetime and current - result.installsAllTime += 1 - result.installsCurrent += 1 - result.installNewEvents.push(event.occurredAt) - break - case 'install_reactivate': - // User re-added skill after removing: only affects current count - result.installsCurrent += 1 - break - case 'install_deactivate': - // User removed skill from all projects: only affects current count - result.installsCurrent -= 1 - break - case 'install_clear': - // User cleared telemetry: uses custom delta values (typically negative) - if (event.delta) { - result.installsAllTime += event.delta.allTime - result.installsCurrent += event.delta.current - } - break - } - } - - return result -} - -/** - * Process a batch of unprocessed stat events. - * - * Called by cron every 5 minutes. Processes up to batchSize events (default 100). - * If the batch is full, schedules an immediate follow-up run to drain the queue. - * - * Processing steps: - * 1. Query unprocessed events (processedAt is undefined) - * 2. Group events by skillId to minimize skill document fetches - * 3. For each skill: - * a. Fetch the skill document once - * b. Aggregate all events for this skill into net deltas - * c. Apply deltas to skill stats (downloads, stars, installs) - * d. Update daily stats for trending (using original event timestamps) - * e. Mark all events as processed - * 4. If batch was full, schedule another run immediately - * - * Aggregation levels: - * - Level 1: Batch of 100 events from the queue - * - Level 2: Group by skillId (e.g., 100 events → 30 unique skills) - * - Level 3: Aggregate events per skill (e.g., 5 events → 1 skill update) - * - Level 4: Daily stats may coalesce (e.g., 3 downloads same day → 1 upsert) - */ -export const processSkillStatEventsInternal = internalMutation({ - args: { batchSize: v.optional(v.number()) }, - handler: async (ctx, args) => { - const batchSize = args.batchSize ?? 100 - const now = Date.now() - - // Level 1: Fetch a batch of unprocessed events - const events = await ctx.db - .query('skillStatEvents') - .withIndex('by_unprocessed', (q) => q.eq('processedAt', undefined)) - .take(batchSize) - - if (events.length === 0) { - return { processed: 0 } - } - - // Level 2: Group events by skillId to minimize database reads - // Instead of fetching the same skill document multiple times, - // we fetch it once and process all its events together - const eventsBySkill = new Map, Doc<'skillStatEvents'>[]>() - for (const event of events) { - const existing = eventsBySkill.get(event.skillId) ?? [] - existing.push(event) - eventsBySkill.set(event.skillId, existing) - } - - // Process each skill's events - for (const [skillId, skillEvents] of eventsBySkill) { - const skill = await ctx.db.get(skillId) - - // Skill was deleted - just mark events as processed - if (!skill) { - for (const event of skillEvents) { - await ctx.db.patch(event._id, { processedAt: now }) - } - continue - } - - // Level 3: Aggregate all events for this skill into net deltas - // e.g., 3 downloads + 2 stars - 1 unstar → { downloads: 3, stars: 1 } - const deltas = aggregateEvents(skillEvents) - - // Apply aggregated deltas to skill stats (single update per skill) - if ( - deltas.downloads !== 0 || - deltas.stars !== 0 || - deltas.installsAllTime !== 0 || - deltas.installsCurrent !== 0 - ) { - const patch = applySkillStatDeltas(skill, { - downloads: deltas.downloads, - stars: deltas.stars, - installsAllTime: deltas.installsAllTime, - installsCurrent: deltas.installsCurrent, - }) - await ctx.db.patch(skill._id, { - ...patch, - updatedAt: now, - }) - } - - // Update daily stats for trending/leaderboards - // We use the ORIGINAL event timestamp (occurredAt) so that: - // - A download at Mon 11:55 PM counts toward Monday's stats - // - Even if the cron processes it on Tuesday - // - // Level 4: bumpDailySkillStats does its own coalescing - multiple - // events on the same day will update the same daily record - for (const occurredAt of deltas.downloadEvents) { - await bumpDailySkillStats(ctx, { skillId, now: occurredAt, downloads: 1 }) - } - for (const occurredAt of deltas.installNewEvents) { - await bumpDailySkillStats(ctx, { skillId, now: occurredAt, installs: 1 }) - } - - // Mark all events for this skill as processed - for (const event of skillEvents) { - await ctx.db.patch(event._id, { processedAt: now }) - } - } - - // If we hit the batch limit, there may be more events waiting. - // Schedule an immediate follow-up run to drain the queue. - // This ensures high-volume periods don't create a backlog. - if (events.length === batchSize) { - await ctx.scheduler.runAfter(0, internal.skillStatEvents.processSkillStatEventsInternal, { - batchSize, - }) - } - - return { processed: events.length } - }, -}) diff --git a/convex/skills.ts b/convex/skills.ts index f07095da..9ea5a36a 100644 --- a/convex/skills.ts +++ b/convex/skills.ts @@ -1,13 +1,11 @@ -import { paginationOptsValidator } from 'convex/server' import { ConvexError, v } from 'convex/values' -import { paginator } from 'convex-helpers/server/pagination' import { internal } from './_generated/api' import type { Doc, Id } from './_generated/dataModel' import type { MutationCtx, QueryCtx } from './_generated/server' import { action, internalMutation, internalQuery, mutation, query } from './_generated/server' import { assertRole, requireUser, requireUserFromAction } from './lib/access' import { generateChangelogPreview as buildChangelogPreview } from './lib/changelog' -import { buildTrendingLeaderboard } from './lib/leaderboards' +import { buildTrendingLeaderboard, getTrendingRange } from './lib/leaderboards' import { fetchText, type PublishResult, @@ -15,7 +13,6 @@ import { queueHighlightedWebhook, } from './lib/skillPublish' import { getFrontmatterValue, hashSkillFiles } from './lib/skills' -import schema from './schema' export { publishVersionForUser } from './lib/skillPublish' @@ -189,7 +186,6 @@ export const listWithLatest = query({ }, }) -// TODO: Delete listPublicPage once all clients have migrated to listPublicPageV2 export const listPublicPage = query({ args: { cursor: v.optional(v.string()), @@ -250,37 +246,6 @@ export const listPublicPage = query({ }, }) -/** - * V2 of listPublicPage using convex-helpers paginator for better cache behavior. - * - * Key differences from V1: - * - Uses `paginator` from convex-helpers (doesn't track end-cursor internally, better caching) - * - Uses `by_active_updated` index to filter soft-deleted skills at query level - * - Returns standard pagination shape compatible with usePaginatedQuery - */ -export const listPublicPageV2 = query({ - args: { - paginationOpts: paginationOptsValidator, - }, - 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 result = await paginator(ctx.db, schema) - .query('skills') - .withIndex('by_active_updated', (q) => q.eq('softDeletedAt', undefined)) - .order('desc') - .paginate(args.paginationOpts) - - // Build the public skill entries (fetch latestVersion + ownerHandle) - const items = await buildPublicSkillEntries(ctx, result.page) - - return { - ...result, - page: items, - } - }, -}) - function sortToIndex( sort: 'downloads' | 'stars' | 'installsCurrent' | 'installsAllTime', ): @@ -301,20 +266,20 @@ function sortToIndex( } async function getTrendingEntries(ctx: QueryCtx, limit: number) { - // Use the pre-computed leaderboard from the hourly cron job. - // Avoid Date.now() here to keep the query deterministic and cacheable. + const now = Date.now() + const { startDay, endDay } = getTrendingRange(now) const latest = await ctx.db .query('skillLeaderboards') .withIndex('by_kind', (q) => q.eq('kind', 'trending')) .order('desc') .take(1) - if (latest[0]) { - return latest[0].items.slice(0, limit) + const leaderboard = latest[0] + if (leaderboard && leaderboard.rangeStartDay === startDay && leaderboard.rangeEndDay === endDay) { + return leaderboard.items.slice(0, limit) } - // No leaderboard exists yet (cold start) - compute on the fly - const fallback = await buildTrendingLeaderboard(ctx, { limit, now: Date.now() }) + const fallback = await buildTrendingLeaderboard(ctx, { limit, now }) return fallback.items } diff --git a/convex/stars.ts b/convex/stars.ts index 53f6f271..3d2beb75 100644 --- a/convex/stars.ts +++ b/convex/stars.ts @@ -2,7 +2,7 @@ import { v } from 'convex/values' import type { Doc } from './_generated/dataModel' import { internalMutation, mutation, query } from './_generated/server' import { requireUser } from './lib/access' -import { insertStatEvent } from './skillStatEvents' +import { applySkillStatDeltas } from './lib/skillStats' export const isStarred = query({ args: { skillId: v.id('skills') }, @@ -30,7 +30,11 @@ export const toggle = mutation({ if (existing) { await ctx.db.delete(existing._id) - await insertStatEvent(ctx, { skillId: skill._id, kind: 'unstar' }) + const patch = applySkillStatDeltas(skill, { stars: -1 }) + await ctx.db.patch(skill._id, { + ...patch, + updatedAt: Date.now(), + }) return { starred: false } } @@ -40,7 +44,10 @@ export const toggle = mutation({ createdAt: Date.now(), }) - await insertStatEvent(ctx, { skillId: skill._id, kind: 'star' }) + await ctx.db.patch(skill._id, { + ...applySkillStatDeltas(skill, { stars: 1 }), + updatedAt: Date.now(), + }) return { starred: true } }, @@ -81,7 +88,10 @@ export const addStarInternal = internalMutation({ createdAt: Date.now(), }) - await insertStatEvent(ctx, { skillId: skill._id, kind: 'star' }) + await ctx.db.patch(skill._id, { + ...applySkillStatDeltas(skill, { stars: 1 }), + updatedAt: Date.now(), + }) return { ok: true as const, starred: true, alreadyStarred: false } }, @@ -99,7 +109,10 @@ export const removeStarInternal = internalMutation({ if (!existing) return { ok: true as const, unstarred: false, alreadyUnstarred: true } await ctx.db.delete(existing._id) - await insertStatEvent(ctx, { skillId: skill._id, kind: 'unstar' }) + await ctx.db.patch(skill._id, { + ...applySkillStatDeltas(skill, { stars: -1 }), + updatedAt: Date.now(), + }) return { ok: true as const, unstarred: true, alreadyUnstarred: false } }, diff --git a/convex/telemetry.ts b/convex/telemetry.ts index 7695ae5e..c4fbe8e3 100644 --- a/convex/telemetry.ts +++ b/convex/telemetry.ts @@ -4,7 +4,7 @@ import type { Id } from './_generated/dataModel' import type { MutationCtx, QueryCtx } from './_generated/server' import { internalMutation, mutation, query } from './_generated/server' import { requireUser } from './lib/access' -import { insertStatEvent } from './skillStatEvents' +import { applySkillStatDeltas, bumpDailySkillStats } from './lib/skillStats' const TELEMETRY_STALE_MS = 120 * 24 * 60 * 60 * 1000 @@ -158,13 +158,13 @@ async function clearTelemetryForUser(ctx: MutationCtx, params: { userId: Id<'use await ctx.db.delete(entry._id) continue } - await insertStatEvent(ctx, { - skillId: skill._id, - kind: 'install_clear', - delta: { - allTime: -1, - current: entry.activeRoots > 0 ? -1 : 0, - }, + const patch = applySkillStatDeltas(skill, { + installsCurrent: entry.activeRoots > 0 ? -1 : 0, + installsAllTime: -1, + }) + await ctx.db.patch(skill._id, { + ...patch, + updatedAt: Date.now(), }) await ctx.db.delete(entry._id) } @@ -371,12 +371,25 @@ async function bumpSkillInstallCounts( ctx: MutationCtx, params: { skillId: Id<'skills'>; deltaAllTime: number; deltaCurrent: number }, ) { - if (params.deltaAllTime === 1 && params.deltaCurrent === 1) { - await insertStatEvent(ctx, { skillId: params.skillId, kind: 'install_new' }) - } else if (params.deltaAllTime === 0 && params.deltaCurrent === 1) { - await insertStatEvent(ctx, { skillId: params.skillId, kind: 'install_reactivate' }) - } else if (params.deltaAllTime === 0 && params.deltaCurrent === -1) { - await insertStatEvent(ctx, { skillId: params.skillId, kind: 'install_deactivate' }) + const skill = await ctx.db.get(params.skillId) + if (!skill) return + const now = Date.now() + const patch = applySkillStatDeltas(skill, { + installsAllTime: params.deltaAllTime, + installsCurrent: params.deltaCurrent, + }) + + await ctx.db.patch(skill._id, { + ...patch, + updatedAt: now, + }) + + if (params.deltaAllTime > 0) { + await bumpDailySkillStats(ctx, { + skillId: params.skillId, + now, + installs: params.deltaAllTime, + }) } } diff --git a/package.json b/package.json index 276daa98..472ae183 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,6 @@ "clawdhub-schema": "workspace:*", "clsx": "^2.1.1", "convex": "^1.31.5", - "convex-helpers": "^0.1.111", "fflate": "^0.8.2", "h3": "2.0.1-rc.8", "lucide-react": "^0.562.0", diff --git a/src/__tests__/skills-index.test.tsx b/src/__tests__/skills-index.test.tsx index 769bacc9..77e0b4ae 100644 --- a/src/__tests__/skills-index.test.tsx +++ b/src/__tests__/skills-index.test.tsx @@ -6,8 +6,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { SkillsIndex } from '../routes/skills/index' const navigateMock = vi.fn() +const useQueryMock = vi.fn() const useActionMock = vi.fn() -const usePaginatedQueryMock = vi.fn() let searchMock: Record = {} vi.mock('@tanstack/react-router', () => ({ @@ -20,25 +20,17 @@ vi.mock('@tanstack/react-router', () => ({ vi.mock('convex/react', () => ({ useAction: (...args: unknown[]) => useActionMock(...args), -})) - -vi.mock('convex-helpers/react', () => ({ - usePaginatedQuery: (...args: unknown[]) => usePaginatedQueryMock(...args), + useQuery: (...args: unknown[]) => useQueryMock(...args), })) describe('SkillsIndex', () => { beforeEach(() => { - usePaginatedQueryMock.mockReset() + useQueryMock.mockReset() useActionMock.mockReset() navigateMock.mockReset() searchMock = {} useActionMock.mockReturnValue(() => Promise.resolve([])) - // Default: return empty results with Exhausted status - usePaginatedQueryMock.mockReturnValue({ - results: [], - status: 'Exhausted', - loadMore: vi.fn(), - }) + useQueryMock.mockReturnValue({ items: [], nextCursor: null }) }) afterEach(() => { @@ -48,12 +40,10 @@ describe('SkillsIndex', () => { it('requests the first skills page', () => { render() - // usePaginatedQuery should be called with the API endpoint and empty args - expect(usePaginatedQueryMock).toHaveBeenCalledWith( - expect.anything(), - {}, - { initialNumItems: 25 }, - ) + expect(useQueryMock).toHaveBeenCalledWith(expect.anything(), { + cursor: undefined, + limit: 50, + }) }) it('renders an empty state when no skills are returned', () => { @@ -65,29 +55,19 @@ describe('SkillsIndex', () => { searchMock = { q: 'remind' } const actionFn = vi.fn().mockResolvedValue([]) useActionMock.mockReturnValue(actionFn) + useQueryMock.mockReturnValue(undefined) vi.useFakeTimers() render() - // usePaginatedQuery should be called with 'skip' when there's a search query - expect(usePaginatedQueryMock).toHaveBeenCalledWith(expect.anything(), 'skip', { - initialNumItems: 25, - }) + expect(useQueryMock).toHaveBeenCalledWith(expect.anything(), 'skip') await act(async () => { await vi.runAllTimersAsync() }) expect(actionFn).toHaveBeenCalledWith({ query: 'remind', highlightedOnly: false, - limit: 25, - }) - await act(async () => { - await vi.runAllTimersAsync() - }) - expect(actionFn).toHaveBeenCalledWith({ - query: 'remind', - highlightedOnly: false, - limit: 25, + limit: 50, }) }) @@ -96,9 +76,10 @@ describe('SkillsIndex', () => { vi.stubGlobal('IntersectionObserver', undefined) const actionFn = vi .fn() - .mockResolvedValueOnce(makeSearchResults(25)) .mockResolvedValueOnce(makeSearchResults(50)) + .mockResolvedValueOnce(makeSearchResults(100)) useActionMock.mockReturnValue(actionFn) + useQueryMock.mockReturnValue(undefined) vi.useFakeTimers() render() @@ -115,7 +96,7 @@ describe('SkillsIndex', () => { expect(actionFn).toHaveBeenLastCalledWith({ query: 'remind', highlightedOnly: false, - limit: 50, + limit: 100, }) }) }) diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 3627ca30..8ff77864 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -48,17 +48,7 @@ function SkillsHome() { Publish a skill - + Browse skills @@ -120,17 +110,7 @@ function SkillsHome() { )}
- + See all skills
diff --git a/src/routes/search.tsx b/src/routes/search.tsx index 551ca980..ba208998 100644 --- a/src/routes/search.tsx +++ b/src/routes/search.tsx @@ -10,10 +10,7 @@ export const Route = createFileRoute('/search')({ to: '/skills', search: { q: search.q || undefined, - sort: undefined, - dir: undefined, highlighted: search.highlighted || undefined, - view: undefined, }, replace: true, }) diff --git a/src/routes/skills/index.tsx b/src/routes/skills/index.tsx index 169b4984..3c95e1b8 100644 --- a/src/routes/skills/index.tsx +++ b/src/routes/skills/index.tsx @@ -1,13 +1,12 @@ import { createFileRoute, Link } from '@tanstack/react-router' -import { useAction } from 'convex/react' -import { usePaginatedQuery } from 'convex-helpers/react' +import { useAction, useQuery } from 'convex/react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { api } from '../../../convex/_generated/api' import type { Doc } from '../../../convex/_generated/dataModel' import { SkillCard } from '../../components/SkillCard' const sortKeys = ['newest', 'downloads', 'installs', 'stars', 'name', 'updated'] as const -const pageSize = 25 +const pageSize = 50 type SortKey = (typeof sortKeys)[number] type SortDir = 'asc' | 'desc' @@ -65,6 +64,9 @@ export function SkillsIndex() { const highlightedOnly = search.highlighted ?? false const [query, setQuery] = useState(search.q ?? '') const searchSkills = useAction(api.search.searchSkills) + const [pages, setPages] = useState>([]) + const [cursor, setCursor] = useState(null) + const [nextCursor, setNextCursor] = useState(null) const [searchResults, setSearchResults] = useState>([]) const [searchLimit, setSearchLimit] = useState(pageSize) const [isSearching, setIsSearching] = useState(false) @@ -75,25 +77,34 @@ export function SkillsIndex() { const hasQuery = trimmedQuery.length > 0 const searchKey = trimmedQuery ? `${trimmedQuery}::${highlightedOnly ? '1' : '0'}` : '' - // Use convex-helpers usePaginatedQuery for better cache behavior - const { - results: paginatedResults, - status: paginationStatus, - loadMore: loadMorePaginated, - } = usePaginatedQuery(api.skills.listPublicPageV2, hasQuery ? 'skip' : {}, { - initialNumItems: pageSize, - }) - - // Derive loading states from pagination status - // status: 'LoadingFirstPage' | 'CanLoadMore' | 'LoadingMore' | 'Exhausted' - const isLoadingList = paginationStatus === 'LoadingFirstPage' - const canLoadMoreList = paginationStatus === 'CanLoadMore' - const isLoadingMoreList = paginationStatus === 'LoadingMore' + const listPage = useQuery( + api.skills.listPublicPage, + hasQuery ? 'skip' : { cursor: cursor ?? undefined, limit: pageSize }, + ) as + | { + items: Array + nextCursor: string | null + } + | undefined + const isLoadingList = !hasQuery && pages.length === 0 && listPage === undefined useEffect(() => { setQuery(search.q ?? '') }, [search.q]) + useEffect(() => { + if (hasQuery) return + setPages([]) + setCursor(null) + setNextCursor(null) + }, [hasQuery]) + + useEffect(() => { + if (hasQuery || !listPage) return + setNextCursor(listPage.nextCursor) + setPages((prev) => (cursor ? [...prev, ...listPage.items] : listPage.items)) + }, [cursor, hasQuery, listPage]) + useEffect(() => { if (!searchKey) { setSearchResults([]) @@ -138,9 +149,8 @@ export function SkillsIndex() { ownerHandle: entry.ownerHandle ?? null, })) } - // paginatedResults is an array of page items from usePaginatedQuery - return paginatedResults as Array - }, [hasQuery, paginatedResults, searchResults]) + return pages + }, [hasQuery, pages, searchResults]) const filtered = useMemo( () => @@ -179,18 +189,20 @@ export function SkillsIndex() { const isLoadingSkills = hasQuery ? isSearching && searchResults.length === 0 : isLoadingList const canLoadMore = hasQuery ? !isSearching && searchResults.length === searchLimit && searchResults.length > 0 - : canLoadMoreList - const isLoadingMore = hasQuery ? isSearching && searchResults.length > 0 : isLoadingMoreList + : nextCursor !== null + const isLoadingMore = hasQuery + ? isSearching && searchResults.length > 0 + : listPage === undefined && pages.length > 0 const canAutoLoad = typeof IntersectionObserver !== 'undefined' const loadMore = useCallback(() => { if (isLoadingMore || !canLoadMore) return if (hasQuery) { setSearchLimit((value) => value + pageSize) - } else { - loadMorePaginated(pageSize) + } else if (nextCursor) { + setCursor(nextCursor) } - }, [canLoadMore, hasQuery, isLoadingMore, loadMorePaginated]) + }, [canLoadMore, hasQuery, isLoadingMore, nextCursor]) useEffect(() => { if (!canLoadMore || typeof IntersectionObserver === 'undefined') return diff --git a/vite.config.ts b/vite.config.ts index 6b0c4893..c982c7cd 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -9,6 +9,16 @@ import { defineConfig } from 'vite' import viteTsConfigPaths from 'vite-tsconfig-paths' const require = createRequire(import.meta.url) +const resvgWasmPath = require.resolve('@resvg/resvg-wasm/index_bg.wasm') +const bricolageBoldPath = require.resolve( + '@fontsource/bricolage-grotesque/files/bricolage-grotesque-latin-800-normal.woff2', +) +const bricolageTextPath = require.resolve( + '@fontsource/bricolage-grotesque/files/bricolage-grotesque-latin-500-normal.woff2', +) +const plexMonoPath = require.resolve( + '@fontsource/ibm-plex-mono/files/ibm-plex-mono-latin-500-normal.woff2', +) const convexEntry = require.resolve('convex') const convexRoot = dirname(dirname(dirname(convexEntry))) @@ -34,6 +44,9 @@ const config = defineConfig({ devtools(), nitro({ serverDir: 'server', + externals: { + traceInclude: [resvgWasmPath, bricolageBoldPath, bricolageTextPath, plexMonoPath], + }, }), // this is the plugin that enables path aliases viteTsConfigPaths({