diff --git a/README.md b/README.md index cf5c539dc8..245c5e5990 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/app/package.json b/app/package.json index 65e2da43af..fb4f2e33f8 100644 --- a/app/package.json +++ b/app/package.json @@ -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", diff --git a/app/src/hooks/use-history-data.ts b/app/src/hooks/use-history-data.ts index 0578ea372e..c55e6a1989 100644 --- a/app/src/hooks/use-history-data.ts +++ b/app/src/hooks/use-history-data.ts @@ -1,5 +1,11 @@ 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; @@ -7,27 +13,6 @@ 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; @@ -67,139 +52,6 @@ async function parallelLimit( return results; } -type FixtureDataSet = Record< - string, - Array & { 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> { - const result: Record> = {}; - - // 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 & { fixture: string }> - >, - result: Record>, -): void { - for (const [variation, fixtures] of Object.entries(data)) { - if (!Array.isArray(fixtures) || fixtures.length === 0) continue; - - const pmTotals: Record = {}; - 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 = {}; - 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(null); const [loading, setLoading] = useState(true); diff --git a/app/src/lib/history-data.ts b/app/src/lib/history-data.ts new file mode 100644 index 0000000000..0ca34ffe44 --- /dev/null +++ b/app/src/lib/history-data.ts @@ -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 & { 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> { + const result: Record> = {}; + + // 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 & { fixture: string }> + >, + result: Record>, +): void { + for (const [variation, fixtures] of Object.entries(data)) { + if (!Array.isArray(fixtures) || fixtures.length === 0) continue; + + const pmTotals: Record = {}; + 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 = {}; + 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); + }); +} diff --git a/app/tests/partial-results.test.js b/app/tests/partial-results.test.js index fb04e5a317..0f9f4d2b48 100644 --- a/app/tests/partial-results.test.js +++ b/app/tests/partial-results.test.js @@ -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", diff --git a/app/tests/statistics.test.ts b/app/tests/statistics.test.ts index 94da043ca1..09f49468c8 100644 --- a/app/tests/statistics.test.ts +++ b/app/tests/statistics.test.ts @@ -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, diff --git a/scripts/history-data.test.js b/scripts/history-data.test.js new file mode 100644 index 0000000000..1d40ead6d1 --- /dev/null +++ b/scripts/history-data.test.js @@ -0,0 +1,118 @@ +const assert = require("node:assert/strict"); +const test = require("node:test"); +const { + extractDayData, + hasPartialResult, +} = require("../app/src/lib/history-data.ts"); + +const dataset = (data) => ({ + data: Object.fromEntries( + Object.entries(data).map(([variation, rows]) => [ + variation, + rows.map((row) => ({ + npm_statistic: "median", + vlt_statistic: "median", + ...row, + })), + ]), + ), + variations: Object.keys(data), + packageManagers: ["npm", "vlt"], +}); + +test("registry history prefers total seconds when both sources exist", () => { + const result = extractDayData({ + chartData: dataset({}), + registryChartData: dataset({ + "registry-clean": [{ fixture: "next", npm: 3, vlt: 2 }], + }), + registryPerPackageCountChartData: dataset({ + "registry-clean": [{ fixture: "next", npm: 90.18, vlt: 60.14 }], + }), + }); + assert.deepEqual(result["registry-clean"], { npm: 3, vlt: 2 }); +}); + +test("legacy totals in chartData remain in seconds even with normalized data", () => { + const result = extractDayData({ + chartData: dataset({ + "registry-clean": [{ fixture: "next", npm: 3, vlt: 2 }], + }), + perPackageCountChartData: dataset({ + "registry-clean": [{ fixture: "next", npm: 90.18, vlt: 60.14 }], + }), + registryPerPackageCountChartData: dataset({ + "registry-clean": [{ fixture: "next", npm: 90.18, vlt: 60.14 }], + }), + }); + assert.deepEqual(result["registry-clean"], { npm: 3, vlt: 2 }); +}); + +test("per-package-only registry history is skipped rather than mislabeled", () => { + const result = extractDayData({ + chartData: dataset({}), + perPackageCountChartData: dataset({ + "registry-clean": [{ fixture: "next", npm: 90.18 }], + }), + registryPerPackageCountChartData: dataset({ + "registry-clean": [{ fixture: "next", npm: 90.18 }], + }), + }); + assert.equal(result["registry-clean"], undefined); + assert.deepEqual(extractDayData({}), {}); +}); + +test("dedicated totals override legacy totals per variation", () => { + const result = extractDayData({ + chartData: dataset({ + "registry-clean": [{ fixture: "next", npm: 100 }], + "registry-lockfile": [{ fixture: "next", npm: 4 }], + }), + registryChartData: dataset({ + "registry-clean": [{ fixture: "next", npm: 3 }], + }), + }); + assert.deepEqual(result, { + "registry-clean": { npm: 3 }, + "registry-lockfile": { npm: 4 }, + }); +}); + +test("PM normalization and seconds aggregation across fixtures remain intact", () => { + const result = extractDayData({ + chartData: dataset({ clean: [{ fixture: "next", npm: 1 }] }), + perPackageCountChartData: dataset({ + clean: [{ fixture: "next", npm: 20 }], + }), + registryChartData: dataset({ + "registry-clean": [ + { fixture: "next", npm: 3, vlt: 2 }, + { fixture: "astro", npm: 5, vlt: 4 }, + ], + }), + }); + assert.deepEqual(result, { + clean: { npm: 20 }, + "registry-clean": { npm: 4, vlt: 3 }, + }); +}); + +test("registry median and completeness filters use the selected total-time source", () => { + const response = { + chartData: dataset({}), + registryChartData: dataset({ + "registry-clean": [{ fixture: "next", npm: 3, vlt: 2 }], + }), + registryPerPackageCountChartData: dataset({ + "registry-clean": [ + { fixture: "next", npm: 90, vlt: 60, npm_partial: true }, + ], + }), + }; + assert.equal(hasPartialResult(response, ["registry-clean"], "npm"), false); + response.registryChartData.data["registry-clean"][0].npm_partial = true; + assert.equal(hasPartialResult(response, ["registry-clean"], "npm"), true); + assert.deepEqual(extractDayData(response)["registry-clean"], { vlt: 2 }); + delete response.registryChartData.data["registry-clean"][0].vlt_statistic; + assert.equal(extractDayData(response)["registry-clean"], undefined); +});