Skip to content

Commit

Permalink
feat: add package insights (#189)
Browse files Browse the repository at this point in the history
* feat: add package insight service

* feat: add stats component

* chore: self review
  • Loading branch information
tom-bywild authored Oct 28, 2024
1 parent 187a923 commit ca302c7
Show file tree
Hide file tree
Showing 29 changed files with 658 additions and 507 deletions.
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"cSpell.words": [
"clsx",
"CRAN",
"cranlogs",
"Inviews",
"Lukas",
"minisearch",
Expand Down
1 change: 1 addition & 0 deletions web/.eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ module.exports = {
"plugin:import/typescript",
],
rules: {
"no-console": "error",
"unused-imports/no-unused-imports": "error",
"unused-imports/no-unused-vars": [
"warn",
Expand Down
161 changes: 161 additions & 0 deletions web/app/data/package-insight-service.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { format, sub } from "date-fns";
import {
CranDownloadsResponse,
CranResponse,
PackageDownloadTrend,
} from "./types";

export class PackageInsightService {
/**
* Get the downloads for a package in the last n days, starting
* from today. The result is an array of objects with the number
* of downloads, the relative trend for the respective time period
* and the label.
*
* @param name The name of the package.
* @returns The downloads for the package.
*/
static async getDownloadsWithTrends(
name: string,
): Promise<PackageDownloadTrend[]> {
const getDownloads = async (days: number, from?: Date) => {
const res = await this.getPackageDownloadsLastNDays({
name,
days,
from,
});
return res?.[0]?.downloads;
};

// Fetch all statistics in parallel.
const now = new Date();
const [stats, trendReferences] = await Promise.all([
Promise.all([
getDownloads(1),
getDownloads(7, now),
getDownloads(30, now),
getDownloads(90, now),
getDownloads(365, now),
]),
Promise.all([
getDownloads(0, sub(now, { days: 2 })),
getDownloads(7, sub(now, { days: 7 })),
getDownloads(30, sub(now, { days: 30 })),
getDownloads(90, sub(now, { days: 90 })),
getDownloads(365, sub(now, { days: 365 })),
]),
]);

// Get rend in percentage.
const trends = stats.map((stat, i) => {
const ref = trendReferences[i];
// No valid values.
if (stat === undefined || ref === undefined || ref === 0) {
return "";
}
const diff = stat - ref;
return `${diff > 0 ? "+" : ""}${((diff / ref) * 100).toFixed(0)}%`;
});

// Aggregate the statistics into a single object.
const labels = [
"Yesterday",
"Last 7 days",
"Last 30 days",
"Last 90 days",
"Last 365 days",
];
const downloads = stats
.map((value, i) => ({
value,
trend: trends[i],
label: labels[i],
}))
.filter(({ value }) => value !== undefined)
.map(({ value, ...rest }) => ({
value: this.format1kDelimiter(value),
...rest,
}));

return downloads;
}

/*
* Private.
*/

private static async getPackageDownloadsLastNDays(params: {
name: string;
days: number;
from?: Date;
}) {
const { name, days, from } = params;

// Special case as the logs-API returns data earliest for
// the last day according to its point of reference (likely UTC).
if (days === 1 && !from) {
return this
.fetchFromCRAN<CranDownloadsResponse>`/downloads/total/last-day/${name}`;
}

const validFrom = from || new Date();
const past = sub(validFrom, { days });
return this
.fetchFromCRAN<CranDownloadsResponse>`/downloads/total/${past}:${validFrom}/${name}`;
}

/**
* Tagged template literal for the CRAN downloads endpoint that
* fetches the statistics for the provided url.
*
* @param template
* @param params
* @returns
*/
private static async fetchFromCRAN<R extends CranResponse = CranResponse>(
template: TemplateStringsArray,
...params: (string | Date)[]
): Promise<R> {
const url = this.getCRANLogsUrl(template, ...params);
return fetch(url, {
headers: {
"Content-Type": "application/json",
},
})
.then((response) => response.json())
.catch(() => undefined);
}

/**
* Tagged template literal for the CRAN statistics API
* that creates the correct url and turns any date into
* the correct format.
*
* @param template
* @param params
* @returns
*/
private static getCRANLogsUrl(
template: TemplateStringsArray,
...params: (string | Date)[]
) {
// Zip template and params together and remove the last empty ''.
const zipped = template.slice(0, -1).reduce(
(acc, part, i) => {
return acc.concat(part, params[i]);
},
[] as (string | Date)[],
);
// Replace all dates with formatted dates.
const stringified = zipped.map((part) => {
if (typeof part === "string") return part;
return format(part, "yyyy-MM-dd");
});

return "https://cranlogs.r-pkg.org" + stringified.join("");
}

private static format1kDelimiter(total: number) {
return total.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ".");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ type PlausibleDataPoint = { page: string; visitors: number };
type TopPageDomain = "authors" | "packages" | "start" | "about";
type TopPagesIndex = Record<TopPageDomain, Array<PlausibleDataPoint>>;

export class InsightService {
export class PageInsightService {
private static plausibleBaseUrl = "https://plausible.io/api/v1/stats";

private static topPages: ExpiringSearchIndex<TopPagesIndex> = {
Expand Down
15 changes: 15 additions & 0 deletions web/app/data/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,3 +112,18 @@ export type ExpiringSearchIndex<T> = {
index: T;
expiresAt: number;
};

export type CranDownloadsResponse = Array<{
downloads: number;
start: string;
end: string;
package: string;
}>;

export type CranResponse = CranDownloadsResponse;

export type PackageDownloadTrend = {
trend: string;
label: string;
value: string;
};
7 changes: 4 additions & 3 deletions web/app/entry.server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,14 @@ import { RemixServer } from "@remix-run/react";
import { isbot } from "isbot";
import { renderToPipeableStream } from "react-dom/server";
import { server } from "./mocks/node.server";
import { slog } from "./modules/observability.server";

const ABORT_DELAY = 5_000;

if (process.env.NODE_ENV === "development") {
server.listen();
server.events.on("request:start", ({ request }) => {
console.debug("MSW intercepted:", request.method, request.url);
slog.debug("MSW intercepted:", request.method, request.url);
});
}

Expand Down Expand Up @@ -82,7 +83,7 @@ function handleBotRequest(
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
console.error(error);
slog.error(error);
}
},
},
Expand Down Expand Up @@ -132,7 +133,7 @@ function handleBrowserRequest(
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
console.error(error);
slog.error(error);
}
},
},
Expand Down
8 changes: 6 additions & 2 deletions web/app/entry.worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,18 @@ export {};

declare let self: ServiceWorkerGlobalScope;

import { Logger } from "@remix-pwa/sw";

const logger = new Logger({ prefix: "[SW]" });

self.addEventListener("install", (event) => {
console.log("Service worker installed");
logger.log("Service worker installed");

event.waitUntil(self.skipWaiting());
});

self.addEventListener("activate", (event) => {
console.log("Service worker activated");
logger.log("Service worker activated");

const cachesToKeep: string[] = [];

Expand Down
4 changes: 2 additions & 2 deletions web/app/modules/binary-download-link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,10 @@ export function BinaryDownloadListItem(props: Props) {
size={18}
className="opacity-50 transition-opacity group-hover/binary:animate-wiggle-more group-hover/binary:opacity-100 group-hover/binary:animate-infinite"
/>
<div className="flex flex-col gap-1">
<div className="flex flex-col gap-2">
<span className={twGradient({ variant })} />
<span className="font-mono leading-none">{headline}</span>
<span className="text-gray-dim">
<span className="text-gray-dim leading-none">
{os} <span className="text-gray-normal opacity-30">/</span> {arch}
</span>
</div>
Expand Down
14 changes: 6 additions & 8 deletions web/app/modules/contact-pill.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
RiArrowRightSLine,
RiExternalLinkLine,
RiUserFill,
RiVipCrown2Fill,
Expand Down Expand Up @@ -49,8 +50,8 @@ export function ContactPill(props: Props) {
const hasRoles = roles.length > 0;

return (
<div className={clsx("flex flex-col gap-3 sm:flex-row", className)}>
<h4 className="text-lg">{name}</h4>
<div className={clsx("flex flex-col gap-4 sm:flex-row", className)}>
<h4 className="shrink-0 text-lg">{name}</h4>
<div className="flex flex-wrap gap-2">
{isMaintainer ? (
<InfoPill
Expand All @@ -67,12 +68,9 @@ export function ContactPill(props: Props) {
</InfoPill>
) : null}
<Link to={`/author/${name}`}>
<InfoPill
size="sm"
label={<RiUserFill size={16} />}
className="bg-gray-ui border-transparent"
>
Show author details
<InfoPill size="sm" label={<RiUserFill size={16} />} variant="jade">
Show author details{" "}
<RiArrowRightSLine size={16} className="text-gray-dim" />
</InfoPill>
</Link>
{hasRoles ? (
Expand Down
8 changes: 4 additions & 4 deletions web/app/modules/copy-pill-button.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { RiFileCopyLine } from "@remixicon/react";
import { useCopyToClipboard } from "@uidotdev/usehooks";
import clsx from "clsx";
import { PropsWithChildren, useRef } from "react";
import { toast } from "sonner";
import { copyTextToClipboard } from "@remix-pwa/client";
import { clog } from "./observability";

type Props = PropsWithChildren<{
className?: string;
Expand All @@ -13,17 +14,16 @@ type Props = PropsWithChildren<{
export function CopyPillButton(props: Props) {
const { children, className, textToCopy, onSuccess } = props;

const [_, copy] = useCopyToClipboard();

const timeLockUntilNextCopyAllowed = useRef(0);
const onCopy = async () => {
if (Date.now() >= timeLockUntilNextCopyAllowed.current) {
timeLockUntilNextCopyAllowed.current = Date.now() + 5_000;
try {
await copy(textToCopy);
await copyTextToClipboard(textToCopy);
toast.success("Copied to clipboard");
onSuccess?.();
} catch (error) {
clog.error(error);
toast.error("Failed to copy to clipboard");
}
}
Expand Down
2 changes: 1 addition & 1 deletion web/app/modules/external-link-pill.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ type Props = PropsWithChildren<{
href: string;
children: ReactNode;
className?: string;
icon?: JSX.Element;
icon?: ReactNode;
}>;

export function ExternalLinkPill(props: Props) {
Expand Down
8 changes: 6 additions & 2 deletions web/app/modules/nav-search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,14 +70,16 @@ export function NavSearch(props: Props) {
fetcher.submit(data, {
debounceTimeout: 200,
method: "POST",
action: "/search?index",
action: "/api/search?index",
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

const onSelect = useCallback((item?: SearchResult) => {
const onSelect = useCallback(() => {
setInput("");
setIsFocused(false);
inputRef.current?.blur();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

useKeyboardEvent(
Expand All @@ -89,6 +91,7 @@ export function NavSearch(props: Props) {
setInput("");
setIsFocused(false);
inputRef.current?.blur();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isFocused]),
);

Expand All @@ -97,6 +100,7 @@ export function NavSearch(props: Props) {
useCallback(() => {
inputRef.current?.focus();
setIsFocused(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []),
);

Expand Down
Loading

0 comments on commit ca302c7

Please sign in to comment.