Skip to content

Commit

Permalink
Add publish dates under each package in the diff (#909)
Browse files Browse the repository at this point in the history
* Add publish dates under each package in the diff

* Refactor publish date code, prepare for dates in autocomplete

* Show time on hover

* Show publication dates in autocomplete

* Print dates in the right timezone

* Fix type error
  • Loading branch information
oBusk committed Mar 9, 2024
1 parent 9764e1d commit 760f4d5
Show file tree
Hide file tree
Showing 11 changed files with 209 additions and 37 deletions.
39 changes: 39 additions & 0 deletions src/app/[...parts]/_page/DiffIntro/PublishDate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { type ComponentPropsWithoutRef } from "react";
import ClientDate from "^/components/ClientDate";
import Skeleton from "^/components/ui/Skeleton";
import getVersionData from "^/lib/api/npm/getVersionData";
import { cx } from "^/lib/cva";
import type SimplePackageSpec from "^/lib/SimplePackageSpec";
import suspense from "^/lib/suspense";

export interface PublishDateProps extends ComponentPropsWithoutRef<"div"> {
pkg: SimplePackageSpec;
}

const shared = cx("my-1 flex h-5 items-center justify-center");

async function PublishDate({ pkg, className, ...props }: PublishDateProps) {
const versionData = await getVersionData(pkg);

const time = versionData[pkg.version]?.time ?? null;

return (
<ClientDate
time={time}
className={cx(shared, "cursor-help", className)}
{...props}
/>
);
}

function PublishDateFallback({ className }: PublishDateProps) {
return (
<div className={cx(shared, className)}>
<Skeleton className="h-2 w-16" />
</div>
);
}

const SuspensedPublishDate = suspense(PublishDate, PublishDateFallback);

export default SuspensedPublishDate;
6 changes: 3 additions & 3 deletions src/app/[...parts]/_page/DiffIntro/SpecBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { forwardRef, type HTMLAttributes } from "react";
import Pkg from "^/components/ui/Pkg";
import { cx } from "^/lib/cva";
import type SimplePackageSpec from "^/lib/SimplePackageSpec";
import PublishDate from "./PublishDate";
import ServiceLinks from "./ServiceLinks";

interface SpecBoxProps extends HTMLAttributes<HTMLElement> {
Expand All @@ -13,9 +14,8 @@ const SpecBox = forwardRef<HTMLElement, SpecBoxProps>(
({ pkg, pkgClassName, ...props }, ref) => (
<section {...props} ref={ref}>
<Pkg pkg={pkg} className={cx("px-1", pkgClassName)} />
<div>
<ServiceLinks pkg={pkg} />
</div>
<PublishDate pkg={pkg} className="font-normal" />
<ServiceLinks pkg={pkg} />
</section>
),
);
Expand Down
14 changes: 12 additions & 2 deletions src/app/_page/MainForm/SpecInput/Suggestion/Suggestion.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { type FunctionComponent } from "react";
import ClientDate from "^/components/ClientDate";
import Stack from "^/components/ui/Stack";
import { type AutocompleteSuggestion } from "^/lib/autocomplete";
import Title from "./Title";
Expand All @@ -10,13 +11,22 @@ export interface SuggestionProps {
}

const Suggestion: FunctionComponent<SuggestionProps> = ({
item: { name, body, tags = [], version } = {},
item: { name, body, tags = [], version, time } = {},
}) => (
<>
<Title name={name} version={version} />

{body ? <p className="text-xs">{body}</p> : null}
<Stack direction="h" className="mt-1 flex flex-wrap gap-1">
<Stack
direction="h"
className="mt-1 flex flex-wrap items-center gap-1 "
>
{time ? (
<ClientDate
className="mr-2 cursor-help text-xs opacity-30"
time={time}
/>
) : null}
{tags.map((tag) => (
<VersionTag key={tag}>{tag}</VersionTag>
))}
Expand Down
37 changes: 8 additions & 29 deletions src/app/api/-/versions/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import packument from "^/lib/api/npm/packument";
import { VERSIONS_PARAMETER_PACKAGE } from "./types";
import getVersionData from "^/lib/api/npm/getVersionData";
import { type Version, VERSIONS_PARAMETER_PACKAGE } from "./types";

export const runtime = "edge";

Expand All @@ -18,35 +18,14 @@ export async function GET(request: Request) {
return new Response("spec must be a string", { status: 400 });
}

const result = await packument(spec, { next: { revalidate: 0 } });
const versionMap = await getVersionData(spec);

const tags = result["dist-tags"];
/**
* Create map from each version in the tags to the tag names. This is to
* avoid having to do a find on the `tags` for every single version.
*
* ```ts
* {
* "1.0.0": ["latest", "bauxite"],
* "1.0.0-beta.1": ["next"],
* }
* ```
*/
const versionToTags = Object.entries(tags).reduce(
(acc, [tag, version]) => {
if (acc[version]) {
acc[version].push(tag);
} else {
acc[version] = [tag];
}
return acc;
},
{} as Record<string, string[]>,
const versions: Version[] = Object.entries(versionMap).map(
([version, data]) => ({
version,
...data,
}),
);
const versions = Object.values(result.versions).map(({ version }) => ({
version,
tags: versionToTags[version],
}));

return NextResponse.json(versions, {
status: 200,
Expand Down
6 changes: 5 additions & 1 deletion src/app/api/-/versions/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import { type VersionData } from "^/lib/api/npm/getVersionData";

export const VERSIONS_PARAMETER_PACKAGE = "package";
export type Version = { version: string; tags?: string[] };
export interface Version extends VersionData {
version: string;
}
export type SpecsEndpointResponse = Version[];
42 changes: 42 additions & 0 deletions src/components/ClientDate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"use client";

import { type HTMLProps, memo } from "react";
import Tooltip from "./ui/Tooltip";

export interface ClientDateProps extends HTMLProps<HTMLDivElement> {
time: string;
}

const currentTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;

/**
* Prints dates in the clients timezone, but also shows the UTC time in a tooltip.
*/
const ClientDateInner = ({ time, ...props }: ClientDateProps) => {
const date = new Date(time);

const label = (
<div className="flex flex-col gap-2">
<div>
{date.toLocaleString()}{" "}
<span className="opacity-30">({currentTimezone})</span>
</div>
<div>
{date.toLocaleString(undefined, {
timeZone: "UTC",
})}{" "}
<span className="opacity-30">(UTC)</span>
</div>
</div>
);

return (
<Tooltip label={label}>
<span {...props}>{date.toLocaleDateString(undefined)}</span>
</Tooltip>
);
};

const ClientDate = memo(ClientDateInner);

export default ClientDate;
62 changes: 62 additions & 0 deletions src/lib/api/npm/getVersionData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { unstable_cache } from "next/cache";
import { cache } from "react";
import type SimplePackageSpec from "^/lib/SimplePackageSpec";
import { simplePackageSpecToString } from "^/lib/SimplePackageSpec";
import packument from "./packument";

// Packuments include a lot of data, often enough to make them too large for the cache.
// For our diff page, and autocomplete, we want to find versions and which date and tag they have.
// So we build this data here, and run it throug the cache to cache this value.

export type VersionData = {
time: string;
tags?: string[];
};

export type VersionMap = {
[version: string]: VersionData;
};

async function getVersionDataInner(
spec: string | SimplePackageSpec,
): Promise<VersionMap> {
const specString =
typeof spec === "string" ? spec : simplePackageSpecToString(spec);

const {
time,
"dist-tags": tags,
versions,
} = await packument(specString, {
// Response is too large to cache
next: { revalidate: false },
});

const versionData: VersionMap = {};

for (const [version, data] of Object.entries(versions)) {
versionData[version] = { time: time[version] };
}

for (const [tag, version] of Object.entries(tags)) {
const entry = versionData[version];
if (entry) {
if (entry.tags != null) {
entry.tags.push(tag);
} else {
entry.tags = [tag];
}
}
}

return versionData;
}

const getVersionData =
// Cache for request de-dupe
cache(
// unstable cache to cache between requests
unstable_cache(getVersionDataInner, ["versionData"]),
);

export default getVersionData;
2 changes: 2 additions & 0 deletions src/lib/autocomplete/AutocompleteSuggestion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ interface AutocompleteSuggestion {
name: string;
/** The version of the package, if available */
version?: string;
/** The timestamp of the publication of the package, if available */
time?: string;
/** A small description to show below the title in the dropdown */
body?: string;
tags?: string[];
Expand Down
3 changes: 2 additions & 1 deletion src/lib/autocomplete/providers/getAutocompleteVersions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,14 @@ async function getAutocompleteVersions(
optionalFilterNpa?.name === name &&
optionalFilterNpa.rawSpec) ||
undefined,
}).map(({ version, versionEmphasized, tags }) => {
}).map(({ version, versionEmphasized, tags, time }) => {
return {
type: AutocompleteSuggestionTypes.Version,
value: `${name}@${version}`,
name,
version: versionEmphasized,
tags,
time,
};
});
}
Expand Down
4 changes: 3 additions & 1 deletion src/lib/autocomplete/providers/versions/matchVersions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export interface Matched {
version: string;
versionEmphasized: string;
tags?: string[];
time?: string;
}

const escapeRegex = (string: string) =>
Expand Down Expand Up @@ -167,11 +168,12 @@ export function matchVersions({

return matches
.sort((a, b) => rcompare(a.version, b.version))
.map(({ version, tags }) => ({
.map(({ version, tags, time }) => ({
version,
versionEmphasized: emphasize(version, rawSpec),
...(tags
? { tags: tags.map((tag) => emphasize(tag, rawSpec)) }
: undefined),
time,
}));
}
31 changes: 31 additions & 0 deletions src/lib/suspense.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import {
type ComponentType,
type FunctionComponent,
type ReactElement,
type ReactNode,
Suspense as SuspenseComp,
} from "react";

/**
* Wrap a component in a Suspense component.
*/
export default function suspense<T>(
WrappedComponent: ComponentType<T>,
fallback: FunctionComponent<T> | ReactNode = <></>,
): FunctionComponent<T> {
const c = (props: T): ReactElement => (
<SuspenseComp
fallback={
typeof fallback === "function" ? fallback(props) : fallback
}
>
<WrappedComponent {...(props as any)} />
</SuspenseComp>
);

c.displayName = `suspense(${
WrappedComponent.displayName ?? WrappedComponent.name
})`;

return c;
}

0 comments on commit 760f4d5

Please sign in to comment.