Skip to content

Commit 47ff5ab

Browse files
authored
add simple downloads chart to the package overview page (#2132)
1 parent 39e33f4 commit 47ff5ab

8 files changed

Lines changed: 339 additions & 5 deletions

File tree

bun.lock

Lines changed: 153 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

components/Package/CollapsibleSection.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import useSWR from 'swr';
66
import { H6 } from '~/common/styleguide';
77
import { Button } from '~/components/Button';
88
import { Arrow } from '~/components/Icons';
9+
import { TimeRange } from '~/util/datetime';
910
import tw from '~/util/tailwind';
1011

1112
import DependencyRow from './DependencyRow';
@@ -30,7 +31,7 @@ export default function CollapsibleSection({ title, data, checkExistence }: Prop
3031
: null,
3132
(url: string) => fetch(url).then(res => res.json()),
3233
{
33-
dedupingInterval: 60_000 * 10,
34+
dedupingInterval: TimeRange.HOUR * 1000,
3435
revalidateOnFocus: false,
3536
}
3637
);
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { LinearGradient } from '@visx/gradient';
2+
import { ParentSize } from '@visx/responsive';
3+
import { AreaSeries, type AxisScale, Tooltip, XYChart } from '@visx/xychart';
4+
import { useMemo } from 'react';
5+
import { Text, View } from 'react-native';
6+
import useSWR from 'swr';
7+
8+
import { Label } from '~/common/styleguide';
9+
import ThreeDotsLoader from '~/components/Package/ThreeDotsLoader';
10+
import { TimeRange } from '~/util/datetime';
11+
import tw from '~/util/tailwind';
12+
13+
type DataMap = Record<string, number>;
14+
type Point = { date: Date; value: number };
15+
16+
type Props = {
17+
packageName: string;
18+
height?: number;
19+
};
20+
21+
const COLOR = '#2e9ab8';
22+
const DATE_FORMAT = {
23+
month: 'short' as const,
24+
day: '2-digit' as const,
25+
year: '2-digit' as const,
26+
};
27+
28+
export default function DownloadsChart({ packageName, height = 48 }: Props) {
29+
const { data } = useSWR(
30+
`/api/proxy/npm-stat?name=${packageName}`,
31+
(url: string) => fetch(url).then(res => res.json()),
32+
{
33+
dedupingInterval: TimeRange.HOUR * 1000,
34+
revalidateOnFocus: false,
35+
}
36+
);
37+
38+
const series = useMemo(
39+
() => (data && Object.keys(data).length ? mapData(data[packageName]) : null),
40+
[data, packageName]
41+
);
42+
43+
const yDomain = useMemo(() => {
44+
if (!series?.length) {
45+
return undefined;
46+
}
47+
48+
const max = series.reduce((acc, p) => Math.max(acc, p.value), 0);
49+
const startPadding = Math.max(1, max * 0.15);
50+
51+
return [0, max + startPadding];
52+
}, [series]);
53+
54+
return (
55+
<ParentSize>
56+
{({ width }) => {
57+
if (data && !Object.keys(data).length) {
58+
return (
59+
<View style={tw`h-full justify-center items-center`}>
60+
<Label style={tw`text-secondary font-light`}>Cannot fetch download data</Label>
61+
</View>
62+
);
63+
}
64+
65+
if (!width || !data || !series) {
66+
return (
67+
<View style={tw`h-full justify-center items-center`}>
68+
<ThreeDotsLoader />
69+
</View>
70+
);
71+
}
72+
73+
return (
74+
<XYChart
75+
width={width}
76+
height={height + 4}
77+
xScale={{ type: 'time' }}
78+
yScale={{ type: 'linear', nice: true, domain: yDomain }}
79+
margin={{ top: 0, right: 0, bottom: 0, left: 0 }}>
80+
<LinearGradient
81+
id="area-gradient"
82+
from={COLOR}
83+
to={COLOR}
84+
fromOpacity={tw.prefixMatch('dark') ? 0.3 : 0.5}
85+
toOpacity={0}
86+
/>
87+
<AreaSeries<AxisScale, AxisScale, Point>
88+
dataKey="area"
89+
data={series}
90+
xAccessor={(p: Point) => p.date.getTime()}
91+
yAccessor={(p: Point) => p.value}
92+
fill="url(#area-gradient)"
93+
/>
94+
<Tooltip<Point>
95+
showVerticalCrosshair
96+
verticalCrosshairStyle={{
97+
stroke: COLOR,
98+
strokeWidth: 0.5,
99+
strokeDasharray: '4 1',
100+
}}
101+
offsetLeft={8}
102+
offsetTop={10}
103+
detectBounds
104+
unstyled
105+
applyPositionStyle
106+
renderTooltip={({ tooltipData }) => {
107+
const data = tooltipData?.nearestDatum?.datum;
108+
if (!data) {
109+
return null;
110+
}
111+
return (
112+
<Text
113+
style={tw`flex flex-col text-xs bg-black text-white font-sans font-light px-2.5 py-1.5 rounded dark:border dark:border-default`}>
114+
<span style={tw`text-palette-gray3 dark:text-secondary`}>
115+
{data.date.toLocaleDateString('en-US', DATE_FORMAT)}
116+
&apos;
117+
</span>
118+
<span>{data.value.toLocaleString()}</span>
119+
</Text>
120+
);
121+
}}
122+
/>
123+
</XYChart>
124+
);
125+
}}
126+
</ParentSize>
127+
);
128+
}
129+
130+
function mapData(dataMap: DataMap): Point[] {
131+
return Object.entries(dataMap).map(([date, value]) => ({
132+
date: new Date(date + 'T00:00:00Z'),
133+
value,
134+
}));
135+
}

components/Package/MorePackagesBox.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import useSWR from 'swr';
88
import { A, Caption, H6, Label, useLayout } from '~/common/styleguide';
99
import { Download, Star } from '~/components/Icons';
1010
import { type APIResponseType, type LibraryType } from '~/types';
11+
import { TimeRange } from '~/util/datetime';
1112
import getApiUrl from '~/util/getApiUrl';
1213
import tw from '~/util/tailwind';
1314
import urlWithQuery from '~/util/urlWithQuery';
@@ -38,7 +39,7 @@ export default function MorePackagesBox({ library }: Props) {
3839
return { libraries: [], total: 0 };
3940
}),
4041
{
41-
dedupingInterval: 60_000 * 10,
42+
dedupingInterval: TimeRange.HOUR * 1000,
4243
revalidateOnFocus: false,
4344
}
4445
);

components/Package/ReadmeBox.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { A, P } from '~/common/styleguide';
1212
import { ReadmeFile } from '~/components/Icons';
1313
import rndDark from '~/styles/shiki/rnd-dark.json';
1414
import rndLight from '~/styles/shiki/rnd-light.json';
15+
import { TimeRange } from '~/util/datetime';
1516
import { extractAndStripBlockquoteType } from '~/util/extractAndStripBlockquoteType';
1617
import { getReadmeAssetURL } from '~/util/getReadmeAssetUrl';
1718
import tw from '~/util/tailwind';
@@ -43,7 +44,7 @@ export default function ReadmeBox({ packageName, githubUrl, isTemplate, loader =
4344
return null;
4445
}),
4546
{
46-
dedupingInterval: 60_000 * 10,
47+
dedupingInterval: TimeRange.MINUTE * 10 * 1000,
4748
revalidateOnFocus: false,
4849
}
4950
);

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@
2727
"@react-native-async-storage/async-storage": "^2.2.0",
2828
"@react-native-picker/picker": "^2.11.4",
2929
"@sentry/react": "^10.34.0",
30+
"@visx/gradient": "^3.12.0",
31+
"@visx/responsive": "^3.12.0",
32+
"@visx/xychart": "^3.12.0",
3033
"crypto-js": "^4.2.0",
3134
"es-toolkit": "^1.44.0",
3235
"expo": "54.0.31",

pages/api/proxy/npm-stat.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { type NextApiRequest, type NextApiResponse } from 'next';
2+
3+
import { NEXT_10M_CACHE_HEADER } from '~/util/Constants';
4+
import { TimeRange } from '~/util/datetime';
5+
import { parseQueryParams } from '~/util/parseQueryParams';
6+
7+
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
8+
const { name } = parseQueryParams(req.query);
9+
const packageName = name ? name.toString().toLowerCase().trim() : undefined;
10+
11+
res.setHeader('Content-Type', 'application/json');
12+
res.setHeader('Cache-Control', 'public, s-maxage=600, stale-while-revalidate=300');
13+
14+
if (!packageName) {
15+
res.statusCode = 500;
16+
return res.json({
17+
error: `Invalid request. You need to specify package name via 'name' query param.`,
18+
});
19+
}
20+
21+
const now = Date.now();
22+
const until = new Date(now).toISOString().slice(0, 10);
23+
const from = new Date(now - TimeRange.MONTH * 1000).toISOString().slice(0, 10);
24+
25+
const result = await fetch(
26+
`https://npm-stat.com/api/download-counts?package=${packageName}&from=${from}&until=${until}`,
27+
NEXT_10M_CACHE_HEADER
28+
);
29+
30+
if ('status' in result && result.status !== 200) {
31+
res.statusCode = result.status;
32+
return res.json({});
33+
}
34+
35+
res.statusCode = 200;
36+
return res.json(await result.json());
37+
}

scenes/PackageOverviewScene.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import TrendingMark from '~/components/Library/TrendingMark';
1010
import UpdatedAtView from '~/components/Library/UpdateAtView';
1111
import CollapsibleSection from '~/components/Package/CollapsibleSection';
1212
import DetailsNavigation from '~/components/Package/DetailsNavigation';
13+
import DownloadsChart from '~/components/Package/DownloadsChart';
1314
import EntityCounter from '~/components/Package/EntityCounter';
1415
import ExampleBox from '~/components/Package/ExampleBox';
1516
import MorePackagesBox from '~/components/Package/MorePackagesBox';
@@ -117,6 +118,10 @@ export default function PackageOverviewScene({
117118
<>
118119
<H6 style={tw`text-[16px] mt-3 text-secondary`}>Popularity</H6>
119120
<TrendingMark library={library} />
121+
<H6 style={tw`text-[16px] text-secondary`}>Downloads (last month)</H6>
122+
<View style={tw`h-[54px] gap-1.5 rounded-lg border border-default overflow-hidden`}>
123+
<DownloadsChart packageName={packageName} />
124+
</View>
120125
</>
121126
)}
122127
{library.github.topics && library.github.topics.length > 0 && (

0 commit comments

Comments
 (0)