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 6b2b028d9..1041e8149 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') ? [ { @@ -471,6 +475,8 @@ export function DocsLayout({ const isExample = matches.some((d) => d.pathname.includes('/examples/')) + const isNpmStats = matches.some((d) => d.pathname.includes('/docs/npm-stats')) + const detailsRef = React.useRef(null!) const flatMenu = React.useMemo( @@ -750,6 +756,7 @@ export function DocsLayout({ !isLandingPage && 'min-h-[88dvh] sm:min-h-0', !isLandingPage && !isExample && + !isNpmStats && !isFullWidth && 'mx-auto w-[900px]', )} diff --git a/src/components/NpmStatsChart.tsx b/src/components/NpmStatsChart.tsx deleted file mode 100644 index 28532bd05..000000000 --- a/src/components/NpmStatsChart.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import * as React from 'react' -import * as Plot from '@observablehq/plot' -import { ParentSize } from './ParentSize' - -type NpmStats = { - start: string - end: string - package: string - downloads: Array<{ - downloads: number - day: string - }> -} - -export function NpmStatsChart({ stats }: { stats: NpmStats[] }) { - const plotRef = React.useRef(null) - - React.useEffect(() => { - if (!stats.length || !plotRef.current) return - - // Flatten the data for the plot - const plotData = stats.flatMap((stat) => - stat.downloads.map((d) => ({ - ...d, - package: stat.package, - date: new Date(d.day), - })), - ) - - const chart = Plot.plot({ - marginLeft: 50, - marginRight: 0, - marginBottom: 70, - width: plotRef.current.clientWidth, - height: plotRef.current.clientHeight, - marks: [ - Plot.line(plotData, { - x: 'date', - y: 'downloads', - stroke: 'package', - strokeWidth: 2, - }), - ], - x: { - type: 'time', - label: 'Date', - labelOffset: 35, - tickFormat: (d: Date) => d.toLocaleDateString(), - }, - y: { - label: 'Downloads', - labelOffset: 35, - tickFormat: (d: number) => { - if (d >= 1000000) { - return `${(d / 1000000).toFixed(1)}M` - } - if (d >= 1000) { - return `${(d / 1000).toFixed(1)}K` - } - return d.toString() - }, - }, - grid: true, - color: { - legend: true, - }, - }) - - plotRef.current.appendChild(chart) - - return () => { - if (plotRef.current) { - plotRef.current.innerHTML = '' - } - } - }, [stats]) - - return ( - - {({ width, height }) => ( -
- )} - - ) -} diff --git a/src/components/npm-stats/ChartControls.tsx b/src/components/npm-stats/ChartControls.tsx new file mode 100644 index 000000000..340eeb5e3 --- /dev/null +++ b/src/components/npm-stats/ChartControls.tsx @@ -0,0 +1,326 @@ +import * as React from 'react' +import { twMerge } from 'tailwind-merge' +import { EllipsisVertical } from 'lucide-react' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@radix-ui/react-dropdown-menu' +import { Tooltip } from '~/components/Tooltip' +import { + type TimeRange, + type BinType, + type TransformMode, + type ShowDataMode, + type FacetValue, + timeRanges, + binningOptions, + isBinningOptionValidForRange, +} from './NPMStatsChart' + +const dropdownButtonStyles = { + base: 'bg-gray-500/10 rounded-md px-2 py-1 text-sm flex items-center gap-1', + active: 'bg-gray-500/20', +} as const + +const transformOptions = [ + { value: 'none', label: 'Actual Values' }, + { value: 'normalize-y', label: 'Relative Change' }, +] as const + +const showDataModeOptions = [ + { value: 'all', label: 'All Data' }, + { value: 'complete', label: 'Hide Partial Data' }, +] as const + +const facetOptions = [{ value: 'name', label: 'Package' }] as const + +export type ChartControlsProps = { + range: TimeRange + binType: BinType + transform: TransformMode + showDataMode: ShowDataMode + onRangeChange: (range: TimeRange) => void + onBinTypeChange: (binType: BinType) => void + onTransformChange: (transform: TransformMode) => void + onShowDataModeChange: (mode: ShowDataMode) => void + // Optional facet controls (main page only) + facetX?: FacetValue + facetY?: FacetValue + onFacetXChange?: (value: FacetValue | undefined) => void + onFacetYChange?: (value: FacetValue | undefined) => void +} + +export function ChartControls({ + range, + binType, + transform, + showDataMode, + onRangeChange, + onBinTypeChange, + onTransformChange, + onShowDataModeChange, + facetX, + facetY, + onFacetXChange, + onFacetYChange, +}: ChartControlsProps) { + const showFacets = onFacetXChange && onFacetYChange + + return ( + <> + {/* Time Range */} + + + + + + + +
+ Time Range +
+ {timeRanges.map(({ value, label }) => ( + onRangeChange(value)} + className={twMerge( + 'w-full px-2 py-1.5 text-left text-sm rounded hover:bg-gray-500/20 flex items-center gap-2 outline-none cursor-pointer', + value === range ? 'text-blue-500 bg-blue-500/10' : '', + 'data-highlighted:bg-gray-500/20 data-highlighted:text-blue-500', + )} + > + {label} + + ))} +
+
+ + {/* Binning Interval */} + + + + + + + +
+ Binning Interval +
+ {binningOptions.map(({ label, value }) => ( + onBinTypeChange(value)} + disabled={!isBinningOptionValidForRange(range, value)} + className={twMerge( + 'w-full px-2 py-1.5 text-left text-sm rounded hover:bg-gray-500/20 flex items-center gap-2 outline-none cursor-pointer', + binType === value ? 'text-blue-500 bg-blue-500/10' : '', + 'data-highlighted:bg-gray-500/20 data-highlighted:text-blue-500', + !isBinningOptionValidForRange(range, value) + ? 'opacity-50 cursor-not-allowed' + : '', + )} + > + {label} + + ))} +
+
+ + {/* Y-Axis Transform */} + + + + + + + +
+ Y-Axis Transform +
+ {transformOptions.map(({ value, label }) => ( + onTransformChange(value as TransformMode)} + className={twMerge( + 'w-full px-2 py-1.5 text-left text-sm rounded hover:bg-gray-500/20 flex items-center gap-2 outline-none cursor-pointer', + transform === value ? 'text-blue-500 bg-blue-500/10' : '', + 'data-highlighted:bg-gray-500/20 data-highlighted:text-blue-500', + )} + > + {label} + + ))} +
+
+ + {/* Facet X (optional) */} + {showFacets && ( + + + + + + + +
+ Horizontal Facet +
+ onFacetXChange(undefined)} + className={twMerge( + 'w-full px-2 py-1.5 text-left text-sm rounded hover:bg-gray-500/20 flex items-center gap-2 outline-none cursor-pointer', + !facetX ? 'text-blue-500 bg-blue-500/10' : '', + 'data-highlighted:bg-gray-500/20 data-highlighted:text-blue-500', + )} + > + No Facet + + {facetOptions.map(({ value, label }) => ( + onFacetXChange(value)} + className={twMerge( + 'w-full px-2 py-1.5 text-left text-sm rounded hover:bg-gray-500/20 flex items-center gap-2 outline-none cursor-pointer', + facetX === value ? 'text-blue-500 bg-blue-500/10' : '', + 'data-highlighted:bg-gray-500/20 data-highlighted:text-blue-500', + )} + > + {label} + + ))} +
+
+ )} + + {/* Facet Y (optional) */} + {showFacets && ( + + + + + + + +
+ Vertical Facet +
+ onFacetYChange(undefined)} + className={twMerge( + 'w-full px-2 py-1.5 text-left text-sm rounded hover:bg-gray-500/20 flex items-center gap-2 outline-none cursor-pointer', + !facetY ? 'text-blue-500 bg-blue-500/10' : '', + 'data-highlighted:bg-gray-500/20 data-highlighted:text-blue-500', + )} + > + No Facet + + {facetOptions.map(({ value, label }) => ( + onFacetYChange(value)} + className={twMerge( + 'w-full px-2 py-1.5 text-left text-sm rounded hover:bg-gray-500/20 flex items-center gap-2 outline-none cursor-pointer', + facetY === value ? 'text-blue-500 bg-blue-500/10' : '', + 'data-highlighted:bg-gray-500/20 data-highlighted:text-blue-500', + )} + > + {label} + + ))} +
+
+ )} + + {/* Show Data Mode */} + + + + + + + +
+ Data Display Mode +
+ {showDataModeOptions.map(({ value, label }) => ( + onShowDataModeChange(value)} + disabled={transform === 'normalize-y'} + className={twMerge( + 'w-full px-2 py-1.5 text-left text-sm rounded hover:bg-gray-500/20 flex items-center gap-2 outline-none cursor-pointer', + showDataMode === value ? 'text-blue-500 bg-blue-500/10' : '', + 'data-highlighted:bg-gray-500/20 data-highlighted:text-blue-500', + transform === 'normalize-y' + ? 'opacity-50 cursor-not-allowed' + : '', + )} + > + {label} + + ))} +
+
+ + ) +} diff --git a/src/components/npm-stats/ColorPickerPopover.tsx b/src/components/npm-stats/ColorPickerPopover.tsx new file mode 100644 index 000000000..7d1adfae3 --- /dev/null +++ b/src/components/npm-stats/ColorPickerPopover.tsx @@ -0,0 +1,59 @@ +import * as React from 'react' +import { HexColorPicker } from 'react-colorful' + +export interface ColorPickerPopoverProps { + packageName: string + position: { x: number; y: number } + currentColor: string + onColorChange: (packageName: string, color: string) => void + onReset: (packageName: string) => void + onClose: () => void +} + +/** + * A popover component for picking package colors. + * Shows a hex color picker with Reset and Done buttons. + */ +export function ColorPickerPopover({ + packageName, + position, + currentColor, + onColorChange, + onReset, + onClose, +}: ColorPickerPopoverProps) { + return ( +
+
+ Pick a color +
+ onColorChange(packageName, color)} + /> +
+ + +
+
+ ) +} diff --git a/src/components/npm-stats/NPMStatsChart.tsx b/src/components/npm-stats/NPMStatsChart.tsx new file mode 100644 index 000000000..8317d4301 --- /dev/null +++ b/src/components/npm-stats/NPMStatsChart.tsx @@ -0,0 +1,448 @@ +import * as React from 'react' +import * as v from 'valibot' +import * as Plot from '@observablehq/plot' +import * as d3 from 'd3' +import { ParentSize } from '~/components/ParentSize' +import { packageGroupSchema } from '~/routes/stats/npm/-comparisons' +import { defaultColors } from '~/utils/npm-packages' + +// Types +export type PackageGroup = v.InferOutput + +export const binTypeSchema = v.picklist([ + 'yearly', + 'monthly', + 'weekly', + 'daily', +]) +export type BinType = v.InferOutput + +export const transformModeSchema = v.picklist(['none', 'normalize-y']) +export type TransformMode = v.InferOutput + +export const showDataModeSchema = v.picklist(['all', 'complete']) +export type ShowDataMode = v.InferOutput + +export type TimeRange = + | '7-days' + | '30-days' + | '90-days' + | '180-days' + | '365-days' + | '730-days' + | '1825-days' + | 'all-time' + +export type FacetValue = 'name' + +// Type for query data returned from npm stats API +export type NpmQueryData = Array<{ + packages: Array<{ + name?: string + hidden?: boolean + downloads: Array<{ day: string; downloads: number }> + }> + start: string + end: string + error?: string + actualStartDate?: Date +}> + +// Binning options configuration +export 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 + +export const binningOptionsByType = binningOptions.reduce( + (acc, option) => { + acc[option.value] = option + return acc + }, + {} as Record, +) + +export 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 + +export 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', +} + +// Get or assign colors for packages +export function getPackageColor( + packageName: string, + packages: PackageGroup[], +): string { + // Find the package group that contains this package + const packageInfo = packages.find((pkg) => + pkg.packages.some((p) => p.name === packageName), + ) + if (packageInfo?.color) { + return packageInfo.color + } + + // Otherwise, assign a default color based on the package's position + const packageIndex = packages.findIndex((pkg) => + pkg.packages.some((p) => p.name === packageName), + ) + return defaultColors[packageIndex % defaultColors.length] +} + +// Custom number formatter for more precise control +export 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() +} + +// Check if a binning option is valid for a time range +export function isBinningOptionValidForRange( + range: TimeRange, + binType: BinType, +): boolean { + switch (range) { + case '7-days': + case '30-days': + return binType === 'daily' + case '90-days': + case '180-days': + return ( + binType === 'daily' || binType === 'weekly' || binType === 'monthly' + ) + case '365-days': + return ( + binType === 'daily' || binType === 'weekly' || binType === 'monthly' + ) + case '730-days': + case '1825-days': + case 'all-time': + return true + } +} + +// Plot figure component +function PlotFigure({ options }: { options: Parameters[0] }) { + 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
+} + +// Props for the NPMStatsChart component +export type NPMStatsChartProps = { + queryData: NpmQueryData | undefined + transform: TransformMode + binType: BinType + packages: PackageGroup[] + range: TimeRange + facetX?: FacetValue + facetY?: FacetValue + showDataMode: ShowDataMode +} + +export function NPMStatsChart({ + queryData, + transform, + binType, + packages, + range, + facetX, + facetY, + showDataMode, +}: NPMStatsChartProps) { + if (!queryData?.length) return null + + const binOption = binningOptionsByType[binType] + const binUnit = binningOptionsByType[binType].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': + // Use the actual start date from the query data, or fall back to npm's stats start date + 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) => { + // Get the corresponding package group from the packages prop to get the hidden state + const packageGroupWithHidden = packages[index] + + // Filter out any sub packages that are hidden before + // summing them into a unified downloads count + 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) => { + // Clamp the data to the floor bin of the start date + const date = d3.utcDay(new Date(d.day)) + if (date < startDate) return + + downloadsByDate.set( + date.getTime(), + // Sum the downloads for each date + (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][], + } + }) + + // Prepare data for plotting + const binnedPackageData = combinedPackageGroups.map((packageGroup) => { + // Now apply our binning as groupings + 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], + })) + + return { + ...packageGroup, + downloads, + } + }) + + // Apply the baseline correction + const baselinePackageIndex = packages.findIndex((pkg) => { + return pkg.baseline + }) + + const baselinePackage = binnedPackageData[baselinePackageIndex] + + const baseLineValuesByDate = + baselinePackage && binnedPackageData.length + ? (() => { + return new Map( + baselinePackage.downloads.map((d) => { + return [d.date.getTime(), d.downloads] + }), + ) + })() + : undefined + + const correctedPackageData = binnedPackageData.map((packageGroup) => { + const first = packageGroup.downloads[0] + const firstDownloads = first?.downloads ?? 0 + + return { + ...packageGroup, + downloads: packageGroup.downloads.map((d) => { + if (baseLineValuesByDate) { + d.downloads = + d.downloads / (baseLineValuesByDate.get(d.date.getTime()) || 1) + } + + return { + ...d, + change: d.downloads - firstDownloads, + } + }), + } + }) + + // Filter out any top-level hidden packages + const filteredPackageData = correctedPackageData.filter((_, index) => { + const packageGroupWithHidden = packages[index] + const isHidden = packageGroupWithHidden?.packages[0]?.hidden + const isBaseline = packageGroupWithHidden?.baseline + return !isBaseline && !isHidden + }) + + const plotData = filteredPackageData.flatMap((d) => d.downloads) + + const baseOptions: Plot.LineYOptions = { + x: 'date', + y: transform === 'normalize-y' ? 'change' : 'downloads', + fx: facetX, + fy: facetY, + } as const + + const partialBinEnd = binUnit.floor(now) + const partialBinStart = binUnit.offset(partialBinEnd, -1) + + // Force complete data mode when using relative change + const effectiveShowDataMode = + transform === 'normalize-y' ? 'complete' : showDataMode + + return ( + + {({ width = 1000, height }) => ( + d.date >= partialBinStart), + { + ...baseOptions, + stroke: 'name', + strokeWidth: 1.5, + strokeDasharray: '2 4', + strokeOpacity: 0.8, + curve: 'monotone-x', + }, + ) + : undefined, + Plot.lineY( + plotData.filter((d) => d.date < partialBinEnd), + { + ...baseOptions, + stroke: 'name', + strokeWidth: 2, + curve: 'monotone-x', + }, + ), + Plot.tip( + effectiveShowDataMode === 'all' + ? plotData + : plotData.filter((d) => d.date < partialBinEnd), + Plot.pointer({ + ...baseOptions, + stroke: 'name', + format: { + x: (d) => + d.toLocaleDateString('en-US', { + month: 'long', + day: 'numeric', + year: 'numeric', + }), + }, + } as Plot.TipOptions), + ), + ] as const + ).filter(Boolean), + x: { + label: 'Date', + labelOffset: 35, + }, + y: { + label: + transform === 'normalize-y' + ? 'Downloads Growth' + : baselinePackage + ? 'Downloads (% of baseline)' + : '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, + }, + }} + /> + )} + + ) +} diff --git a/src/components/npm-stats/NPMSummary.tsx b/src/components/npm-stats/NPMSummary.tsx new file mode 100644 index 000000000..0b7b3330b --- /dev/null +++ b/src/components/npm-stats/NPMSummary.tsx @@ -0,0 +1,176 @@ +import * as React from 'react' +import { Suspense } from 'react' +import { useSuspenseQuery } from '@tanstack/react-query' +import { queryOptions } from '@tanstack/react-query' +import { BlankErrorBoundary } from '~/components/BlankErrorBoundary' +import { ossStatsQuery } from '~/queries/stats' +import { useNpmDownloadCounter } from '~/hooks/useNpmDownloadCounter' +import { fetchRecentDownloadStats } from '~/utils/stats.server' +import type { Library } from '~/libraries' + +/** + * Format a number with appropriate suffix (K, M, B) + */ +function formatNumber(num: number): string { + if (num >= 1_000_000_000) { + return `${(num / 1_000_000_000).toFixed(1)}B` + } + 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.toLocaleString() +} + +/** + * Query options for recent download stats + */ +function recentDownloadsQuery(library: Library) { + return queryOptions({ + queryKey: ['npm-recent-downloads', library.id], + queryFn: () => + fetchRecentDownloadStats({ + data: { + library: { + id: library.id, + repo: library.repo, + frameworks: library.frameworks, + }, + }, + }), + staleTime: 5 * 60 * 1000, // 5 minutes + }) +} + +/** + * Animated counter component for all-time downloads + */ +function AnimatedDownloadCounter({ + npmData, +}: { + npmData: { + totalDownloads: number + ratePerDay?: number + updatedAt?: number + } +}) { + const ref = useNpmDownloadCounter(npmData) + const initialCount = npmData.totalDownloads ?? 0 + return ( + + {initialCount.toLocaleString()} + + ) +} + +/** + * Stat card component + */ +function StatCard({ + value, + label, + animated, + npmData, +}: { + value?: number + label: string + animated?: boolean + npmData?: { + totalDownloads: number + ratePerDay?: number + updatedAt?: number + } +}) { + return ( +
+
+ {animated && npmData ? ( + + ) : ( + formatNumber(value ?? 0) + )} +
+
+ {label} +
+
+ ) +} + +/** + * Content component that fetches and displays NPM stats + */ +function NPMSummaryContent({ library }: { library: Library }) { + // Fetch all-time stats (includes ratePerDay for animation) + const { data: ossStats } = useSuspenseQuery(ossStatsQuery({ library })) + + // Fetch recent download stats (daily, weekly, monthly) + const { data: recentStats } = useSuspenseQuery(recentDownloadsQuery(library)) + + return ( +
+

+ View download statistics for TanStack {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. +

+ +
+ + + + +
+
+ ) +} + +/** + * Skeleton loading state + */ +function NPMSummarySkeleton() { + return ( +
+
+
+ +
+ {[...Array(4)].map((_, i) => ( +
+
+
+
+ ))} +
+
+ ) +} + +/** + * NPM Summary component showing all-time, monthly, weekly, and daily downloads + * Uses Suspense for loading state and ErrorBoundary for error handling + */ +export function NPMSummary({ library }: { library: Library }) { + return ( + }> + + + + + ) +} diff --git a/src/components/npm-stats/PackagePills.tsx b/src/components/npm-stats/PackagePills.tsx new file mode 100644 index 000000000..e646c7eb6 --- /dev/null +++ b/src/components/npm-stats/PackagePills.tsx @@ -0,0 +1,306 @@ +import * as React from 'react' +import { twMerge } from 'tailwind-merge' +import { X, Plus, Eye, EyeOff, Pin, EllipsisVertical } from 'lucide-react' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@radix-ui/react-dropdown-menu' +import { Tooltip } from '~/components/Tooltip' +import { type PackageGroup, getPackageColor } from './NPMStatsChart' + +export type PackagePillProps = { + packageGroup: PackageGroup + index: number + allPackageGroups: PackageGroup[] + error?: string + onColorClick: (packageName: string, event: React.MouseEvent) => void + onToggleVisibility: (index: number, packageName: string) => void + onRemove: (index: number) => void + // Optional advanced features (main page only) + onBaselineChange?: (packageName: string) => void + onCombinePackage?: (packageName: string) => void + onRemoveFromGroup?: (mainPackage: string, subPackage: string) => void + openMenuPackage?: string | null + onMenuOpenChange?: (packageName: string, open: boolean) => void +} + +export function PackagePill({ + packageGroup, + index, + allPackageGroups, + error, + onColorClick, + onToggleVisibility, + onRemove, + onBaselineChange, + onCombinePackage, + onRemoveFromGroup, + openMenuPackage, + onMenuOpenChange, +}: PackagePillProps) { + const mainPackage = packageGroup.packages[0] + if (!mainPackage) return null + + const packageList = packageGroup.packages + const isCombined = packageList.length > 1 + const subPackages = packageList.filter((p) => p.name !== mainPackage.name) + const color = getPackageColor(mainPackage.name, allPackageGroups) + + const showAdvancedMenu = + onBaselineChange && onCombinePackage && onMenuOpenChange + + return ( +
+
+ {packageGroup.baseline ? ( + <> + + + + {mainPackage.name} + + ) : ( + <> + + + + + + + + )} + {isCombined ? ( + + + {subPackages.length} + + ) : null} + + {/* Advanced dropdown menu (main page only) */} + {showAdvancedMenu && ( +
+ onMenuOpenChange(mainPackage.name, open)} + > + + + + + + +
+ Options +
+
+ { + e.preventDefault() + onToggleVisibility(index, mainPackage.name) + }} + className="w-full px-2 py-1.5 text-left text-sm rounded hover:bg-gray-500/20 flex items-center gap-2 outline-none cursor-pointer" + > + {mainPackage.hidden ? ( + + ) : ( + + )} + {mainPackage.hidden ? 'Show Package' : 'Hide Package'} + + { + e.preventDefault() + onBaselineChange(mainPackage.name) + }} + className={twMerge( + 'w-full px-2 py-1.5 text-left text-sm rounded hover:bg-gray-500/20 flex items-center gap-2 outline-none cursor-pointer', + packageGroup.baseline ? 'text-blue-500' : '', + )} + > + + {packageGroup.baseline + ? 'Remove Baseline' + : 'Set as Baseline'} + + { + e.preventDefault() + onColorClick( + mainPackage.name, + e as unknown as React.MouseEvent, + ) + }} + className="w-full px-2 py-1.5 text-left text-sm rounded hover:bg-gray-500/20 flex items-center gap-2 outline-none cursor-pointer" + > +
+ Change Color + + {isCombined && onRemoveFromGroup && ( + <> +
+
+ Sub-packages +
+ {subPackages.map((subPackage) => ( + { + e.preventDefault() + onToggleVisibility(index, subPackage.name) + }} + className="w-full px-2 py-1.5 text-left text-sm rounded hover:bg-gray-500/20 flex items-center gap-2 outline-none cursor-pointer" + > +
+
+ {subPackage.hidden ? ( + + ) : ( + + )} + + {subPackage.name} + +
+ +
+
+ ))} + + )} + { + e.preventDefault() + onCombinePackage(mainPackage.name) + }} + className="w-full px-2 py-1.5 text-left text-sm rounded hover:bg-gray-500/20 flex items-center gap-2 outline-none cursor-pointer" + > + + Add Packages + +
+ + +
+ )} + + +
+ {error && ( +
+ {error} +
+ )} +
+ ) +} + +export type PackagePillsProps = { + packageGroups: PackageGroup[] + queryData?: Array<{ error?: string } | undefined> + onColorClick: (packageName: string, event: React.MouseEvent) => void + onToggleVisibility: (index: number, packageName: string) => void + onRemove: (index: number) => void + // Optional advanced features (main page only) + onBaselineChange?: (packageName: string) => void + onCombinePackage?: (packageName: string) => void + onRemoveFromGroup?: (mainPackage: string, subPackage: string) => void + openMenuPackage?: string | null + onMenuOpenChange?: (packageName: string, open: boolean) => void +} + +export function PackagePills({ + packageGroups, + queryData, + onColorClick, + onToggleVisibility, + onRemove, + onBaselineChange, + onCombinePackage, + onRemoveFromGroup, + openMenuPackage, + onMenuOpenChange, +}: PackagePillsProps) { + return ( +
+ {packageGroups.map((pkg, index) => ( + + ))} +
+ ) +} diff --git a/src/components/npm-stats/PackageSearch.tsx b/src/components/npm-stats/PackageSearch.tsx new file mode 100644 index 000000000..4ac1fd0d5 --- /dev/null +++ b/src/components/npm-stats/PackageSearch.tsx @@ -0,0 +1,157 @@ +import * as React from 'react' +import { useDebouncedValue } from '@tanstack/react-pacer' +import { Search } from 'lucide-react' +import { keepPreviousData, useQuery } from '@tanstack/react-query' +import { Command } from 'cmdk' +import { Spinner } from '~/components/Spinner' + +type NpmSearchResult = { + name: string + description?: string + version?: string + label?: string + publisher?: { username?: string } +} + +export type PackageSearchProps = { + onSelect: (packageName: string) => void + placeholder?: string + autoFocus?: boolean +} + +export function PackageSearch({ + onSelect, + placeholder = 'Search for a package...', + autoFocus = false, +}: PackageSearchProps) { + const [inputValue, setInputValue] = React.useState('') + const [open, setOpen] = React.useState(false) + const containerRef = React.useRef(null) + + const [debouncedInputValue] = useDebouncedValue(inputValue, { + wait: 150, + }) + + React.useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + containerRef.current && + !containerRef.current.contains(event.target as Node) + ) { + setOpen(false) + } + } + + document.addEventListener('mousedown', handleClickOutside) + return () => { + document.removeEventListener('mousedown', handleClickOutside) + } + }, []) + + const searchQuery = useQuery({ + queryKey: ['npm-search', debouncedInputValue], + queryFn: async () => { + if (!debouncedInputValue || debouncedInputValue.length <= 2) + return [] as Array + + const response = await fetch( + `https://api.npms.io/v2/search?q=${encodeURIComponent( + debouncedInputValue, + )}&size=10`, + ) + const data = (await response.json()) as { + results: Array<{ package: NpmSearchResult }> + } + return data.results.map((r) => r.package) + }, + enabled: debouncedInputValue.length > 2, + placeholderData: keepPreviousData, + }) + + const results = React.useMemo(() => { + const hasInputValue = searchQuery.data?.find( + (d) => d.name === debouncedInputValue, + ) + + return [ + ...(hasInputValue + ? [] + : [ + { + name: debouncedInputValue, + label: `Use "${debouncedInputValue}"`, + }, + ]), + ...(searchQuery.data ?? []), + ] + }, [searchQuery.data, debouncedInputValue]) + + const handleInputChange = (value: string) => { + setInputValue(value) + } + + const handleSelect = (value: string) => { + const selectedItem = results?.find((item) => item.name === value) + if (!selectedItem) return + + onSelect(selectedItem.name) + setInputValue('') + setOpen(false) + } + + return ( +
+
+ +
+ + setOpen(true)} + // eslint-disable-next-line jsx-a11y/no-autofocus + autoFocus={autoFocus} + /> +
+ {searchQuery.isFetching && ( +
+ +
+ )} + {inputValue.length && open ? ( + + {inputValue.length < 3 ? ( +
Keep typing to search...
+ ) : searchQuery.isLoading ? ( +
+ Searching... +
+ ) : !results?.length ? ( +
No packages found
+ ) : null} + {results?.map((item) => ( + +
{item.label || item.name}
+
+ {item.description} +
+
+ {item.version ? `v${item.version}• ` : ''} + {item.publisher?.username} +
+
+ ))} +
+ ) : null} +
+
+
+ ) +} diff --git a/src/components/npm-stats/PopularComparisons.tsx b/src/components/npm-stats/PopularComparisons.tsx new file mode 100644 index 000000000..6305847e8 --- /dev/null +++ b/src/components/npm-stats/PopularComparisons.tsx @@ -0,0 +1,106 @@ +import * as React from 'react' +import { Link } from '@tanstack/react-router' +import type { PackageGroup } from './NPMStatsChart' +import { defaultColors } from '~/utils/npm-packages' + +export type ComparisonGroup = { + title: string + packageGroups: PackageGroup[] +} + +type PopularComparisonsProps = { + comparisons: ComparisonGroup[] + // Optional: customize the link target + linkTo?: string + // Optional: callback when a comparison is clicked + onSelect?: (comparison: ComparisonGroup) => void +} + +export function PopularComparisons({ + comparisons, + linkTo = '.', + onSelect, +}: PopularComparisonsProps) { + return ( +
+

Popular Comparisons

+
+ {comparisons.map((comparison) => { + const baselinePackage = comparison.packageGroups.find( + (pg) => pg.baseline, + ) + + const content = ( +
+
+
+

{comparison.title}

+
+
+ {comparison.packageGroups + .filter((d) => !d.baseline) + .map((packageGroup) => ( +
+
+ {packageGroup.packages[0]?.name} +
+ ))} +
+
+ {baselinePackage && ( +
+
Baseline:
+
+ {baselinePackage.packages[0]?.name} +
+
+ )} +
+ ) + + if (onSelect) { + return ( + + ) + } + + return ( + ) => ({ + ...prev, + packageGroups: comparison.packageGroups, + })} + resetScroll={false} + onClick={() => { + window.scrollTo({ + top: 0, + behavior: 'smooth', + }) + }} + className="block p-4 bg-gray-500/10 hover:bg-gray-500/20 rounded-lg transition-colors" + > + {content} + + ) + })} +
+
+ ) +} diff --git a/src/components/npm-stats/Resizable.tsx b/src/components/npm-stats/Resizable.tsx new file mode 100644 index 000000000..7072d3f36 --- /dev/null +++ b/src/components/npm-stats/Resizable.tsx @@ -0,0 +1,69 @@ +import * as React from 'react' + +export type ResizableProps = { + height: number + onHeightChange: (height: number) => void + children: React.ReactNode + minHeight?: number +} + +export function Resizable({ + height, + onHeightChange, + children, + minHeight = 300, +}: ResizableProps) { + const [isDragging, setIsDragging] = React.useState(false) + const [dragEl, setDragEl] = React.useState(null) + const startYRef = React.useRef(0) + const startHeightRef = React.useRef(height) + + const onHeightChangeRef = React.useRef(onHeightChange) + React.useEffect(() => { + onHeightChangeRef.current = onHeightChange + }) + + React.useEffect(() => { + if (!dragEl) return + + const handleMouseDown = (e: MouseEvent) => { + setIsDragging(true) + startYRef.current = e.clientY + startHeightRef.current = height + + const handleMouseMove = (e: MouseEvent) => { + const deltaY = e.clientY - startYRef.current + const newHeight = Math.max(minHeight, startHeightRef.current + deltaY) + onHeightChangeRef.current(newHeight) + } + + const handleMouseUp = () => { + setIsDragging(false) + document.removeEventListener('mousemove', handleMouseMove) + document.removeEventListener('mouseup', handleMouseUp) + } + + document.addEventListener('mousemove', handleMouseMove) + document.addEventListener('mouseup', handleMouseUp) + } + + dragEl.addEventListener('mousedown', handleMouseDown) + return () => { + dragEl?.removeEventListener('mousedown', handleMouseDown) + } + }, [dragEl, height, minHeight]) + + return ( +
+ {children} +
+
+
+
+ ) +} diff --git a/src/components/npm-stats/StatsTable.tsx b/src/components/npm-stats/StatsTable.tsx new file mode 100644 index 000000000..6f09b611b --- /dev/null +++ b/src/components/npm-stats/StatsTable.tsx @@ -0,0 +1,195 @@ +import * as React from 'react' +import * as d3 from 'd3' +import { X, EyeOff } from 'lucide-react' +import { Tooltip } from '~/components/Tooltip' +import { + type PackageGroup, + type TransformMode, + type NpmQueryData, + type BinType, + binningOptionsByType, + getPackageColor, + formatNumber, +} from './NPMStatsChart' + +export interface StatsTableProps { + queryData: NpmQueryData | undefined + packageGroups: PackageGroup[] + binOption: (typeof binningOptionsByType)[BinType] + transform: TransformMode + onColorClick: (packageName: string, event: React.MouseEvent) => void + onToggleVisibility: (index: number, packageName: string) => void + onRemove: (index: number) => void +} + +interface PackageStat { + package: string + totalDownloads: number + binDownloads: number + growth: number + growthPercentage: number + color: string + hidden: boolean | undefined + index: number +} + +/** + * Calculates package statistics from query data for display in the stats table. + */ +function calculateStats( + queryData: NpmQueryData | undefined, + packageGroups: PackageGroup[], + binOption: StatsTableProps['binOption'], +): PackageStat[] { + if (!queryData) return [] + + return queryData + .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(new Date(a.day)).getTime() - + d3.utcDay(new Date(b.day)).getTime(), + ) + + const binUnit = binOption.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, packageGroups) + + const firstBin = binnedDownloads[0] + const lastBin = binnedDownloads[binnedDownloads.length - 1] + + if (!firstBin || !lastBin) return null + + const growth = lastBin[1] - firstBin[1] + const growthPercentage = growth / (firstBin[1] || 1) + + return { + package: firstPackage.name, + totalDownloads: d3.sum(binnedDownloads, (d) => d[1]), + binDownloads: lastBin[1], + growth, + growthPercentage, + color, + hidden: firstPackage.hidden, + index, + } + }) + .filter((stat): stat is PackageStat => stat != null) +} + +/** + * A table showing download statistics for packages. + * Displays package name, total downloads, and downloads per period. + */ +export function StatsTable({ + queryData, + packageGroups, + binOption, + transform, + onColorClick, + onToggleVisibility, + onRemove, +}: StatsTableProps) { + const stats = calculateStats(queryData, packageGroups, binOption) + const sortedStats = [...stats].sort((a, b) => + transform === 'normalize-y' + ? b.growth - a.growth + : b.binDownloads - a.binDownloads, + ) + + return ( +
+ + + + + + + + + + {sortedStats.map((stat) => ( + + + + + + ))} + +
+ Package Name + + Total Period Downloads + + Downloads last {binOption.single} +
+
+ + + +
+
+ + + + + + +
+
+
+
+ {formatNumber(stat.totalDownloads)} + + {formatNumber(stat.binDownloads)} +
+
+ ) +} diff --git a/src/components/npm-stats/index.ts b/src/components/npm-stats/index.ts new file mode 100644 index 000000000..eda7a9be1 --- /dev/null +++ b/src/components/npm-stats/index.ts @@ -0,0 +1,47 @@ +export { + NPMStatsChart, + type NPMStatsChartProps, + type NpmQueryData, + type PackageGroup, + type BinType, + type TransformMode, + type ShowDataMode, + type TimeRange, + type FacetValue, + binTypeSchema, + transformModeSchema, + showDataModeSchema, + binningOptions, + binningOptionsByType, + timeRanges, + defaultRangeBinTypes, + getPackageColor, + formatNumber, + isBinningOptionValidForRange, +} from './NPMStatsChart' + +export { PopularComparisons, type ComparisonGroup } from './PopularComparisons' + +export { PackageSearch, type PackageSearchProps } from './PackageSearch' + +export { Resizable, type ResizableProps } from './Resizable' + +export { npmQueryOptions } from './npmQueryOptions' + +export { + ColorPickerPopover, + type ColorPickerPopoverProps, +} from './ColorPickerPopover' + +export { StatsTable, type StatsTableProps } from './StatsTable' + +export { NPMSummary } from './NPMSummary' + +export { ChartControls, type ChartControlsProps } from './ChartControls' + +export { + PackagePill, + PackagePills, + type PackagePillProps, + type PackagePillsProps, +} from './PackagePills' diff --git a/src/components/npm-stats/npmQueryOptions.ts b/src/components/npm-stats/npmQueryOptions.ts new file mode 100644 index 000000000..9646a7ca5 --- /dev/null +++ b/src/components/npm-stats/npmQueryOptions.ts @@ -0,0 +1,122 @@ +import * as d3 from 'd3' +import { keepPreviousData, queryOptions } from '@tanstack/react-query' +import type { PackageGroup, TimeRange, NpmQueryData } from './NPMStatsChart' + +/** + * Shared TanStack Query options for fetching NPM download statistics. + * Used by both the main /stats/npm page and library-specific NPM stats pages. + */ +export function npmQueryOptions({ + packageGroups, + range, +}: { + packageGroups: PackageGroup[] + range: TimeRange +}) { + const now = d3.utcDay(new Date()) + // Set to start of today to avoid timezone issues + now.setHours(0, 0, 0, 0) + const endDate = now + + // NPM download statistics only go back to January 10, 2015 + 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': + // Use NPM's stats start date - the API will return empty data for dates before packages existed + 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 (): Promise => { + try { + // Import the bulk server function for fetching all npm downloads at once + const { fetchNpmDownloadsBulk } = await import('~/utils/stats.server') + + // Make a single bulk request for all packages + 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), + }, + }) + + // Process results to match the expected format + return results.map((result, groupIndex) => { + let actualStartDate = startDate + + // Find the earliest non-zero download for this package group + 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 ?? undefined, + actualStartDate, + } + }) + } catch (error) { + console.error('Failed to fetch npm stats:', error) + // Return error state for all package groups + return packageGroups.map((packageGroup) => ({ + packages: packageGroup.packages.map((pkg) => ({ + ...pkg, + downloads: [], + })), + start: formatDate(startDate), + end: formatDate(endDate), + error: 'Failed to fetch package data (see console for details)', + actualStartDate: startDate, + })) + } + }, + placeholderData: keepPreviousData, + }) +} diff --git a/src/libraries/libraries.ts b/src/libraries/libraries.ts index 6d24c138b..c85a82580 100644 --- a/src/libraries/libraries.ts +++ b/src/libraries/libraries.ts @@ -259,6 +259,7 @@ export const pacer: LibrarySlim = { badge: 'beta', repo: 'tanstack/pacer', frameworks: ['react', 'preact', 'solid'], + legacyPackages: ['@tanstack/pacer-lite'], latestVersion: 'v0', latestBranch: 'main', availableVersions: ['v0'], 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..be494ff68 --- /dev/null +++ b/src/routes/$libraryId/$version.docs.npm-stats.tsx @@ -0,0 +1,474 @@ +import * as React from 'react' +import { createFileRoute, Link } from '@tanstack/react-router' +import * as v from 'valibot' +import { useThrottledCallback } from '@tanstack/react-pacer' +import { Plus } from 'lucide-react' +import { useQuery } from '@tanstack/react-query' +import { twMerge } from 'tailwind-merge' + +import { getLibrary } from '~/libraries' +import { DocContainer } from '~/components/DocContainer' +import { Tooltip } from '~/components/Tooltip' +import { Spinner } from '~/components/Spinner' +import { + NPMStatsChart, + Resizable, + ColorPickerPopover, + StatsTable, + PackageSearch, + NPMSummary, + ChartControls, + PackagePills, + npmQueryOptions, + type PackageGroup, + type TimeRange, + type BinType, + type TransformMode, + type ShowDataMode, + binningOptionsByType, + defaultRangeBinTypes, + getPackageColor, +} from '~/components/npm-stats' +import { + getLibraryNpmPackages, + getAvailableFrameworkAdapters, + getAvailableCompetitors, + frameworkMeta, + defaultColors, +} from '~/utils/npm-packages' +import { packageGroupSchema } from '~/routes/stats/npm/-comparisons' + +const transformModeSchema = v.picklist(['none', 'normalize-y']) +const binTypeSchema = v.picklist(['yearly', 'monthly', 'weekly', 'daily']) +const showDataModeSchema = v.picklist(['all', 'complete']) + +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.picklist([ + '7-days', + '30-days', + '90-days', + '180-days', + '365-days', + '730-days', + '1825-days', + 'all-time', + ]), + '365-days', + ), + '365-days', + ), + transform: v.fallback(v.optional(transformModeSchema, 'none'), 'none'), + binType: v.fallback(v.optional(binTypeSchema, 'weekly'), 'weekly'), + showDataMode: v.fallback(v.optional(showDataModeSchema, 'all'), 'all'), + height: v.fallback(v.optional(v.number(), 400), 400), + }), + component: RouteComponent, +}) + +type NpmStatsSearch = { + packageGroups?: PackageGroup[] + range?: TimeRange + transform?: TransformMode + binType?: BinType + showDataMode?: ShowDataMode + height?: number +} + +function RouteComponent() { + const { libraryId } = Route.useParams() + const library = getLibrary(libraryId) + const search = Route.useSearch() + const navigate = Route.useNavigate() + + // Get library-specific packages (without competitors - they're shown as suggestions) + const libraryPackages = getLibraryNpmPackages(library) + + // Use search params or default to library packages + const packageGroups: PackageGroup[] = search.packageGroups ?? libraryPackages + const range: TimeRange = search.range ?? '365-days' + const transform: TransformMode = search.transform ?? 'none' + const binTypeParam: BinType | undefined = search.binType + const showDataModeParam: ShowDataMode = search.showDataMode ?? 'all' + const height: number = search.height ?? 400 + + const binType: BinType = binTypeParam ?? defaultRangeBinTypes[range] + const binOption = binningOptionsByType[binType] + + const [colorPickerPackage, setColorPickerPackage] = React.useState< + string | null + >(null) + const [colorPickerPosition, setColorPickerPosition] = React.useState<{ + x: number + y: number + } | null>(null) + const [openMenuPackage, setOpenMenuPackage] = React.useState( + null, + ) + + const npmQuery = useQuery( + npmQueryOptions({ + packageGroups, + range, + }), + ) + + const handleRangeChange = (newRange: TimeRange) => { + navigate({ + to: '.', + search: (prev: NpmStatsSearch) => ({ + ...prev, + range: newRange, + binType: defaultRangeBinTypes[newRange], + }), + }) + } + + const handleBinnedChange = (value: BinType) => { + navigate({ + to: '.', + search: (prev: NpmStatsSearch) => ({ + ...prev, + binType: value, + }), + resetScroll: false, + }) + } + + const handleTransformChange = (mode: TransformMode) => { + navigate({ + to: '.', + search: (prev: NpmStatsSearch) => ({ + ...prev, + transform: mode, + }), + resetScroll: false, + }) + } + + const handleShowDataModeChange = (mode: ShowDataMode) => { + navigate({ + to: '.', + search: (prev: NpmStatsSearch) => ({ + ...prev, + showDataMode: mode, + }), + resetScroll: false, + }) + } + + const togglePackageVisibility = (index: number, packageName: string) => { + navigate({ + to: '.', + search: (prev: NpmStatsSearch) => ({ + ...prev, + packageGroups: prev.packageGroups?.map((pkg, i) => + i === index + ? { + ...pkg, + packages: pkg.packages.map((p) => + p.name === packageName ? { ...p, hidden: !p.hidden } : p, + ), + } + : pkg, + ), + }), + replace: true, + resetScroll: false, + }) + } + + const handleRemovePackageName = (packageGroupIndex: number) => { + navigate({ + to: '.', + search: (prev: NpmStatsSearch) => ({ + ...prev, + packageGroups: prev.packageGroups?.filter( + (_, i: number) => i !== packageGroupIndex, + ), + }), + resetScroll: false, + }) + } + + const handleAddPackage = (packageName: string, color?: string) => { + navigate({ + to: '.', + search: (prev: NpmStatsSearch) => { + // Get the next default color based on existing package count + const existingCount = (prev.packageGroups ?? packageGroups).length + const nextColor = + color ?? defaultColors[existingCount % defaultColors.length] + + return { + ...prev, + packageGroups: [ + ...(prev.packageGroups ?? packageGroups), + { packages: [{ name: packageName }], color: nextColor }, + ], + } + }, + resetScroll: false, + }) + } + + // Get available framework adapters that aren't already in the chart + const availableAdapters = getAvailableFrameworkAdapters( + library, + packageGroups, + ) + + // Get available competitor packages that aren't already in the chart + const availableCompetitors = getAvailableCompetitors(library, packageGroups) + + const handleBaselineChange = (packageName: string) => { + navigate({ + to: '.', + search: (prev: NpmStatsSearch) => { + return { + ...prev, + packageGroups: prev.packageGroups?.map((pkg) => { + const baseline = + pkg.packages[0]?.name === packageName ? !pkg.baseline : false + + return { + ...pkg, + baseline, + } + }), + } + }, + resetScroll: false, + }) + } + + const handleColorClick = (packageName: string, event: React.MouseEvent) => { + const rect = event.currentTarget.getBoundingClientRect() + setColorPickerPosition({ x: rect.left, y: rect.bottom + 5 }) + setColorPickerPackage(packageName) + } + + const handleColorChange = useThrottledCallback( + (packageName: string, color: string | null) => { + navigate({ + to: '.', + search: (prev: NpmStatsSearch) => { + const packageGroup = packageGroups.find((pkg) => + pkg.packages.some((p) => p.name === packageName), + ) + if (!packageGroup) return prev + + const newPackages = packageGroups.map((pkg) => + pkg === packageGroup + ? color === null + ? { packages: pkg.packages } + : { ...pkg, color } + : pkg, + ) + + return { + ...prev, + packageGroups: newPackages, + } + }, + replace: true, + resetScroll: false, + }) + }, + { + wait: 100, + }, + ) + + const onHeightChange = useThrottledCallback( + (height: number) => { + navigate({ + to: '.', + search: (prev: NpmStatsSearch) => ({ ...prev, height }), + resetScroll: false, + }) + }, + { + wait: 16, + }, + ) + + const handleMenuOpenChange = (packageName: string, open: boolean) => { + if (!open) { + setOpenMenuPackage(null) + } else { + setOpenMenuPackage(packageName) + } + } + + return ( + +
+
+

+ NPM Stats for {library.name} +

+ + +
+ + +
+ + {/* Package Pills */} +
+ +
+ + {/* Suggested Framework Adapters */} + {availableAdapters.length > 0 && ( +
+ + Add adapter: + + {availableAdapters.map(({ framework, packageName, color }) => ( + + + + ))} +
+ )} + + {/* Suggested Competitor Packages */} + {availableCompetitors.length > 0 && ( +
+ + Compare with: + + {availableCompetitors.map((competitor) => { + const mainPackage = competitor.packages[0]?.name + if (!mainPackage) return null + const color = competitor.color ?? defaultColors[0] + return ( + + + + ) + })} +
+ )} + + {/* Color Picker Popover */} + {colorPickerPackage && colorPickerPosition && ( + handleColorChange(pkgName, null)} + onClose={() => { + setColorPickerPackage(null) + setColorPickerPosition(null) + }} + /> + )} + + {/* Chart */} + {packageGroups.length > 0 && ( +
+
+ {npmQuery.isFetching && npmQuery.data ? ( +
+
+ +
+ Updating... +
+
+
+ ) : null} + + {npmQuery.isLoading ? ( +
+
+ +
+ Loading download statistics... +
+
+
+ ) : ( + + )} +
+
+ + {/* Stats Table */} + +
+ )} + +
+
+
+ + ) +} diff --git a/src/routes/stats/npm/index.tsx b/src/routes/stats/npm/index.tsx index f174c26a8..3480856f7 100644 --- a/src/routes/stats/npm/index.tsx +++ b/src/routes/stats/npm/index.tsx @@ -1,23 +1,10 @@ import * as React from 'react' import { Link, createFileRoute } from '@tanstack/react-router' import * as v from 'valibot' -import { useDebouncedValue, useThrottledCallback } from '@tanstack/react-pacer' -import { - X, - Plus, - Eye, - EyeOff, - Pin, - EllipsisVertical, - Search, -} from 'lucide-react' -import { keepPreviousData, queryOptions, useQuery } from '@tanstack/react-query' -import * as Plot from '@observablehq/plot' -import { ParentSize } from '~/components/ParentSize' -import { Tooltip } from '~/components/Tooltip' +import { useThrottledCallback } from '@tanstack/react-pacer' +import { X } from 'lucide-react' +import { useQuery } from '@tanstack/react-query' import { Card } from '~/components/Card' -import * as d3 from 'd3' -import { HexColorPicker } from 'react-colorful' import { seo } from '~/utils/seo' import { getPopularComparisons, @@ -26,16 +13,27 @@ import { } from './-comparisons' import { GamHeader, GamVrec1 } from '~/components/Gam' import { AdGate } from '~/contexts/AdsContext' -import { twMerge } from 'tailwind-merge' -// Using public asset URL -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@radix-ui/react-dropdown-menu' -import { Command } from 'cmdk' import { Spinner } from '~/components/Spinner' +import { + NPMStatsChart, + PackageSearch, + Resizable, + ColorPickerPopover, + StatsTable, + PopularComparisons, + ChartControls, + PackagePills, + npmQueryOptions, + type PackageGroup, + type TimeRange, + type BinType, + type TransformMode, + type ShowDataMode, + type FacetValue, + binningOptionsByType, + defaultRangeBinTypes, + getPackageColor, +} from '~/components/npm-stats' const transformModeSchema = v.picklist(['none', 'normalize-y']) const binTypeSchema = v.picklist(['yearly', 'monthly', 'weekly', 'daily']) @@ -193,29 +191,6 @@ export const Route = createFileRoute('/stats/npm/')({ }, }) -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 - -type TimeRange = - | '7-days' - | '30-days' - | '90-days' - | '180-days' - | '365-days' - | '730-days' - | '1825-days' - | 'all-time' - -type BinType = v.InferOutput - type NpmStatsSearch = { packageGroups?: Array<{ name?: string @@ -232,761 +207,6 @@ type NpmStatsSearch = { height?: number } -type NpmSearchResult = { - name: string - description?: string - version?: string - label?: string - publisher?: { username?: string } -} - -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, -) - -type TransformMode = 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 - -// Custom number formatter for more precise control -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() -} - -const dropdownButtonStyles = { - base: 'bg-gray-500/10 rounded-md px-2 py-1 text-sm flex items-center gap-1', - active: 'bg-gray-500/20', -} as const - -function npmQueryOptions({ - packageGroups, - range, -}: { - packageGroups: v.InferOutput[] - range: TimeRange -}) { - const now = d3.utcDay(new Date()) - // Set to start of today to avoid timezone issues - now.setHours(0, 0, 0, 0) - const endDate = now - - // NPM download statistics only go back to January 10, 2015 - 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': - // Use NPM's stats start date - the API will return empty data for dates before packages existed - 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 { - // Import the bulk server function for fetching all npm downloads at once - const { fetchNpmDownloadsBulk } = await import('~/utils/stats.server') - - // Make a single bulk request for all packages - 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), - }, - }) - - // Process results to match the expected format - return results.map((result, groupIndex) => { - let actualStartDate = startDate - - // Find the earliest non-zero download for this package group - 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 error state for all package groups - return packageGroups.map((packageGroup) => ({ - packages: packageGroup.packages.map((pkg) => ({ - ...pkg, - downloads: [], - })), - start: formatDate(startDate), - end: formatDate(endDate), - error: 'Failed to fetch package data (see console for details)', - actualStartDate: startDate, - })) - } - }, - placeholderData: keepPreviousData, - }) -} - -// Get or assign colors for packages -function getPackageColor( - packageName: string, - packages: v.InferOutput[], -) { - // Find the package group that contains this package - const packageInfo = packages.find((pkg) => - pkg.packages.some((p) => p.name === packageName), - ) - if (packageInfo?.color) { - return packageInfo.color - } - - // Otherwise, assign a default color based on the package's position - 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
-} - -function Resizable({ - height, - onHeightChange, - children, -}: { - height: number - onHeightChange: (height: number) => void - children: React.ReactNode -}) { - const [isDragging, setIsDragging] = React.useState(false) - const [dragEl, setDragEl] = React.useState(null) - const startYRef = React.useRef(0) - const startHeightRef = React.useRef(height) - - const onHeightChangeRef = React.useRef(onHeightChange) - React.useEffect(() => { - onHeightChangeRef.current = onHeightChange - }) - - React.useEffect(() => { - if (!dragEl) return - - const handleMouseDown = (e: MouseEvent) => { - setIsDragging(true) - startYRef.current = e.clientY - startHeightRef.current = height - - const handleMouseMove = (e: MouseEvent) => { - const deltaY = e.clientY - startYRef.current - const newHeight = Math.max(300, startHeightRef.current + deltaY) - onHeightChangeRef.current(newHeight) - } - - const handleMouseUp = () => { - setIsDragging(false) - document.removeEventListener('mousemove', handleMouseMove) - document.removeEventListener('mouseup', handleMouseUp) - } - - document.addEventListener('mousemove', handleMouseMove) - document.addEventListener('mouseup', handleMouseUp) - } - - dragEl.addEventListener('mousedown', handleMouseDown) - return () => { - dragEl?.removeEventListener('mousedown', handleMouseDown) - } - }, [dragEl, height]) - - return ( -
- {children} -
-
-
-
- ) -} - -const showDataModeOptions = [ - { value: 'all', label: 'All Data' }, - { value: 'complete', label: 'Hide Partial Data' }, -] as const - -type ShowDataMode = v.InferOutput - -function NpmStatsChart({ - queryData, - transform, - binType, - packages, - range, - facetX, - facetY, - showDataMode, -}: { - queryData: - | undefined - | Awaited< - ReturnType>['queryFn']> - > - transform: TransformMode - binType: BinType - packages: v.InferOutput[] - range: TimeRange - facetX?: FacetValue - facetY?: FacetValue - showDataMode: ShowDataMode -}) { - if (!queryData?.length) return null - - const binOption = binningOptionsByType[binType] - const binUnit = binningOptionsByType[binType].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': - // Use the actual start date from the query data, or fall back to npm's stats start date - 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) => { - // Get the corresponding package group from the packages prop to get the hidden state - const packageGroupWithHidden = packages[index] - - // Filter out any sub packages that are hidden before - // summing them into a unified downloads count - 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) => { - // Clamp the data to the floor bin of the start date - const date = d3.utcDay(new Date(d.day)) - if (date < startDate) return - - downloadsByDate.set( - date.getTime(), - // Sum the downloads for each date - (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][], - } - }) - - // Prepare data for plotting - const binnedPackageData = combinedPackageGroups.map((packageGroup) => { - // Now apply our binning as groupings - 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], - })) - - return { - ...packageGroup, - downloads, - } - }) - - // Apply the baseline correction - - const baselinePackageIndex = packages.findIndex((pkg) => { - return pkg.baseline - }) - - const baselinePackage = binnedPackageData[baselinePackageIndex] - - const baseLineValuesByDate = - baselinePackage && binnedPackageData.length - ? (() => { - return new Map( - baselinePackage.downloads.map((d) => { - return [d.date.getTime(), d.downloads] - }), - ) - })() - : undefined - - const correctedPackageData = binnedPackageData.map((packageGroup) => { - const first = packageGroup.downloads[0] - const firstDownloads = first?.downloads ?? 0 - - return { - ...packageGroup, - downloads: packageGroup.downloads.map((d) => { - if (baseLineValuesByDate) { - d.downloads = - d.downloads / (baseLineValuesByDate.get(d.date.getTime()) || 1) - } - - return { - ...d, - change: d.downloads - firstDownloads, - } - }), - } - }) - - // Filter out any top-level hidden packages - const filteredPackageData = correctedPackageData.filter((_, index) => { - const packageGroupWithHidden = packages[index] - const isHidden = packageGroupWithHidden?.packages[0]?.hidden - const isBaseline = packageGroupWithHidden?.baseline - return !isBaseline && !isHidden - }) - - const plotData = filteredPackageData.flatMap((d) => d.downloads) - - const baseOptions: Plot.LineYOptions = { - x: 'date', - y: transform === 'normalize-y' ? 'change' : 'downloads', - fx: facetX, - fy: facetY, - } as const - - const partialBinEnd = binUnit.floor(now) - const partialBinStart = binUnit.offset(partialBinEnd, -1) - - // Force complete data mode when using relative change - const effectiveShowDataMode = - transform === 'normalize-y' ? 'complete' : showDataMode - - return ( - - {({ width = 1000, height }) => ( - d.date >= partialBinStart), - { - ...baseOptions, - stroke: 'name', - strokeWidth: 1.5, - strokeDasharray: '2 4', - strokeOpacity: 0.8, - curve: 'monotone-x', - }, - ), - ], - Plot.lineY( - plotData.filter((d) => d.date < partialBinEnd), - { - ...baseOptions, - stroke: 'name', - strokeWidth: 2, - curve: 'monotone-x', - }, - ), - Plot.tip( - effectiveShowDataMode === 'all' - ? plotData - : plotData.filter((d) => d.date < partialBinEnd), - Plot.pointer({ - ...baseOptions, - stroke: 'name', - format: { - x: (d) => - 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' - : baselinePackage - ? 'Downloads (% of baseline)' - : '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, - }, - }} - /> - )} - - ) -} - -function PackageSearch({ - onSelect, - placeholder = 'Search for a package...', - autoFocus = false, -}: { - onSelect: (packageName: string) => void - placeholder?: string - autoFocus?: boolean -}) { - const [inputValue, setInputValue] = React.useState('') - const [open, setOpen] = React.useState(false) - const containerRef = React.useRef(null) - - const [debouncedInputValue] = useDebouncedValue(inputValue, { - wait: 150, - }) - - React.useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if ( - containerRef.current && - !containerRef.current.contains(event.target as Node) - ) { - setOpen(false) - } - } - - document.addEventListener('mousedown', handleClickOutside) - return () => { - document.removeEventListener('mousedown', handleClickOutside) - } - }, []) - - const searchQuery = useQuery({ - queryKey: ['npm-search', debouncedInputValue], - queryFn: async () => { - if (!debouncedInputValue || debouncedInputValue.length <= 2) - return [] as Array - - const response = await fetch( - `https://api.npms.io/v2/search?q=${encodeURIComponent( - debouncedInputValue, - )}&size=10`, - ) - const data = (await response.json()) as { - results: Array<{ package: NpmSearchResult }> - } - return data.results.map((r) => r.package) - }, - enabled: debouncedInputValue.length > 2, - placeholderData: keepPreviousData, - }) - - const results = React.useMemo(() => { - const hasInputValue = searchQuery.data?.find( - (d) => d.name === debouncedInputValue, - ) - - return [ - ...(hasInputValue - ? [] - : [ - { - name: debouncedInputValue, - label: `Use "${debouncedInputValue}"`, - }, - ]), - ...(searchQuery.data ?? []), - ] - }, [searchQuery.data, debouncedInputValue]) - - const handleInputChange = (value: string) => { - setInputValue(value) - } - - const handleSelect = (value: string) => { - const selectedItem = results?.find((item) => item.name === value) - if (!selectedItem) return - - onSelect(selectedItem.name) - setInputValue('') - setOpen(false) - } - - return ( -
-
- -
- - setOpen(true)} - // eslint-disable-next-line jsx-a11y/no-autofocus - autoFocus={autoFocus} - /> -
- {searchQuery.isFetching && ( -
- -
- )} - {inputValue.length && open ? ( - - {inputValue.length < 3 ? ( -
Keep typing to search...
- ) : searchQuery.isLoading ? ( -
- Searching... -
- ) : !results?.length ? ( -
No packages found
- ) : null} - {results?.map((item) => ( - -
{item.label || item.name}
-
- {item.description} -
-
- {item.version ? `v${item.version}• ` : ''} - {item.publisher?.username} -
-
- ))} -
- ) : null} -
-
-
- ) -} - -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', -} - -// Add a function to check if a binning option is valid for a time range -function isBinningOptionValidForRange( - range: TimeRange, - binType: BinType, -): boolean { - switch (range) { - case '7-days': - case '30-days': - return binType === 'daily' - case '90-days': - case '180-days': - return ( - binType === 'daily' || binType === 'weekly' || binType === 'monthly' - ) - case '365-days': - return ( - binType === 'daily' || binType === 'weekly' || binType === 'monthly' - ) - case '730-days': - case '1825-days': - case 'all-time': - return true - } -} - -const transformOptions = [ - { value: 'none', label: 'Actual Values' }, - { value: 'normalize-y', label: 'Relative Change' }, -] as const - -const facetOptions = [ - { value: 'name', label: 'Package' }, - // Add more options here in the future -] as const - -type FacetValue = (typeof facetOptions)[number]['value'] - -type PackageGroup = v.InferOutput - function RouteComponent() { const search = Route.useSearch() const packageGroups: PackageGroup[] = search.packageGroups ?? [] @@ -1340,489 +560,33 @@ function RouteComponent() {
- - - - - - - -
- Time Range -
- {timeRanges.map(({ value, label }) => ( - handleRangeChange(value)} - className={twMerge( - 'w-full px-2 py-1.5 text-left text-sm rounded hover:bg-gray-500/20 flex items-center gap-2 outline-none cursor-pointer', - value === range ? 'text-blue-500 bg-blue-500/10' : '', - 'data-highlighted:bg-gray-500/20 data-highlighted:text-blue-500', - )} - > - {label} - - ))} -
-
- - - - - - - -
- Binning Interval -
- {binningOptions.map(({ label, value }) => ( - handleBinnedChange(value)} - disabled={!isBinningOptionValidForRange(range, value)} - className={twMerge( - 'w-full px-2 py-1.5 text-left text-sm rounded hover:bg-gray-500/20 flex items-center gap-2 outline-none cursor-pointer', - binType === value ? 'text-blue-500 bg-blue-500/10' : '', - 'data-highlighted:bg-gray-500/20 data-highlighted:text-blue-500', - !isBinningOptionValidForRange(range, value) - ? 'opacity-50 cursor-not-allowed' - : '', - )} - > - {label} - - ))} -
-
- - - - - - - -
- Y-Axis Transform -
- {transformOptions.map(({ value, label }) => ( - - handleTransformChange(value as TransformMode) - } - className={twMerge( - 'w-full px-2 py-1.5 text-left text-sm rounded hover:bg-gray-500/20 flex items-center gap-2 outline-none cursor-pointer', - transform === value ? 'text-blue-500 bg-blue-500/10' : '', - 'data-highlighted:bg-gray-500/20 data-highlighted:text-blue-500', - )} - > - {label} - - ))} -
-
- - - - - - - -
- Horizontal Facet -
- handleFacetXChange(undefined)} - className={twMerge( - 'w-full px-2 py-1.5 text-left text-sm rounded hover:bg-gray-500/20 flex items-center gap-2 outline-none cursor-pointer', - !facetX ? 'text-blue-500 bg-blue-500/10' : '', - 'data-highlighted:bg-gray-500/20 data-highlighted:text-blue-500', - )} - > - No Facet - - {facetOptions.map(({ value, label }) => ( - handleFacetXChange(value)} - className={twMerge( - 'w-full px-2 py-1.5 text-left text-sm rounded hover:bg-gray-500/20 flex items-center gap-2 outline-none cursor-pointer', - facetX === value ? 'text-blue-500 bg-blue-500/10' : '', - 'data-highlighted:bg-gray-500/20 data-highlighted:text-blue-500', - )} - > - {label} - - ))} -
-
- - - - - - - -
- Vertical Facet -
- handleFacetYChange(undefined)} - className={twMerge( - 'w-full px-2 py-1.5 text-left text-sm rounded hover:bg-gray-500/20 flex items-center gap-2 outline-none cursor-pointer', - !facetY ? 'text-blue-500 bg-blue-500/10' : '', - 'data-highlighted:bg-gray-500/20 data-highlighted:text-blue-500', - )} - > - No Facet - - {facetOptions.map(({ value, label }) => ( - handleFacetYChange(value)} - className={twMerge( - 'w-full px-2 py-1.5 text-left text-sm rounded hover:bg-gray-500/20 flex items-center gap-2 outline-none cursor-pointer', - facetY === value ? 'text-blue-500 bg-blue-500/10' : '', - 'data-highlighted:bg-gray-500/20 data-highlighted:text-blue-500', - )} - > - {label} - - ))} -
-
- - - - - - - -
- Data Display Mode -
- {showDataModeOptions.map(({ value, label }) => ( - handleShowDataModeChange(value)} - disabled={transform === 'normalize-y'} - className={twMerge( - 'w-full px-2 py-1.5 text-left text-sm rounded hover:bg-gray-500/20 flex items-center gap-2 outline-none cursor-pointer', - showDataModeParam === value - ? 'text-blue-500 bg-blue-500/10' - : '', - 'data-highlighted:bg-gray-500/20 data-highlighted:text-blue-500', - transform === 'normalize-y' - ? 'opacity-50 cursor-not-allowed' - : '', - )} - > - {label} - - ))} -
-
-
-
- {packageGroups.map((pkg, index) => { - const mainPackage = pkg.packages[0] - if (!mainPackage) return null - const packageList = pkg.packages - const isCombined = packageList.length > 1 - const subPackages = packageList.filter( - (p) => p.name !== mainPackage.name, - ) - const color = getPackageColor(mainPackage.name, packageGroups) - - // Get error for this package if any - const packageError = npmQuery.data?.[index]?.error - - return ( -
-
- {pkg.baseline ? ( - <> - - - - {mainPackage.name} - - ) : ( - <> - - - - - - - - )} - {isCombined ? ( - - + {subPackages.length} - - ) : null} -
- - handleMenuOpenChange(mainPackage.name, open) - } - > - - - - - - -
- Options -
-
- { - e.preventDefault() - togglePackageVisibility(index, mainPackage.name) - }} - className="w-full px-2 py-1.5 text-left text-sm rounded hover:bg-gray-500/20 flex items-center gap-2 outline-none cursor-pointer" - > - {mainPackage.hidden ? ( - - ) : ( - - )} - {mainPackage.hidden - ? 'Show Package' - : 'Hide Package'} - - { - e.preventDefault() - handleBaselineChange(mainPackage.name) - }} - className={twMerge( - 'w-full px-2 py-1.5 text-left text-sm rounded hover:bg-gray-500/20 flex items-center gap-2 outline-none cursor-pointer', - pkg.baseline ? 'text-blue-500' : '', - )} - > - - {pkg.baseline - ? 'Remove Baseline' - : 'Set as Baseline'} - - { - e.preventDefault() - handleColorClick( - mainPackage.name, - e as unknown as React.MouseEvent, - ) - }} - className="w-full px-2 py-1.5 text-left text-sm rounded hover:bg-gray-500/20 flex items-center gap-2 outline-none cursor-pointer" - > -
- Change Color - - {isCombined && ( - <> -
-
- Sub-packages -
- {subPackages.map((subPackage) => ( - { - e.preventDefault() - togglePackageVisibility( - index, - subPackage.name, - ) - }} - className="w-full px-2 py-1.5 text-left text-sm rounded hover:bg-gray-500/20 flex items-center gap-2 outline-none cursor-pointer" - > -
-
- {subPackage.hidden ? ( - - ) : ( - - )} - - {subPackage.name} - -
- -
-
- ))} - - )} - { - e.preventDefault() - handleCombinePackage(mainPackage.name) - }} - className="w-full px-2 py-1.5 text-left text-sm rounded hover:bg-gray-500/20 flex items-center gap-2 outline-none cursor-pointer" - > - - Add Packages - -
- - -
- -
- {packageError && ( -
- {packageError} -
- )} -
- ) - })} +
+ {/* Combine Package Dialog */} {combiningPackage && ( @@ -1851,44 +615,17 @@ function RouteComponent() { {/* Color Picker Popover */} {colorPickerPackage && colorPickerPosition && ( -
handleColorChange(pkgName, null)} + onClose={() => { + setColorPickerPackage(null) + setColorPickerPosition(null) }} - > -
- Pick a color -
- - handleColorChange(colorPickerPackage, color) - } - /> -
- - -
-
+ /> )} {Object.keys(packageGroups).length ? ( @@ -1919,7 +656,7 @@ function RouteComponent() {
) : ( -
-
- - - - - - - - - - {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 - - // Sort downloads by date - const sortedDownloads = packageGroupDownloads.packages - .flatMap((p) => p.downloads) - .sort( - (a, b) => - d3.utcDay(a.day).getTime() - - d3.utcDay(b.day).getTime(), - ) - - // Get the binning unit and calculate partial bin boundaries - const binUnit = binOption.bin - const now = d3.utcDay(new Date()) - const partialBinEnd = binUnit.floor(now) - - // Filter downloads based on showDataMode for total downloads - const filteredDownloads = sortedDownloads.filter( - (d) => d3.utcDay(new Date(d.day)) < partialBinEnd, - ) - - // Group downloads by bin using d3 - 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, - packageGroups, - ) - - const firstBin = binnedDownloads[0] - const lastBin = - binnedDownloads[binnedDownloads.length - 1] - - if (!firstBin || !lastBin) return null - - const growth = lastBin[1] - firstBin[1] - const growthPercentage = growth / (firstBin[1] || 1) - - return { - package: firstPackage.name, - totalDownloads: d3.sum( - binnedDownloads, - (d) => d[1], - ), - binDownloads: lastBin[1], - growth, - growthPercentage, - color, - hidden: firstPackage.hidden, - index, - } - }) - .filter( - ( - stat, - ): stat is { - package: string - totalDownloads: number - binDownloads: number - growth: number - growthPercentage: number - color: string - hidden: boolean | undefined - index: number - } => stat != null, - ) - .sort((a, b) => - transform === 'normalize-y' - ? b.growth - a.growth - : b.binDownloads - a.binDownloads, - ) - .map((stat) => ( - - - - - - ))} - -
- Package Name - - Total Period Downloads - - Downloads last {binOption.single} -
-
- - - -
-
- - - - - - -
-
-
-
- {formatNumber(stat.totalDownloads)} - - {formatNumber(stat.binDownloads)} -
-
+
) : null} {/* Popular Comparisons Section */} -
-

Popular Comparisons

-
- {getPopularComparisons().map((comparison) => { - const baselinePackage = comparison.packageGroups.find( - (pg) => pg.baseline, - ) - return ( - ({ - ...prev, - packageGroups: comparison.packageGroups, - })} - resetScroll={false} - onClick={(e) => { - window.scrollTo({ - top: 0, - behavior: 'smooth', - }) - }} - className="block p-4 bg-gray-500/10 hover:bg-gray-500/20 rounded-lg transition-colors space-y-4" - > -
-
-

{comparison.title}

-
-
- {comparison.packageGroups - .filter((d) => !d.baseline) - .map((packageGroup) => ( -
-
- {packageGroup.packages[0].name} -
- ))} -
-
- {baselinePackage && ( -
-
Baseline:
-
- {baselinePackage.packages[0].name} -
-
- )} - - ) - })} -
-
+
diff --git a/src/utils/npm-packages.ts b/src/utils/npm-packages.ts new file mode 100644 index 000000000..2ed31a258 --- /dev/null +++ b/src/utils/npm-packages.ts @@ -0,0 +1,241 @@ +import * as v from 'valibot' +import type { LibrarySlim, Framework } from '~/libraries/types' +import { packageGroupSchema } from '~/routes/stats/npm/-comparisons' + +export type PackageGroup = v.InferOutput + +// Default colors for charts +export 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 + +// Framework display names and colors +export const frameworkMeta: Record = + { + react: { name: 'React', color: '#61DAFB' }, + preact: { name: 'Preact', color: '#673AB8' }, + solid: { name: 'Solid', color: '#2C4F7C' }, + vue: { name: 'Vue', color: '#42B883' }, + svelte: { name: 'Svelte', color: '#FF3E00' }, + angular: { name: 'Angular', color: '#DD0031' }, + lit: { name: 'Lit', color: '#325CFF' }, + qwik: { name: 'Qwik', color: '#18B6F6' }, + vanilla: { name: 'Vanilla', color: '#F7DF1E' }, + } + +// Competitor packages for each library (keyed by library id) +// These are the only packages that need manual maintenance +const libraryCompetitors: Partial> = { + query: [ + { packages: [{ name: 'swr' }], color: '#ec4899' }, + { packages: [{ name: '@apollo/client' }], color: '#6B46C1' }, + { packages: [{ name: '@trpc/client' }], color: '#2596BE' }, + ], + router: [ + { packages: [{ name: 'react-router' }], color: '#FF0000' }, + { packages: [{ name: 'wouter' }], color: '#8b5cf6' }, + ], + table: [ + { + packages: [{ name: 'ag-grid-community' }, { name: 'ag-grid-enterprise' }], + color: '#29B6F6', + }, + { packages: [{ name: 'handsontable' }], color: '#FFCA28' }, + { packages: [{ name: '@mui/x-data-grid' }], color: '#1976D2' }, + ], + form: [ + { packages: [{ name: 'react-hook-form' }], color: '#EC5990' }, + { packages: [{ name: '@conform-to/dom' }], color: '#FF5733' }, + ], + virtual: [ + { packages: [{ name: 'react-virtualized' }], color: '#FF6B6B' }, + { packages: [{ name: 'react-window' }], color: '#4ECDC4' }, + { packages: [{ name: 'virtua' }], color: '#6C5CE7' }, + ], + store: [ + { packages: [{ name: 'zustand' }], color: '#764ABC' }, + { packages: [{ name: 'jotai' }], color: '#6366f1' }, + { packages: [{ name: 'valtio' }], color: '#FF6B6B' }, + ], + pacer: [ + { packages: [{ name: 'lodash.debounce' }], color: '#3498db' }, + { packages: [{ name: 'lodash.throttle' }], color: '#2ecc71' }, + ], +} + +/** + * Get the NPM package name for a library + framework combination. + * Handles special cases like angular-query-experimental. + */ +export function getFrameworkPackageName( + library: LibrarySlim, + framework: Framework, +): string | null { + // Vanilla doesn't have a framework adapter (uses core package) + if (framework === 'vanilla') { + return null + } + + // Special cases for package naming + if (library.id === 'query' && framework === 'angular') { + return '@tanstack/angular-query-experimental' + } + + // Standard pattern: @tanstack/{framework}-{libraryId} + return `@tanstack/${framework}-${library.id}` +} + +/** + * Get the core/main package name for a library. + * This is the primary package shown in npm stats. + */ +export function getLibraryMainPackage(library: LibrarySlim): string { + // Special cases + if (library.id === 'start') { + return '@tanstack/start-client-core' + } + if (library.id === 'config') { + return '@tanstack/vite-config' + } + if (library.id === 'create-tsrouter-app') { + return 'create-tsrouter-app' + } + + // Use corePackageName if specified (e.g., table-core) + if (library.corePackageName) { + return `@tanstack/${library.corePackageName}` + } + + return `@tanstack/${library.id}` +} + +/** + * Get the library's brand color from its styling. + * Extracts color from colorFrom (e.g., "from-red-500" -> "#EF4444") + */ +export function getLibraryColor(library: LibrarySlim): string { + // Map Tailwind color classes to hex values + const colorMap: Record = { + 'from-red-500': '#EF4444', + 'from-amber-500': '#F59E0B', + 'from-emerald-500': '#10B981', + 'from-teal-500': '#14B8A6', + 'from-cyan-500': '#06B6D4', + 'from-blue-500': '#3B82F6', + 'from-yellow-500': '#EAB308', + 'from-purple-500': '#A855F7', + 'from-pink-500': '#EC4899', + 'from-orange-500': '#F97316', + 'from-lime-500': '#84CC16', + 'from-twine-500': '#B89A56', + 'from-black': '#1F2937', + } + + // Try to extract color from colorFrom + const colorClass = library.colorFrom.split(' ')[0] + return colorMap[colorClass] ?? defaultColors[0] +} + +/** + * Get the primary package group for a library (main package + legacy packages). + */ +export function getLibraryPackageGroup(library: LibrarySlim): PackageGroup { + const mainPackage = getLibraryMainPackage(library) + const packages = [{ name: mainPackage }] + + // Add legacy packages if any + if (library.legacyPackages) { + for (const legacy of library.legacyPackages) { + packages.push({ name: legacy }) + } + } + + return { + packages, + color: getLibraryColor(library), + } +} + +/** + * Get available framework adapters for a library that aren't already in the current packages. + */ +export function getAvailableFrameworkAdapters( + library: LibrarySlim, + currentPackages: PackageGroup[], +): Array<{ framework: Framework; packageName: string; color: string }> { + // Get all package names currently in the chart + const currentPackageNames = new Set( + currentPackages.flatMap((pg) => pg.packages.map((p) => p.name)), + ) + + return library.frameworks + .map((framework) => { + const packageName = getFrameworkPackageName(library, framework) + if (!packageName) return null + // Skip if already added + if (currentPackageNames.has(packageName)) return null + + return { + framework, + packageName, + color: frameworkMeta[framework]?.color ?? defaultColors[0], + } + }) + .filter( + ( + item, + ): item is { framework: Framework; packageName: string; color: string } => + item !== null, + ) +} + +/** + * Get the NPM packages for a specific library (main package + legacy). + */ +export function getLibraryNpmPackages(library: LibrarySlim): PackageGroup[] { + return [getLibraryPackageGroup(library)] +} + +/** + * Get available competitor packages for a library that aren't already in the current packages. + */ +export function getAvailableCompetitors( + library: LibrarySlim, + currentPackages: PackageGroup[], +): PackageGroup[] { + const competitors = libraryCompetitors[library.id] ?? [] + if (competitors.length === 0) return [] + + // Get all package names currently in the chart + const currentPackageNames = new Set( + currentPackages.flatMap((pg) => pg.packages.map((p) => p.name)), + ) + + // Filter out competitors that are already added + return competitors.filter((competitor) => { + const mainPackageName = competitor.packages[0]?.name + return mainPackageName && !currentPackageNames.has(mainPackageName) + }) +} + +/** + * Get comparison packages for a library (library + competitors). + * @deprecated Use getLibraryNpmPackages for default packages and getAvailableCompetitors for suggestions + */ +export function getLibraryComparisonPackages( + library: LibrarySlim, +): PackageGroup[] { + const libraryPackages = getLibraryNpmPackages(library) + const competitors = libraryCompetitors[library.id] ?? [] + + return [...libraryPackages, ...competitors] +} diff --git a/src/utils/stats.server.ts b/src/utils/stats.server.ts index 04fc4b99e..cb244b63f 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() - Number(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, + } + })