Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add package insights #189

Merged
merged 3 commits into from
Oct 28, 2024
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
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