Skip to content
Open
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,8 @@ The generated charts show:
- Standard deviation in tooltips
- Summary table with total installation times and package counts

Registry history uses total installation time in seconds from `registryChartData`, with legacy registry entries in `chartData` as a fallback. Dates containing only normalized registry data are omitted from the seconds chart because those values are measured in milliseconds per package.

### View Results Online

Results are automatically deployed to GitHub Pages when running on the main branch:
Expand Down
2 changes: 1 addition & 1 deletion app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"version": "0.0.0",
"type": "module",
"scripts": {
"test": "node --test tests/*.test.js",
"test": "node --test tests/*.test.*",
"dev": "vite",
"format": "prettier --write . --log-level warn --ignore-path ./.prettierignore --cache",
"build": "tsc -b && vite build",
Expand Down
160 changes: 6 additions & 154 deletions app/src/hooks/use-history-data.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,18 @@
import { useState, useEffect } from "react";
import type { HistoryData, HistoryVariation } from "@/types/history";
import {
PACKAGE_MANAGERS,
extractDayData,
hasPartialResult,
} from "@/lib/history-data";
import type { ChartDataResponse } from "@/lib/history-data";

/** Max days to attempt fetching (generates date strings, 404s are skipped) */
const MAX_DAYS = 180;

/** How many fetches to run in parallel */
const CONCURRENCY = 10;

const PACKAGE_MANAGERS = [
"npm",
"yarn",
"pnpm",
"pacquet",
"berry",
"zpm",
"deno",
"bun",
"vlt",
"aube",
"nx",
"turbo",
"vp",
"node",
"aws",
"cloudsmith",
"github",
"jfrog",
];

interface UseHistoryDataReturn {
historyData: HistoryData | null;
loading: boolean;
Expand Down Expand Up @@ -67,139 +52,6 @@ async function parallelLimit<T>(
return results;
}

type FixtureDataSet = Record<
string,
Array<Record<string, number | string | boolean> & { fixture: string }>
>;

interface ChartDataResponse {
date: string;
chartData: {
variations: string[];
data: FixtureDataSet;
packageManagers: string[];
};
perPackageCountChartData?: {
variations: string[];
data: FixtureDataSet;
packageManagers: string[];
};
registryChartData?: {
variations: string[];
data: FixtureDataSet;
packageManagers: string[];
};
registryPerPackageCountChartData?: {
variations: string[];
data: FixtureDataSet;
packageManagers: string[];
};
}

/**
* Extract per-PM averages (across fixtures) for each variation from a single
* day's chart-data.json response.
*
* For package-management variations (clean, cache, lockfile, etc.) we use
* perPackageCountChartData when available — these values are already in ms/pkg
* and match what the leaderboard cards display. Falls back to total-time
* chartData for older files that lack per-package data.
*
* Registry and task-runner variations always use total-time data.
*/
export function extractDayData(
response: ChartDataResponse,
): Record<string, Record<string, number>> {
const result: Record<string, Record<string, number>> = {};

// Use per-package data for package-management variations when available,
// otherwise fall back to total-time chartData
const pmSource =
response.perPackageCountChartData?.data ?? response.chartData.data;
extractFromDataSet(pmSource, result);

// Process registry chart data. Use registryPerPackageCountChartData when
// available (normalized ms/pkg values); fall back to total-time
// registryChartData for older files that lack per-package registry data.
const registrySource =
response.registryPerPackageCountChartData?.data ??
response.registryChartData?.data;
if (registrySource) {
extractFromDataSet(registrySource, result);
}

return result;
}

function extractFromDataSet(
data: Record<
string,
Array<Record<string, number | string | boolean> & { fixture: string }>
>,
result: Record<string, Record<string, number>>,
): void {
for (const [variation, fixtures] of Object.entries(data)) {
if (!Array.isArray(fixtures) || fixtures.length === 0) continue;

const pmTotals: Record<string, { sum: number; count: number }> = {};
const partialPMs = new Set(
PACKAGE_MANAGERS.filter((pm) =>
fixtures.some((fixture) => fixture[`${pm}_partial`] === true),
),
);

for (const fixture of fixtures) {
for (const pm of PACKAGE_MANAGERS) {
// Never splice legacy mean points into a median series. Reprocessing
// dated raw files adds median metadata and restores those dates.
if (
partialPMs.has(pm) ||
fixture[`${pm}_statistic`] !== "median" ||
fixture[`${pm}_dnf`] === true ||
fixture[`${pm}_partial`] === true
)
continue;
const val = fixture[pm];
if (typeof val === "number" && Number.isFinite(val)) {
if (!pmTotals[pm]) pmTotals[pm] = { sum: 0, count: 0 };
pmTotals[pm].sum += val;
pmTotals[pm].count++;
}
}
}

const pmAverages: Record<string, number> = {};
for (const [pm, { sum, count }] of Object.entries(pmTotals)) {
pmAverages[pm] = Math.round((sum / count) * 1000) / 1000;
}

if (Object.keys(pmAverages).length > 0) {
result[variation] = pmAverages;
}
}
}

// Preserve the reason a daily value is missing when building category averages.
// Otherwise an average could silently use only the variations that succeeded.
export function hasPartialResult(
response: ChartDataResponse,
variations: string[],
pm: string,
): boolean {
return [
response.chartData,
response.perPackageCountChartData,
response.registryChartData,
response.registryPerPackageCountChartData,
].some((source) =>
variations.some((variation) =>
source?.data[variation]?.some(
(fixture) => fixture[`${pm}_partial`] === true,
),
),
);
}

export const useHistoryData = (): UseHistoryDataReturn => {
const [historyData, setHistoryData] = useState<HistoryData | null>(null);
const [loading, setLoading] = useState(true);
Expand Down
166 changes: 166 additions & 0 deletions app/src/lib/history-data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
export const PACKAGE_MANAGERS = [
"npm",
"yarn",
"pnpm",
"pacquet",
"berry",
"zpm",
"deno",
"bun",
"vlt",
"aube",
"nx",
"turbo",
"vp",
"node",
"aws",
"cloudsmith",
"github",
"jfrog",
];

type FixtureDataSet = Record<
string,
Array<Record<string, number | string | boolean> & { fixture: string }>
>;

export interface ChartDataResponse {
date: string;
chartData: {
variations: string[];
data: FixtureDataSet;
packageManagers: string[];
};
perPackageCountChartData?: {
variations: string[];
data: FixtureDataSet;
packageManagers: string[];
};
registryChartData?: {
variations: string[];
data: FixtureDataSet;
packageManagers: string[];
};
registryPerPackageCountChartData?: {
variations: string[];
data: FixtureDataSet;
packageManagers: string[];
};
}

/**
* Extract per-PM averages (across fixtures) for each variation from a single
* day's chart-data.json response.
*
* For package-management variations (clean, cache, lockfile, etc.) we use
* perPackageCountChartData when available — these values are already in ms/pkg
* and match what the leaderboard cards display. Falls back to total-time
* chartData for older files that lack per-package data.
*
* Registry variations use total-time data, including legacy totals in chartData.
* Per-package-only registry history is omitted rather than mislabeled as seconds.
*/
export function extractDayData(
response: ChartDataResponse,
): Record<string, Record<string, number>> {
const result: Record<string, Record<string, number>> = {};

// Use per-package data for package-management variations when available,
// otherwise fall back to total-time chartData
const pmSource =
response.perPackageCountChartData?.data ?? response.chartData?.data ?? {};
extractFromDataSet(
Object.fromEntries(
Object.entries(pmSource).filter(
([variation]) => !variation.startsWith("registry-"),
),
),
result,
);

// Registry history is labeled in seconds. Older files may contain registry
// totals in chartData rather than a dedicated registryChartData dataset.
// Normalized ms/package data cannot serve as a seconds fallback.
const registrySource = {
...response.chartData?.data,
...response.registryChartData?.data,
};
extractFromDataSet(
Object.fromEntries(
Object.entries(registrySource).filter(([variation]) =>
variation.startsWith("registry-"),
),
),
result,
);

return result;
}

function extractFromDataSet(
data: Record<
string,
Array<Record<string, number | string | boolean> & { fixture: string }>
>,
result: Record<string, Record<string, number>>,
): void {
for (const [variation, fixtures] of Object.entries(data)) {
if (!Array.isArray(fixtures) || fixtures.length === 0) continue;

const pmTotals: Record<string, { sum: number; count: number }> = {};
const partialPMs = new Set(
PACKAGE_MANAGERS.filter((pm) =>
fixtures.some((fixture) => fixture[`${pm}_partial`] === true),
),
);

for (const fixture of fixtures) {
for (const pm of PACKAGE_MANAGERS) {
// Never splice legacy mean points into a median series. Reprocessing
// dated raw files adds median metadata and restores those dates.
if (
partialPMs.has(pm) ||
fixture[`${pm}_statistic`] !== "median" ||
fixture[`${pm}_dnf`] === true ||
fixture[`${pm}_partial`] === true
)
continue;
const val = fixture[pm];
if (typeof val === "number" && Number.isFinite(val)) {
if (!pmTotals[pm]) pmTotals[pm] = { sum: 0, count: 0 };
pmTotals[pm].sum += val;
pmTotals[pm].count++;
}
}
}

const pmAverages: Record<string, number> = {};
for (const [pm, { sum, count }] of Object.entries(pmTotals)) {
pmAverages[pm] = Math.round((sum / count) * 1000) / 1000;
}

if (Object.keys(pmAverages).length > 0) {
result[variation] = pmAverages;
}
}
}

// Preserve the reason a daily value is missing when building category averages.
// Otherwise an average could silently use only the variations that succeeded.
export function hasPartialResult(
response: ChartDataResponse,
variations: string[],
pm: string,
): boolean {
return variations.some((variation) => {
// Use the same source as extractDayData: unused normalized registry rows
// must not change whether the seconds-only category average is eligible.
const rows = variation.startsWith("registry-")
? (response.registryChartData?.data[variation] ??
response.chartData?.data[variation])
: (response.perPackageCountChartData?.data ?? response.chartData?.data)?.[
variation
];
return rows?.some((fixture) => fixture[`${pm}_partial`] === true);
});
}
2 changes: 1 addition & 1 deletion app/tests/partial-results.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
import {
extractDayData,
hasPartialResult,
} from "../src/hooks/use-history-data.ts";
} from "../src/lib/history-data.ts";

const complete = {
fixture: "next",
Expand Down
2 changes: 1 addition & 1 deletion app/tests/statistics.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import test from "node:test";
import { extractDayData } from "../src/hooks/use-history-data.ts";
import { extractDayData } from "../src/lib/history-data.ts";
import {
calculateAverageVariationData,
calculateLeaderboard,
Expand Down
Loading