Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 153 additions & 2 deletions bun.lock

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion components/Package/CollapsibleSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import useSWR from 'swr';
import { H6 } from '~/common/styleguide';
import { Button } from '~/components/Button';
import { Arrow } from '~/components/Icons';
import { TimeRange } from '~/util/datetime';
import tw from '~/util/tailwind';

import DependencyRow from './DependencyRow';
Expand All @@ -30,7 +31,7 @@ export default function CollapsibleSection({ title, data, checkExistence }: Prop
: null,
(url: string) => fetch(url).then(res => res.json()),
{
dedupingInterval: 60_000 * 10,
dedupingInterval: TimeRange.HOUR * 1000,
revalidateOnFocus: false,
}
);
Expand Down
135 changes: 135 additions & 0 deletions components/Package/DownloadsChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { LinearGradient } from '@visx/gradient';
import { ParentSize } from '@visx/responsive';
import { AreaSeries, type AxisScale, Tooltip, XYChart } from '@visx/xychart';
import { useMemo } from 'react';
import { Text, View } from 'react-native';
import useSWR from 'swr';

import { Label } from '~/common/styleguide';
import ThreeDotsLoader from '~/components/Package/ThreeDotsLoader';
import { TimeRange } from '~/util/datetime';
import tw from '~/util/tailwind';

type DataMap = Record<string, number>;
type Point = { date: Date; value: number };

type Props = {
packageName: string;
height?: number;
};

const COLOR = '#2e9ab8';
const DATE_FORMAT = {
month: 'short' as const,
day: '2-digit' as const,
year: '2-digit' as const,
};

export default function DownloadsChart({ packageName, height = 48 }: Props) {
const { data } = useSWR(
`/api/proxy/npm-stat?name=${packageName}`,
(url: string) => fetch(url).then(res => res.json()),
{
dedupingInterval: TimeRange.HOUR * 1000,
revalidateOnFocus: false,
}
);

const series = useMemo(
() => (data && Object.keys(data).length ? mapData(data[packageName]) : null),
[data, packageName]
);

const yDomain = useMemo(() => {
if (!series?.length) {
return undefined;
}

const max = series.reduce((acc, p) => Math.max(acc, p.value), 0);
const startPadding = Math.max(1, max * 0.15);

return [0, max + startPadding];
}, [series]);

return (
<ParentSize>
{({ width }) => {
if (data && !Object.keys(data).length) {
return (
<View style={tw`h-full justify-center items-center`}>
<Label style={tw`text-secondary font-light`}>Cannot fetch download data</Label>
</View>
);
}

if (!width || !data || !series) {
return (
<View style={tw`h-full justify-center items-center`}>
<ThreeDotsLoader />
</View>
);
}

return (
<XYChart
width={width}
height={height + 4}
xScale={{ type: 'time' }}
yScale={{ type: 'linear', nice: true, domain: yDomain }}
margin={{ top: 0, right: 0, bottom: 0, left: 0 }}>
<LinearGradient
id="area-gradient"
from={COLOR}
to={COLOR}
fromOpacity={tw.prefixMatch('dark') ? 0.3 : 0.5}
toOpacity={0}
/>
<AreaSeries<AxisScale, AxisScale, Point>
dataKey="area"
data={series}
xAccessor={(p: Point) => p.date.getTime()}
yAccessor={(p: Point) => p.value}
fill="url(#area-gradient)"
/>
<Tooltip<Point>
showVerticalCrosshair
verticalCrosshairStyle={{
stroke: COLOR,
strokeWidth: 0.5,
strokeDasharray: '4 1',
}}
offsetLeft={8}
offsetTop={10}
detectBounds
unstyled
applyPositionStyle
renderTooltip={({ tooltipData }) => {
const data = tooltipData?.nearestDatum?.datum;
if (!data) {
return null;
}
return (
<Text
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`}>
<span style={tw`text-palette-gray3 dark:text-secondary`}>
{data.date.toLocaleDateString('en-US', DATE_FORMAT)}
&apos;
</span>
<span>{data.value.toLocaleString()}</span>
</Text>
);
}}
/>
</XYChart>
);
}}
</ParentSize>
);
}

function mapData(dataMap: DataMap): Point[] {
return Object.entries(dataMap).map(([date, value]) => ({
date: new Date(date + 'T00:00:00Z'),
value,
}));
}
3 changes: 2 additions & 1 deletion components/Package/MorePackagesBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import useSWR from 'swr';
import { A, Caption, H6, Label, useLayout } from '~/common/styleguide';
import { Download, Star } from '~/components/Icons';
import { type APIResponseType, type LibraryType } from '~/types';
import { TimeRange } from '~/util/datetime';
import getApiUrl from '~/util/getApiUrl';
import tw from '~/util/tailwind';
import urlWithQuery from '~/util/urlWithQuery';
Expand Down Expand Up @@ -38,7 +39,7 @@ export default function MorePackagesBox({ library }: Props) {
return { libraries: [], total: 0 };
}),
{
dedupingInterval: 60_000 * 10,
dedupingInterval: TimeRange.HOUR * 1000,
revalidateOnFocus: false,
}
);
Expand Down
3 changes: 2 additions & 1 deletion components/Package/ReadmeBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { A, P } from '~/common/styleguide';
import { ReadmeFile } from '~/components/Icons';
import rndDark from '~/styles/shiki/rnd-dark.json';
import rndLight from '~/styles/shiki/rnd-light.json';
import { TimeRange } from '~/util/datetime';
import { extractAndStripBlockquoteType } from '~/util/extractAndStripBlockquoteType';
import { getReadmeAssetURL } from '~/util/getReadmeAssetUrl';
import tw from '~/util/tailwind';
Expand Down Expand Up @@ -43,7 +44,7 @@ export default function ReadmeBox({ packageName, githubUrl, isTemplate, loader =
return null;
}),
{
dedupingInterval: 60_000 * 10,
dedupingInterval: TimeRange.MINUTE * 10 * 1000,
revalidateOnFocus: false,
}
);
Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@
"@react-native-async-storage/async-storage": "^2.2.0",
"@react-native-picker/picker": "^2.11.4",
"@sentry/react": "^10.34.0",
"@visx/gradient": "^3.12.0",
"@visx/responsive": "^3.12.0",
"@visx/xychart": "^3.12.0",
"crypto-js": "^4.2.0",
"es-toolkit": "^1.44.0",
"expo": "54.0.31",
Expand Down
37 changes: 37 additions & 0 deletions pages/api/proxy/npm-stat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { type NextApiRequest, type NextApiResponse } from 'next';

import { NEXT_10M_CACHE_HEADER } from '~/util/Constants';
import { TimeRange } from '~/util/datetime';
import { parseQueryParams } from '~/util/parseQueryParams';

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { name } = parseQueryParams(req.query);
const packageName = name ? name.toString().toLowerCase().trim() : undefined;

res.setHeader('Content-Type', 'application/json');
res.setHeader('Cache-Control', 'public, s-maxage=600, stale-while-revalidate=300');

if (!packageName) {
res.statusCode = 500;
return res.json({
error: `Invalid request. You need to specify package name via 'name' query param.`,
});
}

const now = Date.now();
const until = new Date(now).toISOString().slice(0, 10);
const from = new Date(now - TimeRange.MONTH * 1000).toISOString().slice(0, 10);

const result = await fetch(
`https://npm-stat.com/api/download-counts?package=${packageName}&from=${from}&until=${until}`,
NEXT_10M_CACHE_HEADER
);

if ('status' in result && result.status !== 200) {
res.statusCode = result.status;
return res.json({});
}

res.statusCode = 200;
return res.json(await result.json());
}
5 changes: 5 additions & 0 deletions scenes/PackageOverviewScene.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import TrendingMark from '~/components/Library/TrendingMark';
import UpdatedAtView from '~/components/Library/UpdateAtView';
import CollapsibleSection from '~/components/Package/CollapsibleSection';
import DetailsNavigation from '~/components/Package/DetailsNavigation';
import DownloadsChart from '~/components/Package/DownloadsChart';
import EntityCounter from '~/components/Package/EntityCounter';
import ExampleBox from '~/components/Package/ExampleBox';
import MorePackagesBox from '~/components/Package/MorePackagesBox';
Expand Down Expand Up @@ -117,6 +118,10 @@ export default function PackageOverviewScene({
<>
<H6 style={tw`text-[16px] mt-3 text-secondary`}>Popularity</H6>
<TrendingMark library={library} />
<H6 style={tw`text-[16px] text-secondary`}>Downloads (last month)</H6>
<View style={tw`h-[54px] gap-1.5 rounded-lg border border-default overflow-hidden`}>
<DownloadsChart packageName={packageName} />
</View>
</>
)}
{library.github.topics && library.github.topics.length > 0 && (
Expand Down