diff --git a/.gitignore b/.gitignore index ee81e8959..7553fe246 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ dist .content-collections test-results +.claude/CLAUDE.md diff --git a/src/components/DocsLayout.tsx b/src/components/DocsLayout.tsx index 6eadd3e1f..616e05833 100644 --- a/src/components/DocsLayout.tsx +++ b/src/components/DocsLayout.tsx @@ -378,6 +378,10 @@ const useMenuConfig = ({ label: 'Contributors', to: '/$libraryId/$version/docs/contributors', }, + { + label: 'NPM Stats', + to: '/$libraryId/$version/docs/npm-stats', + }, ...(config.sections.find((d) => d.label === 'Community Resources') ? [ { diff --git a/src/components/NpmStatsSummaryBar.tsx b/src/components/NpmStatsSummaryBar.tsx new file mode 100644 index 000000000..955045857 --- /dev/null +++ b/src/components/NpmStatsSummaryBar.tsx @@ -0,0 +1,121 @@ +import { Suspense } from 'react' +import { useSuspenseQuery } from '@tanstack/react-query' +import { BlankErrorBoundary } from './BlankErrorBoundary' +import type { LibrarySlim } from '~/libraries' +import { ossStatsQuery, recentDownloadStatsQuery } from '~/queries/stats' +import { useNpmDownloadCounter } from '~/hooks/useNpmDownloadCounter' + +function formatNumber(num: number): string { + if (num >= 1000000) { + return (num / 1000000).toFixed(1) + 'M' + } else if (num >= 1000) { + return (num / 1000).toFixed(1) + 'K' + } + return num.toLocaleString() +} + +function isValidMetric(value: number | undefined | null): boolean { + return ( + value !== undefined && + value !== null && + !Number.isNaN(value) && + value >= 0 && + Number.isFinite(value) + ) +} + +function NpmStatsSummaryContent({ library }: { library: LibrarySlim }) { + const { data: stats } = useSuspenseQuery(ossStatsQuery({ library })) + const { data: recentStats } = useSuspenseQuery( + recentDownloadStatsQuery({ library }), + ) + + const npmDownloads = stats.npm?.totalDownloads ?? 0 + const hasNpmDownloads = isValidMetric(npmDownloads) + + // Use actual data from the API + const dailyDownloads = recentStats?.dailyDownloads ?? 0 + const weeklyDownloads = recentStats?.weeklyDownloads ?? 0 + const monthlyDownloads = recentStats?.monthlyDownloads ?? 0 + + // IMPORTANT: useNpmDownloadCounter returns a ref callback, not state + // Must be applied to a DOM element + const counterRef = useNpmDownloadCounter(stats.npm) + + return ( +
+
+ {/* All Time Downloads (Animated with ref callback) */} +
+
+ {hasNpmDownloads ? 0 : 0} +
+
+ All Time Downloads +
+
+ + {/* Monthly Downloads */} +
+
+ {formatNumber(monthlyDownloads)} +
+
+ Monthly Downloads +
+
+ + {/* Weekly Downloads */} +
+
+ {formatNumber(weeklyDownloads)} +
+
+ Weekly Downloads +
+
+ + {/* Daily Downloads */} +
+
+ {formatNumber(dailyDownloads)} +
+
+ Daily Downloads +
+
+
+
+ ) +} + +export default function NpmStatsSummaryBar({ + library, +}: { + library: LibrarySlim +}) { + return ( + +
+
+ {Array(4) + .fill(0) + .map((_, i) => ( +
+
+
+
+ ))} +
+
+ + } + > + + + +
+ ) +} diff --git a/src/libraries/form.tsx b/src/libraries/form.tsx index 610aae95a..4914688de 100644 --- a/src/libraries/form.tsx +++ b/src/libraries/form.tsx @@ -12,6 +12,7 @@ export const formProject = { latestBranch: 'main', bgRadial: 'from-yellow-500 via-yellow-600/50 to-transparent', textColor: 'text-yellow-600', + competitors: ['react-hook-form', 'formik', 'react-final-form', 'final-form'], testimonials: [ { quote: diff --git a/src/libraries/pacer.tsx b/src/libraries/pacer.tsx index d347dc4a9..5988b6c57 100644 --- a/src/libraries/pacer.tsx +++ b/src/libraries/pacer.tsx @@ -12,6 +12,7 @@ export const pacerProject = { bgRadial: 'from-lime-500 via-lime-700/50 to-transparent', textColor: `text-lime-700`, defaultDocs: 'overview', + competitors: ['lodash.debounce', 'lodash.throttle', 'p-queue', 'bottleneck'], featureHighlights: [ { title: 'Flexible & Type-Safe', diff --git a/src/libraries/query.tsx b/src/libraries/query.tsx index 198e5e6fc..f296bc23b 100644 --- a/src/libraries/query.tsx +++ b/src/libraries/query.tsx @@ -17,6 +17,7 @@ export const queryProject = { defaultDocs: 'framework/react/overview', installPath: 'framework/$framework/installation', legacyPackages: ['react-query'], + competitors: ['swr', '@apollo/client', 'relay-runtime', '@urql/core'], handleRedirects: (href: string) => { handleRedirects( reactQueryV3List, diff --git a/src/libraries/router.tsx b/src/libraries/router.tsx index 3235e4d92..7c49613d2 100644 --- a/src/libraries/router.tsx +++ b/src/libraries/router.tsx @@ -16,6 +16,7 @@ export const routerProject = { defaultDocs: 'framework/react/overview', installPath: 'framework/$framework/quick-start', legacyPackages: ['react-location'], + competitors: ['react-router-dom', '@reach/router', 'next', 'remix'], hideCodesandboxUrl: true as const, showVercelUrl: false, showNetlifyUrl: true, diff --git a/src/libraries/store.tsx b/src/libraries/store.tsx index 64cd9918d..034ec6f6b 100644 --- a/src/libraries/store.tsx +++ b/src/libraries/store.tsx @@ -12,6 +12,7 @@ export const storeProject = { bgRadial: 'from-twine-500 via-twine-700/50 to-transparent', textColor: 'text-twine-700', defaultDocs: 'overview', + competitors: ['zustand', 'redux', 'mobx', 'jotai', 'valtio'], featureHighlights: [ { title: 'Battle-Tested', diff --git a/src/libraries/table.tsx b/src/libraries/table.tsx index ea569e7d9..cf540fb4d 100644 --- a/src/libraries/table.tsx +++ b/src/libraries/table.tsx @@ -16,6 +16,7 @@ export const tableProject = { defaultDocs: 'introduction', corePackageName: 'table-core', legacyPackages: ['react-table'], + competitors: ['ag-grid-community', '@mui/x-data-grid', 'react-data-grid'], handleRedirects: (href: string) => { handleRedirects( reactTableV7List, diff --git a/src/libraries/types.ts b/src/libraries/types.ts index ba3f28a09..e5e390ab1 100644 --- a/src/libraries/types.ts +++ b/src/libraries/types.ts @@ -68,6 +68,7 @@ export type LibrarySlim = { legacyPackages?: string[] installPath?: string corePackageName?: string + competitors?: string[] handleRedirects?: (href: string) => void /** * If false, the library is hidden from sidebar navigation and pages have noindex meta tag. diff --git a/src/libraries/virtual.tsx b/src/libraries/virtual.tsx index 87391aef7..960f22fe5 100644 --- a/src/libraries/virtual.tsx +++ b/src/libraries/virtual.tsx @@ -14,6 +14,7 @@ export const virtualProject = { textColor: 'text-purple-600', defaultDocs: 'introduction', legacyPackages: ['react-virtual'], + competitors: ['react-window', 'react-virtualized', '@tanstack/virtual-core'], testimonials: [ { quote: diff --git a/src/queries/stats.ts b/src/queries/stats.ts index c626b57b3..930a008ad 100644 --- a/src/queries/stats.ts +++ b/src/queries/stats.ts @@ -1,5 +1,5 @@ import { queryOptions } from '@tanstack/react-query' -import { getOSSStats } from '~/utils/stats.server' +import { getOSSStats, fetchRecentDownloadStats } from '~/utils/stats.server' import type { StatsQueryParams } from '~/utils/stats.server' import type { LibrarySlim } from '~/libraries' @@ -26,3 +26,27 @@ export function ossStatsQuery({ library }: { library?: LibrarySlim } = {}) { : undefined, }) } + +export const recentDownloadStatsQueryOptions = (library: LibrarySlim) => + queryOptions({ + queryKey: ['stats', 'recent-downloads', library.id], + queryFn: () => + fetchRecentDownloadStats({ + data: { + library: { + id: library.id, + repo: library.repo, + frameworks: library.frameworks, + }, + }, + }), + staleTime: 1000 * 60 * 10, // Cache for 10 minutes (fresher than all-time stats) + }) + +export function recentDownloadStatsQuery({ + library, +}: { + library: LibrarySlim +}) { + return recentDownloadStatsQueryOptions(library) +} diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 6904731e9..5f16995c6 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -91,6 +91,7 @@ import { Route as LibraryIdVersionDocsRouteImport } from './routes/$libraryId/$v import { Route as LibraryIdVersionDocsIndexRouteImport } from './routes/$libraryId/$version.docs.index' import { Route as ApiAuthCallbackProviderRouteImport } from './routes/api/auth/callback/$provider' import { Route as LibraryIdVersionDocsChar123Char125DotmdRouteImport } from './routes/$libraryId/$version.docs.{$}[.]md' +import { Route as LibraryIdVersionDocsNpmStatsRouteImport } from './routes/$libraryId/$version.docs.npm-stats' import { Route as LibraryIdVersionDocsContributorsRouteImport } from './routes/$libraryId/$version.docs.contributors' import { Route as LibraryIdVersionDocsCommunityResourcesRouteImport } from './routes/$libraryId/$version.docs.community-resources' import { Route as LibraryIdVersionDocsSplatRouteImport } from './routes/$libraryId/$version.docs.$' @@ -513,6 +514,12 @@ const LibraryIdVersionDocsChar123Char125DotmdRoute = path: '/{$}.md', getParentRoute: () => LibraryIdVersionDocsRoute, } as any) +const LibraryIdVersionDocsNpmStatsRoute = + LibraryIdVersionDocsNpmStatsRouteImport.update({ + id: '/npm-stats', + path: '/npm-stats', + getParentRoute: () => LibraryIdVersionDocsRoute, + } as any) const LibraryIdVersionDocsContributorsRoute = LibraryIdVersionDocsContributorsRouteImport.update({ id: '/contributors', @@ -645,6 +652,7 @@ export interface FileRoutesByFullPath { '/$libraryId/$version/docs/$': typeof LibraryIdVersionDocsSplatRoute '/$libraryId/$version/docs/community-resources': typeof LibraryIdVersionDocsCommunityResourcesRoute '/$libraryId/$version/docs/contributors': typeof LibraryIdVersionDocsContributorsRoute + '/$libraryId/$version/docs/npm-stats': typeof LibraryIdVersionDocsNpmStatsRoute '/$libraryId/$version/docs/{$}.md': typeof LibraryIdVersionDocsChar123Char125DotmdRoute '/api/auth/callback/$provider': typeof ApiAuthCallbackProviderRoute '/$libraryId/$version/docs/': typeof LibraryIdVersionDocsIndexRoute @@ -731,6 +739,7 @@ export interface FileRoutesByTo { '/$libraryId/$version/docs/$': typeof LibraryIdVersionDocsSplatRoute '/$libraryId/$version/docs/community-resources': typeof LibraryIdVersionDocsCommunityResourcesRoute '/$libraryId/$version/docs/contributors': typeof LibraryIdVersionDocsContributorsRoute + '/$libraryId/$version/docs/npm-stats': typeof LibraryIdVersionDocsNpmStatsRoute '/$libraryId/$version/docs/{$}.md': typeof LibraryIdVersionDocsChar123Char125DotmdRoute '/api/auth/callback/$provider': typeof ApiAuthCallbackProviderRoute '/$libraryId/$version/docs': typeof LibraryIdVersionDocsIndexRoute @@ -824,6 +833,7 @@ export interface FileRoutesById { '/$libraryId/$version/docs/$': typeof LibraryIdVersionDocsSplatRoute '/$libraryId/$version/docs/community-resources': typeof LibraryIdVersionDocsCommunityResourcesRoute '/$libraryId/$version/docs/contributors': typeof LibraryIdVersionDocsContributorsRoute + '/$libraryId/$version/docs/npm-stats': typeof LibraryIdVersionDocsNpmStatsRoute '/$libraryId/$version/docs/{$}.md': typeof LibraryIdVersionDocsChar123Char125DotmdRoute '/api/auth/callback/$provider': typeof ApiAuthCallbackProviderRoute '/$libraryId/$version/docs/': typeof LibraryIdVersionDocsIndexRoute @@ -918,6 +928,7 @@ export interface FileRouteTypes { | '/$libraryId/$version/docs/$' | '/$libraryId/$version/docs/community-resources' | '/$libraryId/$version/docs/contributors' + | '/$libraryId/$version/docs/npm-stats' | '/$libraryId/$version/docs/{$}.md' | '/api/auth/callback/$provider' | '/$libraryId/$version/docs/' @@ -1004,6 +1015,7 @@ export interface FileRouteTypes { | '/$libraryId/$version/docs/$' | '/$libraryId/$version/docs/community-resources' | '/$libraryId/$version/docs/contributors' + | '/$libraryId/$version/docs/npm-stats' | '/$libraryId/$version/docs/{$}.md' | '/api/auth/callback/$provider' | '/$libraryId/$version/docs' @@ -1096,6 +1108,7 @@ export interface FileRouteTypes { | '/$libraryId/$version/docs/$' | '/$libraryId/$version/docs/community-resources' | '/$libraryId/$version/docs/contributors' + | '/$libraryId/$version/docs/npm-stats' | '/$libraryId/$version/docs/{$}.md' | '/api/auth/callback/$provider' | '/$libraryId/$version/docs/' @@ -1736,6 +1749,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LibraryIdVersionDocsChar123Char125DotmdRouteImport parentRoute: typeof LibraryIdVersionDocsRoute } + '/$libraryId/$version/docs/npm-stats': { + id: '/$libraryId/$version/docs/npm-stats' + path: '/npm-stats' + fullPath: '/$libraryId/$version/docs/npm-stats' + preLoaderRoute: typeof LibraryIdVersionDocsNpmStatsRouteImport + parentRoute: typeof LibraryIdVersionDocsRoute + } '/$libraryId/$version/docs/contributors': { id: '/$libraryId/$version/docs/contributors' path: '/contributors' @@ -1799,6 +1819,7 @@ interface LibraryIdVersionDocsRouteChildren { LibraryIdVersionDocsSplatRoute: typeof LibraryIdVersionDocsSplatRoute LibraryIdVersionDocsCommunityResourcesRoute: typeof LibraryIdVersionDocsCommunityResourcesRoute LibraryIdVersionDocsContributorsRoute: typeof LibraryIdVersionDocsContributorsRoute + LibraryIdVersionDocsNpmStatsRoute: typeof LibraryIdVersionDocsNpmStatsRoute LibraryIdVersionDocsChar123Char125DotmdRoute: typeof LibraryIdVersionDocsChar123Char125DotmdRoute LibraryIdVersionDocsIndexRoute: typeof LibraryIdVersionDocsIndexRoute LibraryIdVersionDocsFrameworkIndexRoute: typeof LibraryIdVersionDocsFrameworkIndexRoute @@ -1813,6 +1834,7 @@ const LibraryIdVersionDocsRouteChildren: LibraryIdVersionDocsRouteChildren = { LibraryIdVersionDocsCommunityResourcesRoute: LibraryIdVersionDocsCommunityResourcesRoute, LibraryIdVersionDocsContributorsRoute: LibraryIdVersionDocsContributorsRoute, + LibraryIdVersionDocsNpmStatsRoute: LibraryIdVersionDocsNpmStatsRoute, LibraryIdVersionDocsChar123Char125DotmdRoute: LibraryIdVersionDocsChar123Char125DotmdRoute, LibraryIdVersionDocsIndexRoute: LibraryIdVersionDocsIndexRoute, diff --git a/src/routes/$libraryId/$version.docs.npm-stats.tsx b/src/routes/$libraryId/$version.docs.npm-stats.tsx new file mode 100644 index 000000000..3c10ecdd6 --- /dev/null +++ b/src/routes/$libraryId/$version.docs.npm-stats.tsx @@ -0,0 +1,1013 @@ +import { createFileRoute, useNavigate } from '@tanstack/react-router' +import * as v from 'valibot' +import * as React from 'react' +import { useEffect } from 'react' +import { keepPreviousData, queryOptions, useQuery } from '@tanstack/react-query' +import * as Plot from '@observablehq/plot' +import * as d3 from 'd3' +import { X, Eye, EyeOff } from 'lucide-react' +import { DocContainer } from '~/components/DocContainer' +import { DocTitle } from '~/components/DocTitle' +import { getLibrary } from '~/libraries' +import NpmStatsSummaryBar from '~/components/NpmStatsSummaryBar' +import { useWidthToggle } from '~/components/DocsLayout' +import { ParentSize } from '~/components/ParentSize' +import { Spinner } from '~/components/Spinner' +import { twMerge } from 'tailwind-merge' + +const transformModeSchema = v.enum_(['none', 'normalize-y']) +const binTypeSchema = v.enum_(['yearly', 'monthly', 'weekly', 'daily']) +const showDataModeSchema = v.enum_(['all', 'complete']) + +const packageGroupSchema = v.object({ + packages: v.array( + v.object({ + name: v.string(), + hidden: v.optional(v.boolean()), + }), + ), + color: v.optional(v.nullable(v.string())), + baseline: v.optional(v.boolean()), +}) + +const facetValueSchema = v.union([v.literal('name'), v.undefined_()]) + +type TimeRange = + | '7-days' + | '30-days' + | '90-days' + | '180-days' + | '365-days' + | '730-days' + | '1825-days' + | 'all-time' + +type BinType = v.InferOutput + +const defaultColors = [ + '#1f77b4', // blue + '#ff7f0e', // orange + '#2ca02c', // green + '#d62728', // red + '#9467bd', // purple + '#8c564b', // brown + '#e377c2', // pink + '#7f7f7f', // gray + '#bcbd22', // yellow-green + '#17becf', // cyan +] as const + +const binningOptions = [ + { + label: 'Yearly', + value: 'yearly', + single: 'year', + bin: d3.utcYear, + }, + { + label: 'Monthly', + value: 'monthly', + single: 'month', + bin: d3.utcMonth, + }, + { + label: 'Weekly', + value: 'weekly', + single: 'week', + bin: d3.utcWeek, + }, + { + label: 'Daily', + value: 'daily', + single: 'day', + bin: d3.utcDay, + }, +] as const + +const binningOptionsByType = binningOptions.reduce( + (acc, option) => { + acc[option.value] = option + return acc + }, + {} as Record, +) + +function npmQueryOptions({ + packageGroups, + range, +}: { + packageGroups: (typeof packageGroupSchema)[] + range: TimeRange +}) { + const now = d3.utcDay(new Date()) + now.setHours(0, 0, 0, 0) + const endDate = now + + const NPM_STATS_START_DATE = d3.utcDay(new Date('2015-01-10')) + + const startDate = (() => { + switch (range) { + case '7-days': + return d3.utcDay.offset(now, -7) + case '30-days': + return d3.utcDay.offset(now, -30) + case '90-days': + return d3.utcDay.offset(now, -90) + case '180-days': + return d3.utcDay.offset(now, -180) + case '365-days': + return d3.utcDay.offset(now, -365) + case '730-days': + return d3.utcDay.offset(now, -730) + case '1825-days': + return d3.utcDay.offset(now, -1825) + case 'all-time': + return NPM_STATS_START_DATE + } + })() + + const formatDate = (date: Date) => { + return date.toISOString().split('T')[0] + } + + return queryOptions({ + queryKey: [ + 'npm-stats', + packageGroups.map((pg) => ({ + packages: pg.packages.map((p) => ({ name: p.name })), + })), + range, + ], + queryFn: async () => { + try { + const { fetchNpmDownloadsBulk } = await import('~/utils/stats.server') + + const results = await fetchNpmDownloadsBulk({ + data: { + packageGroups: packageGroups.map((pg) => ({ + packages: pg.packages.map((p) => ({ + name: p.name, + hidden: p.hidden, + })), + })), + startDate: formatDate(startDate), + endDate: formatDate(endDate), + }, + }) + + return results.map((result, groupIndex) => { + let actualStartDate = startDate + + for (const pkg of result.packages) { + const firstNonZero = pkg.downloads.find((d) => d.downloads > 0) + if (firstNonZero) { + const firstNonZeroDate = d3.utcDay(new Date(firstNonZero.day)) + if (firstNonZeroDate < actualStartDate) { + actualStartDate = firstNonZeroDate + } + } + } + + return { + packages: result.packages.map((pkg) => ({ + ...packageGroups[groupIndex].packages.find( + (p) => p.name === pkg.name, + ), + downloads: pkg.downloads, + })), + start: formatDate(actualStartDate), + end: formatDate(endDate), + error: result.error, + actualStartDate, + } + }) + } catch (error) { + console.error('Failed to fetch npm stats:', error) + return packageGroups.map((packageGroup) => ({ + packages: packageGroup.packages.map((pkg) => ({ + ...pkg, + downloads: [], + })), + start: formatDate(startDate), + end: formatDate(endDate), + error: 'Failed to fetch package data', + actualStartDate: startDate, + })) + } + }, + placeholderData: keepPreviousData, + }) +} + +function getPackageColor( + packageName: string, + packages: v.InferOutput[], +) { + const packageInfo = packages.find((pkg) => + pkg.packages.some((p) => p.name === packageName), + ) + if (packageInfo?.color) { + return packageInfo.color + } + + const packageIndex = packages.findIndex((pkg) => + pkg.packages.some((p) => p.name === packageName), + ) + return defaultColors[packageIndex % defaultColors.length] +} + +function PlotFigure({ options }: { options: any }) { + const containerRef = React.useRef(null) + + React.useEffect(() => { + if (!containerRef.current) return + const plot = Plot.plot(options) + containerRef.current.append(plot) + return () => plot.remove() + }, [options]) + + return
+} + +type TransformMode = v.InferOutput + +function NpmStatsChart({ + queryData, + binType, + packages, + range, + height = 400, + transform = 'none', +}: { + queryData: + | undefined + | Awaited< + ReturnType>['queryFn']> + > + binType: BinType + packages: v.InferOutput[] + range: TimeRange + height?: number + transform?: TransformMode +}) { + if (!queryData?.length) return null + + const binOption = binningOptionsByType[binType] + const binUnit = binOption.bin + + const now = d3.utcDay(new Date()) + + let startDate = (() => { + switch (range) { + case '7-days': + return d3.utcDay.offset(now, -7) + case '30-days': + return d3.utcDay.offset(now, -30) + case '90-days': + return d3.utcDay.offset(now, -90) + case '180-days': + return d3.utcDay.offset(now, -180) + case '365-days': + return d3.utcDay.offset(now, -365) + case '730-days': + return d3.utcDay.offset(now, -730) + case '1825-days': + return d3.utcDay.offset(now, -1825) + case 'all-time': + const earliestActualStartDate = queryData + .map((pkg) => pkg.actualStartDate) + .filter((d): d is Date => d !== undefined) + .sort((a, b) => a.getTime() - b.getTime())[0] + return earliestActualStartDate || d3.utcDay(new Date('2015-01-10')) + } + })() + + startDate = binOption.bin.floor(startDate) + + const combinedPackageGroups = queryData.map((queryPackageGroup, index) => { + const packageGroupWithHidden = packages[index] + + const visiblePackages = queryPackageGroup.packages.filter((p, i) => { + const hiddenState = packageGroupWithHidden?.packages.find( + (pg) => pg.name === p.name, + )?.hidden + return !i || !hiddenState + }) + + const downloadsByDate: Map = new Map() + + visiblePackages.forEach((pkg) => { + pkg.downloads.forEach((d) => { + const date = d3.utcDay(new Date(d.day)) + if (date < startDate) return + + downloadsByDate.set( + date.getTime(), + (downloadsByDate.get(date.getTime()) || 0) + d.downloads, + ) + }) + }) + + return { + ...queryPackageGroup, + downloads: Array.from(downloadsByDate.entries()).map( + ([date, downloads]) => [d3.utcDay(new Date(date)), downloads], + ) as [Date, number][], + } + }) + + const binnedPackageData = combinedPackageGroups.map((packageGroup) => { + const binned = d3.sort( + d3.rollup( + packageGroup.downloads, + (v) => d3.sum(v, (d) => d[1]), + (d) => binUnit.floor(d[0]), + ), + (d) => d[0], + ) + + const downloads = binned.map((d) => ({ + name: packageGroup.packages[0].name, + date: d3.utcDay(new Date(d[0])), + downloads: d[1], + change: 0, // Will be calculated below + })) + + return { + ...packageGroup, + downloads, + } + }) + + // Apply relative change calculation for normalize-y transform + const correctedPackageData = binnedPackageData.map((packageGroup) => { + const first = packageGroup.downloads[0] + const firstDownloads = first?.downloads ?? 0 + + return { + ...packageGroup, + downloads: packageGroup.downloads.map((d) => ({ + ...d, + change: d.downloads - firstDownloads, + })), + } + }) + + const filteredPackageData = correctedPackageData.filter((_, index) => { + const packageGroupWithHidden = packages[index] + const isHidden = packageGroupWithHidden?.packages[0]?.hidden + return !isHidden + }) + + const plotData = filteredPackageData.flatMap((d) => d.downloads) + + return ( + + {({ width = 1000 }) => ( + + d.toLocaleDateString('en-US', { + month: 'long', + day: 'numeric', + year: 'numeric', + }), + }, + } as Plot.TipOptions), + ), + ].filter(Boolean), + x: { + label: 'Date', + labelOffset: 35, + }, + y: { + label: + transform === 'normalize-y' ? 'Downloads Growth' : 'Downloads', + labelOffset: 35, + }, + grid: true, + color: { + domain: [...new Set(plotData.map((d) => d.name))], + range: [...new Set(plotData.map((d) => d.name))] + .filter((pkg): pkg is string => pkg !== undefined) + .map((pkg) => getPackageColor(pkg, packages)), + legend: false, + }, + }} + /> + )} + + ) +} + +export const Route = createFileRoute('/$libraryId/$version/docs/npm-stats')({ + validateSearch: v.object({ + packageGroups: v.fallback( + v.optional(v.array(packageGroupSchema)), + undefined, + ), + range: v.fallback( + v.optional( + v.enum_([ + '7-days', + '30-days', + '90-days', + '180-days', + '365-days', + '730-days', + '1825-days', + 'all-time', + ]), + ), + '365-days', + ), + transform: v.fallback(v.optional(transformModeSchema), 'none'), + facetX: v.fallback(v.optional(facetValueSchema), undefined), + facetY: v.fallback(v.optional(facetValueSchema), undefined), + binType: v.fallback(v.optional(binTypeSchema), 'weekly'), + showDataMode: v.fallback(v.optional(showDataModeSchema), 'all'), + height: v.fallback(v.optional(v.number()), 400), + }), + component: RouteComponent, +}) + +function getPackageName( + frameworkValue: string, + libraryId: string, + library: ReturnType, +): string { + if (frameworkValue === 'vanilla') { + const coreName = library.corePackageName || libraryId + return `@tanstack/${coreName}` + } + if (frameworkValue === 'angular' && libraryId === 'query') { + return `@tanstack/angular-query-experimental` + } + return `@tanstack/${frameworkValue}-${libraryId}` +} + +const timeRanges = [ + { value: '7-days', label: '7 Days' }, + { value: '30-days', label: '30 Days' }, + { value: '90-days', label: '90 Days' }, + { value: '180-days', label: '6 Months' }, + { value: '365-days', label: '1 Year' }, + { value: '730-days', label: '2 Years' }, + { value: '1825-days', label: '5 Years' }, + { value: 'all-time', label: 'All Time' }, +] as const + +const defaultRangeBinTypes: Record = { + '7-days': 'daily', + '30-days': 'daily', + '90-days': 'weekly', + '180-days': 'weekly', + '365-days': 'weekly', + '730-days': 'monthly', + '1825-days': 'monthly', + 'all-time': 'monthly', +} + +const formatNumber = (num: number) => { + if (num >= 1_000_000) { + return `${(num / 1_000_000).toFixed(1)}M` + } + if (num >= 1_000) { + return `${(num / 1_000).toFixed(1)}k` + } + return num.toString() +} + +function RouteComponent() { + const { libraryId } = Route.useParams() + const library = getLibrary(libraryId) + const navigate = useNavigate() + const search = Route.useSearch() + const { setIsFullWidth } = useWidthToggle() + + const range: TimeRange = search.range ?? '365-days' + const binTypeParam: BinType | undefined = search.binType + const binType: BinType = binTypeParam ?? defaultRangeBinTypes[range] + const height: number = search.height ?? 400 + const transform: TransformMode = search.transform ?? 'none' + + // Enable full-width mode for stats page + useEffect(() => { + setIsFullWidth(true) + // Cleanup: reset to normal width when leaving the page + return () => setIsFullWidth(false) + }, [setIsFullWidth]) + + // Convert library's textColor (Tailwind class) to actual hex color + const getLibraryColor = (textColor: string): string | undefined => { + const tailwindColors: Record = { + // Query + 'text-amber-500': '#f59e0b', + // Table + 'text-blue-600': '#2563eb', + 'text-blue-500': '#3b82f6', + // Router + 'text-emerald-500': '#10b981', + // Form + 'text-yellow-600': '#d97706', + // Virtual + 'text-purple-600': '#9333ea', + 'text-violet-700': '#6d28d9', + 'text-violet-400': '#a78bfa', + // Start + 'text-cyan-600': '#0891b2', + // Store + 'text-twine-600': '#8b5a3c', + 'text-twine-500': '#a0673f', + 'text-twine-700': '#7c5a47', + // Config, Ranger + 'text-gray-700': '#374151', + // AI + 'text-pink-700': '#be185d', + // Pacer + 'text-lime-700': '#4d7c0f', + 'text-lime-600': '#65a30d', + 'text-lime-500': '#84cc16', + // DB + 'text-orange-700': '#c2410c', + // Devtools + 'text-slate-600': '#475569', + // Legacy/fallback colors + 'text-red-500': '#ef4444', + } + return tailwindColors[textColor] + } + + const libraryColor = getLibraryColor(library.textColor || '') + + // Get the core package name for this library + const corePackageName = getPackageName('vanilla', libraryId, library) + + // Default package groups if none are set + const defaultPackageGroups = search.packageGroups || [ + { + packages: [{ name: corePackageName }], + color: libraryColor || null, // Use library's theme color for core package + }, + ] + + const handlePackageGroupsChange = ( + newPackageGroups: typeof defaultPackageGroups, + ) => { + navigate({ + to: '.', + search: (prev) => ({ + ...prev, + packageGroups: newPackageGroups, + }), + resetScroll: false, + }) + } + + const handleRangeChange = (newRange: TimeRange) => { + navigate({ + to: '.', + search: (prev) => ({ + ...prev, + range: newRange, + binType: defaultRangeBinTypes[newRange], + }), + resetScroll: false, + }) + } + + const handleBinTypeChange = (newBinType: BinType) => { + navigate({ + to: '.', + search: (prev) => ({ + ...prev, + binType: newBinType, + }), + resetScroll: false, + }) + } + + const handleTransformChange = (newTransform: TransformMode) => { + navigate({ + to: '.', + search: (prev) => ({ + ...prev, + transform: newTransform, + }), + resetScroll: false, + }) + } + + const togglePackageVisibility = (index: number, packageName: string) => { + const newPackageGroups = defaultPackageGroups.map((pkg, i) => + i === index + ? { + ...pkg, + packages: pkg.packages.map((p) => + p.name === packageName ? { ...p, hidden: !p.hidden } : p, + ), + } + : pkg, + ) + handlePackageGroupsChange(newPackageGroups) + } + + const handleRemovePackage = (index: number) => { + const newPackageGroups = defaultPackageGroups.filter((_, i) => i !== index) + handlePackageGroupsChange(newPackageGroups) + } + + // Fetch chart data + const npmQuery = useQuery( + npmQueryOptions({ + packageGroups: defaultPackageGroups, + range, + }), + ) + + return ( + +
+
+ NPM Stats for {library.name} +
+
+
+ +
+

+ View download statistics for {library.name} packages. Compare + different time periods and track usage trends. +

+
+ + + +
+

+ *These top summary stats account for core packages, legacy package + names, and all framework adapters. +

+
+ + {/* Current Packages List */} + {defaultPackageGroups.length > 0 && ( +
+

+ Current Packages +

+
+ {defaultPackageGroups.map((pkg, index) => { + const mainPackage = pkg.packages[0] + const color = getPackageColor( + mainPackage.name, + defaultPackageGroups, + ) + + return ( +
+
+ + +
+ ) + })} +
+
+ )} + + {/* Framework Adapters Section */} + {library.frameworks && library.frameworks.length > 0 && ( +
+

+ Add Framework Adapters +

+
+ {library.frameworks.map((framework) => { + const frameworkPackageName = getPackageName( + framework, + libraryId, + library, + ) + const isAlreadyAdded = defaultPackageGroups.some((pg) => + pg.packages.some( + (pkg) => pkg.name === frameworkPackageName, + ), + ) + + return ( + + ) + })} +
+
+ )} + + {/* Competitors Section */} + {library.competitors && library.competitors.length > 0 && ( +
+

+ Compare with Similar Packages +

+
+ {library.competitors.map((competitor) => { + const isAlreadyAdded = defaultPackageGroups.some((pg) => + pg.packages.some((pkg) => pkg.name === competitor), + ) + + return ( + + ) + })} +
+
+ )} + + {/* Chart Controls */} +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ + {/* Chart */} +
+ {npmQuery.isLoading ? ( +
+
+ +
+ Loading download statistics... +
+
+
+ ) : npmQuery.isError ? ( +
+
+ Failed to load chart data +
+
+ ) : ( + + )} +
+ + {/* Statistics Table */} + {npmQuery.data && npmQuery.data.length > 0 && ( +
+ + + + + + + + + + {npmQuery.data + ?.map((packageGroupDownloads, index) => { + if ( + !packageGroupDownloads.packages.some( + (p) => p.downloads.length, + ) + ) { + return null + } + + const firstPackage = packageGroupDownloads.packages[0] + if (!firstPackage?.name) return null + + const sortedDownloads = packageGroupDownloads.packages + .flatMap((p) => p.downloads) + .sort( + (a, b) => + d3.utcDay(a.day).getTime() - + d3.utcDay(b.day).getTime(), + ) + + const binUnit = binningOptionsByType[binType].bin + const now = d3.utcDay(new Date()) + const partialBinEnd = binUnit.floor(now) + + const filteredDownloads = sortedDownloads.filter( + (d) => d3.utcDay(new Date(d.day)) < partialBinEnd, + ) + + const binnedDownloads = d3.sort( + d3.rollup( + filteredDownloads, + (v) => d3.sum(v, (d) => d.downloads), + (d) => binUnit.floor(new Date(d.day)), + ), + (d) => d[0], + ) + + const color = getPackageColor( + firstPackage.name, + defaultPackageGroups, + ) + + const lastBin = + binnedDownloads[binnedDownloads.length - 1] + const totalDownloads = d3.sum( + binnedDownloads, + (d) => d[1], + ) + + return ( + + + + + + ) + }) + .filter(Boolean)} + +
+ Package Name + + Total Period Downloads + + Downloads last {binningOptionsByType[binType].single} +
+
+
+ + {firstPackage.name} + +
+
+ {formatNumber(totalDownloads)} + + {lastBin ? formatNumber(lastBin[1]) : '-'} +
+
+ )} + +
+
+
+ + ) +} diff --git a/src/utils/stats.server.ts b/src/utils/stats.server.ts index 04fc4b99e..31f8202fe 100644 --- a/src/utils/stats.server.ts +++ b/src/utils/stats.server.ts @@ -786,3 +786,228 @@ export const fetchNpmDownloadChunk = createServerFn({ method: 'GET' }) throw error } }) + +/** + * Fetch recent download statistics (daily, weekly, monthly) for a library + * Uses getRegisteredPackages to include all framework adapters + */ +export const fetchRecentDownloadStats = createServerFn({ method: 'POST' }) + .inputValidator( + v.object({ + library: v.object({ + id: v.string(), + repo: v.string(), + frameworks: v.optional(v.array(v.string())), + }), + }), + ) + .handler(async ({ data }) => { + // Add HTTP caching headers - shorter cache for recent data + setResponseHeaders( + new Headers({ + 'Cache-Control': 'public, max-age=300, stale-while-revalidate=600', + 'Netlify-CDN-Cache-Control': + 'public, max-age=300, durable, stale-while-revalidate=600', + }), + ) + + // Import db functions dynamically + const { + getRegisteredPackages, + getBatchNpmDownloadChunks, + setCachedNpmDownloadChunk, + } = await import('./stats-db.server') + + // Get all registered packages for this library (includes framework adapters) + let packageNames = await getRegisteredPackages(data.library.id) + + // If no packages registered, fall back to basic package name + if (packageNames.length === 0) { + packageNames = [`@tanstack/${data.library.id}`] + } + + const today = new Date() + const todayStr = today.toISOString().substring(0, 10) + + // Calculate date ranges + const dailyStart = new Date(today.getTime() - 24 * 60 * 60 * 1000) + .toISOString() + .substring(0, 10) + const weeklyStart = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000) + .toISOString() + .substring(0, 10) + const monthlyStart = new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000) + .toISOString() + .substring(0, 10) + + // Create chunk requests for all packages and time periods + const chunkRequests = [] + for (const packageName of packageNames) { + chunkRequests.push( + { + packageName, + dateFrom: dailyStart, + dateTo: todayStr, + binSize: 'daily' as const, + period: 'daily', + }, + { + packageName, + dateFrom: weeklyStart, + dateTo: todayStr, + binSize: 'daily' as const, + period: 'weekly', + }, + { + packageName, + dateFrom: monthlyStart, + dateTo: todayStr, + binSize: 'daily' as const, + period: 'monthly', + }, + ) + } + + // Try to get cached data first + const cachedChunks = await getBatchNpmDownloadChunks(chunkRequests) + const needsFetch: typeof chunkRequests = [] + const results = new Map() + + // Check what we have in cache vs what needs fetching + for (const req of chunkRequests) { + const cacheKey = `${req.packageName}|${req.dateFrom}|${req.dateTo}|${req.binSize}` + const cached = cachedChunks.get(cacheKey) + + if (cached) { + // Check if cache is recent enough (within last hour for recent data) + const cacheAge = Date.now() - (cached.updatedAt ?? 0) + const isStale = cacheAge > 60 * 60 * 1000 // 1 hour + + if (!isStale) { + results.set(cacheKey, cached) + continue + } + } + + needsFetch.push(req) + } + + // Fetch missing/stale data from NPM API + if (needsFetch.length > 0) { + const fetchPromises = needsFetch.map(async (req) => { + try { + const response = await fetch( + `https://api.npmjs.org/downloads/range/${req.dateFrom}:${req.dateTo}/${req.packageName}`, + { + headers: { + Accept: 'application/json', + 'User-Agent': 'TanStack-Stats', + }, + }, + ) + + if (!response.ok) { + if (response.status === 404) { + // Package not found, return zero data + return { + key: `${req.packageName}|${req.dateFrom}|${req.dateTo}|${req.binSize}`, + data: { + packageName: req.packageName, + dateFrom: req.dateFrom, + dateTo: req.dateTo, + binSize: req.binSize, + dailyData: [], + totalDownloads: 0, + isImmutable: false, + updatedAt: Date.now(), + }, + } + } + throw new Error(`NPM API error: ${response.status}`) + } + + const result = await response.json() + const downloads = result.downloads || [] + + const chunkData = { + packageName: req.packageName, + dateFrom: req.dateFrom, + dateTo: req.dateTo, + binSize: req.binSize, + totalDownloads: downloads.reduce( + (sum: number, d: any) => sum + d.downloads, + 0, + ), + dailyData: downloads, + isImmutable: false, // Recent data is mutable + updatedAt: Date.now(), + } + + // Cache this chunk asynchronously + setCachedNpmDownloadChunk(chunkData).catch((err) => + console.warn( + `Failed to cache recent downloads for ${req.packageName}:`, + err, + ), + ) + + return { + key: `${req.packageName}|${req.dateFrom}|${req.dateTo}|${req.binSize}`, + data: chunkData, + } + } catch (error) { + console.error( + `Failed to fetch recent downloads for ${req.packageName}:`, + error, + ) + // Return zero data on error + return { + key: `${req.packageName}|${req.dateFrom}|${req.dateTo}|${req.binSize}`, + data: { + packageName: req.packageName, + dateFrom: req.dateFrom, + dateTo: req.dateTo, + binSize: req.binSize, + dailyData: [], + totalDownloads: 0, + isImmutable: false, + updatedAt: Date.now(), + }, + } + } + }) + + const fetchResults = await Promise.all(fetchPromises) + for (const result of fetchResults) { + results.set(result.key, result.data) + } + } + + // Aggregate results by time period + let dailyTotal = 0 + let weeklyTotal = 0 + let monthlyTotal = 0 + + for (const req of chunkRequests) { + const cacheKey = `${req.packageName}|${req.dateFrom}|${req.dateTo}|${req.binSize}` + const chunk = results.get(cacheKey) + + if (chunk) { + const downloads = chunk.totalDownloads || 0 + + if (req.period === 'daily') { + dailyTotal += downloads + } else if (req.period === 'weekly') { + weeklyTotal += downloads + } else if (req.period === 'monthly') { + monthlyTotal += downloads + } + } + } + + return { + dailyDownloads: dailyTotal, + weeklyDownloads: weeklyTotal, + monthlyDownloads: monthlyTotal, + } + })