From 80d5e886fb39c77fd532c42df73a38e645348439 Mon Sep 17 00:00:00 2001 From: graphieros Date: Fri, 1 May 2026 11:01:35 +0200 Subject: [PATCH 01/35] chore: add translations --- i18n/locales/en.json | 10 +++++++++- i18n/locales/fr-FR.json | 10 +++++++++- i18n/schema.json | 24 ++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 6851ed5511..8e312fe212 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -587,7 +587,15 @@ "trusted_publisher_added": "Trusted publishing enabled", "trusted_publisher_removed": "Trusted publishing removed", "provenance_added": "Provenance enabled", - "provenance_removed": "Provenance removed" + "provenance_removed": "Provenance removed", + "chart": { + "base_scale": "Start y-axis at zero", + "copy_alt": { + "version_events": "version {version}: {events}", + "key_changes": "Key changes: {version_events}.", + "general_description": "Line chart showing the {metric} of the {package} package, from version {first} to {last}. The {metric} in version {first} is {first_value}, in version {last} is {last_value} ({overall_progress_percentage}% overall). {key_changes} {watermark}." + } + } }, "dependencies": { "title": "Dependency ({count}) | Dependencies ({count})", diff --git a/i18n/locales/fr-FR.json b/i18n/locales/fr-FR.json index 0e40fa415b..b14f02e9a5 100644 --- a/i18n/locales/fr-FR.json +++ b/i18n/locales/fr-FR.json @@ -575,7 +575,15 @@ "trusted_publisher_added": "Vérification ajoutée", "trusted_publisher_removed": "Vérification enlevée", "provenance_added": "Preuve de provenance ajoutée", - "provenance_removed": "Preuve de provenance enlevée" + "provenance_removed": "Preuve de provenance enlevée", + "chart": { + "base_scale": "Positionner les ordonnées à zéro", + "copy_alt": { + "version_events": "version {version}: {events}", + "key_changes": "Principaux changements: {version_events}", + "general_description": "Graphique en ligne montrant la métrique {metric} pour le paquet {package}, depuis la version {first} à {last}. La valeur de la métrique {metric} pour la version {first} est {first_value}, et {last_value} pour la version {last} ({overall_progress_percentage}% overall). {key_changes} {watermark}." + } + } }, "dependencies": { "title": "Dépendances ({count})", diff --git a/i18n/schema.json b/i18n/schema.json index 7b6a3b3945..a3a00bc311 100644 --- a/i18n/schema.json +++ b/i18n/schema.json @@ -1767,6 +1767,30 @@ }, "provenance_removed": { "type": "string" + }, + "chart": { + "type": "object", + "properties": { + "base_scale": { + "type": "string" + }, + "copy_alt": { + "type": "object", + "properties": { + "key_changes": { + "type": "string" + }, + "version_events": { + "type": "string" + }, + "general_description": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false } }, "additionalProperties": false From 2fca3d6fda4361afb61b88387b2bf64ed2ac83cd Mon Sep 17 00:00:00 2001 From: graphieros Date: Fri, 1 May 2026 11:03:52 +0200 Subject: [PATCH 02/35] feat: add timeline chart --- app/components/Package/TimelineChart.vue | 805 ++++++++++++++++++ .../[[org]]/[packageName].vue | 26 +- app/utils/charts.ts | 88 ++ server/api/registry/timeline/[...pkg].get.ts | 7 + 4 files changed, 917 insertions(+), 9 deletions(-) create mode 100644 app/components/Package/TimelineChart.vue diff --git a/app/components/Package/TimelineChart.vue b/app/components/Package/TimelineChart.vue new file mode 100644 index 0000000000..d6875d094a --- /dev/null +++ b/app/components/Package/TimelineChart.vue @@ -0,0 +1,805 @@ + + + + + diff --git a/app/pages/package-timeline/[[org]]/[packageName].vue b/app/pages/package-timeline/[[org]]/[packageName].vue index bb9f577eb0..c0b428185b 100644 --- a/app/pages/package-timeline/[[org]]/[packageName].vue +++ b/app/pages/package-timeline/[[org]]/[packageName].vue @@ -4,6 +4,7 @@ import { compare } from 'semver' import type { TimelineResponse, TimelineVersion, + SubEvent, } from '~~/server/api/registry/timeline/[...pkg].get' import type { TimelineSizeResponse } from '~~/server/api/registry/timeline/sizes/[...pkg].get' @@ -143,13 +144,6 @@ if (import.meta.client) { const bytesFormatter = useBytesFormatter() -interface SubEvent { - key: string - positive: boolean - icon: string - text: string -} - // Detect notable changes between consecutive versions (size, license, ESM, types) // Versions are compared against their semver predecessor, not chronological neighbor, // so interleaved legacy releases don't produce misleading cross-line diffs. @@ -308,6 +302,8 @@ const versionSubEvents = computed(() => { return result }) +const selectedVersion = shallowRef(null) + useSeoMeta({ title: () => `Timeline - ${packageName.value} - npmx`, description: () => `Version timeline for ${packageName.value}`, @@ -325,6 +321,14 @@ useSeoMeta({ page="timeline" /> +
+
+
+ +
+
+
+
@@ -333,7 +337,7 @@ useSeoMeta({
    -
  1. +
  2. {{ entry.version }} @@ -371,7 +379,7 @@ useSeoMeta({ class="relative border-s border-border/50 ms-3 mt-2" >
  3. diff --git a/app/utils/charts.ts b/app/utils/charts.ts index 3cdbe3fd34..d6a69c3126 100644 --- a/app/utils/charts.ts +++ b/app/utils/charts.ts @@ -9,6 +9,7 @@ import type { VueUiXyDatasetLineItem, } from 'vue-data-ui' import type { ChartTimeGranularity } from '~/types/chart' +import type { SubEvent } from '~~/server/api/registry/timeline/[...pkg].get' export function sum(numbers: number[]): number { return numbers.reduce((a, b) => a + b, 0) @@ -451,6 +452,37 @@ export type FacetBarChartConfig = VueUiHorizontalBarConfig & { $t: TrendTranslateFunction } +export type TimelineSizeCacheValue = { + totalSize: number + dependencyCount: number +} + +export type ConvertedTimelineSizeCacheEntry = TimelineSizeCacheValue & { + name: string +} + +export type EnrichedTimelineSizeCacheEntry = ConvertedTimelineSizeCacheEntry & { + version: string + time?: string + license?: string + type?: string + hasTypes?: boolean + hasTrustedPublisher?: boolean + hasProvenance?: boolean + tags: string[] + events: SubEvent[] + hasPositive: boolean + hasNegative: boolean +} + +export type TimelineChartConfig = VueUiXyConfig & { + metric: 'totalSize' | 'dependencyCount' + packageName: string + copy: (text: string) => Promise + $t: TrendTranslateFunction + numberFormatter: (value: number) => string +} + // Used for TrendsChart.vue export function createAltTextForTrendLineChart({ dataset, @@ -705,6 +737,62 @@ export async function copyAltTextForCompareScatterChart({ await config.copy(altText) } +// Used for TimelineChart.vue +export function createAltTextForTimelineChart({ + dataset, + config, +}: AltCopyArgs) { + if (!dataset) return '' + const metric = + config.metric === 'totalSize' + ? config.$t('package.stats.install_size') + : config.$t('compare.dependencies') + const withEvents = dataset.filter(d => d.events.length) + const first = dataset[0] + const last = dataset.at(-1) + const firstValue = config.metric === 'totalSize' ? first?.totalSize : first?.dependencyCount + const lastValue = config.metric === 'totalSize' ? last?.totalSize : last?.dependencyCount + const overall_progress_percentage = Math.round(((lastValue ?? 0) / (firstValue ?? 1) - 1) * 100) + + const version_events = withEvents + .map(item => + config.$t('package.timeline.chart.copy_alt.version_events', { + version: item.version, + // eslint-disable-next-line @intlify/vue-i18n/no-dynamic-keys + events: item.events.map(e => config.$t(e.text).toLocaleLowerCase()).join(', '), + }), + ) + .join('; ') + + const key_changes = !withEvents.length + ? '' + : config.$t('package.timeline.chart.copy_alt.key_changes', { + version_events, + }) + + const altText = config.$t('package.timeline.chart.copy_alt.general_description', { + metric: metric.toLocaleLowerCase(), + package: config.packageName, + first: first?.version ?? '', + last: last?.version ?? '', + first_value: config.numberFormatter(firstValue ?? 0), + last_value: config.numberFormatter(lastValue ?? 0), + overall_progress_percentage, + key_changes, + watermark: config.$t('package.trends.copy_alt.watermark'), + }) + + return altText +} + +export async function copyAltTextForTimelineChart({ + dataset, + config, +}: AltCopyArgs) { + const altText = createAltTextForTimelineChart({ dataset, config }) + await config.copy(altText) +} + // Used in chart context menu callbacks // @todo replace with downloadFileLink export function loadFile(link: string, filename: string) { diff --git a/server/api/registry/timeline/[...pkg].get.ts b/server/api/registry/timeline/[...pkg].get.ts index 05ffbecdf6..aa4455513f 100644 --- a/server/api/registry/timeline/[...pkg].get.ts +++ b/server/api/registry/timeline/[...pkg].get.ts @@ -18,6 +18,13 @@ export interface TimelineResponse { total: number } +export interface SubEvent { + key: string + positive: boolean + icon: string + text: string +} + /** * Returns paginated version timeline data for a package. * From 96953f6f9fc34cc63b77ebb7d4ca9b574eadb034 Mon Sep 17 00:00:00 2001 From: graphieros Date: Fri, 1 May 2026 11:04:02 +0200 Subject: [PATCH 03/35] chore: add tests --- test/nuxt/a11y.spec.ts | 16 ++++++ test/unit/app/utils/charts.spec.ts | 91 ++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+) diff --git a/test/nuxt/a11y.spec.ts b/test/nuxt/a11y.spec.ts index 901c85e562..14eb6f7b97 100644 --- a/test/nuxt/a11y.spec.ts +++ b/test/nuxt/a11y.spec.ts @@ -268,6 +268,7 @@ import SearchProviderToggleServer from '~/components/SearchProviderToggle.server import PackageTrendsChart from '~/components/Package/TrendsChart.vue' import FacetBarChart from '~/components/Compare/FacetBarChart.vue' import FacetScatterChart from '~/components/Compare/FacetScatterChart.vue' +import PackageTimelineChart from '~/components/Package/TimelineChart.vue' import PackageLikeCard from '~/components/Package/LikeCard.vue' import SizeIncrease from '~/components/Package/SizeIncrease.vue' import SizeDecrease from '~/components/Package/SizeDecrease.vue' @@ -1000,6 +1001,21 @@ describe('component accessibility audits', () => { expect(results.violations).toEqual([]) }) + describe('PackageTimelineChart', () => { + it('should have no accessibility violations', async () => { + const wrapper = await mountSuspended(PackageTimelineChart, { + props: { + sizeCache: new Map(), + versionSubEvents: new Map(), + timelineEntries: [], + selectedVersion: null, + }, + }) + const results = await runAxe(wrapper) + expect(results.violations).toEqual([]) + }) + }) + describe('FacetBarChart', () => { it('should have no accessibility violations', async () => { const wrapper = await mountSuspended(FacetBarChart, { diff --git a/test/unit/app/utils/charts.spec.ts b/test/unit/app/utils/charts.spec.ts index 72c3d6c998..ae2ff47593 100644 --- a/test/unit/app/utils/charts.spec.ts +++ b/test/unit/app/utils/charts.spec.ts @@ -11,6 +11,8 @@ import { copyAltTextForTrendLineChart, createAltTextForVersionsBarChart, copyAltTextForVersionsBarChart, + createAltTextForTimelineChart, + copyAltTextForTimelineChart, loadFile, sanitise, insertLineBreaks, @@ -19,6 +21,8 @@ import { type TrendLineDataset, type VersionsBarConfig, type VersionsBarDataset, + type TimelineChartConfig, + type EnrichedTimelineSizeCacheEntry, } from '~/utils/charts' import type { AltCopyArgs } from 'vue-data-ui' @@ -35,6 +39,19 @@ function createTranslateMock() { return { translate, calls } } +function createTimelineConfig(overrides: Partial = {}): TimelineChartConfig { + const { translate } = createTranslateMock() + const config: TimelineChartConfig = { + numberFormatter: (value: number) => `nf${value}`, + packageName: 'nuxt', + metric: 'totalSize', + copy: vi.fn(async () => undefined), + $t: translate, + } as unknown as TimelineChartConfig + + return { ...config, ...overrides } +} + function createTrendLineConfig(overrides: Partial = {}): TrendLineConfig { const { translate } = createTranslateMock() @@ -1187,6 +1204,80 @@ describe('copyAltTextForVersionsBarChart', () => { }) }) +const timelineDataset = [ + { + dependencyCount: 100, + events: [], + version: '4.0.0', + totalSize: 120_000_000, + }, + { + dependencyCount: 80, + events: [], + version: '4.0.1', + totalSize: 115_000_000, + }, +] as unknown as EnrichedTimelineSizeCacheEntry[] + +describe('createAltTextForTimelineChart', () => { + it('handles empty dataset without throwing', () => { + const { translate } = createTranslateMock() + const config = createTimelineConfig({ $t: translate }) + + expect(() => + createAltTextForTimelineChart({ + dataset: [], + config, + } as AltCopyArgs), + ).not.toThrow() + }) + + it('returns empty string when dataset is null', () => { + const translateMock = createTranslateMock() + const config = createTimelineConfig({ $t: translateMock.translate }) + + const result = createAltTextForTimelineChart({ + dataset: null, + config, + } as unknown as AltCopyArgs) + + expect(result).toBe('') + expect(translateMock.calls).toHaveLength(0) + }) + + it('returns an alt text', () => { + const translateMock = createTranslateMock() + const config = createTimelineConfig({ $t: translateMock.translate }) + + const result = createAltTextForTimelineChart({ + dataset: timelineDataset, + config, + } as unknown as AltCopyArgs) + + expect(result).toBe('t:package.timeline.chart.copy_alt.general_description') + expect(translateMock.calls).toHaveLength(3) + }) +}) + +describe('copyAltTextForTimelineChart', () => { + it('forwards createAltTextForTimelineChart result to config.copy', async () => { + const copyMock = vi.fn(async () => undefined) + const config = createTimelineConfig({ copy: copyMock }) + const expected = createAltTextForTimelineChart({ + dataset: timelineDataset, + config, + }) + + await copyAltTextForTimelineChart({ + dataset: timelineDataset, + config, + } as AltCopyArgs) + + expect(copyMock).toHaveBeenCalledTimes(1) + expect(copyMock).toHaveBeenCalledWith(expected) + }) +}) + describe('loadFile', () => { let createElementMock: ReturnType let clickMock: ReturnType From 9e7a26623f998fd0b8d23309e3944f4a04f667c0 Mon Sep 17 00:00:00 2001 From: graphieros Date: Fri, 1 May 2026 11:15:49 +0200 Subject: [PATCH 04/35] fix: remove unused iterators --- app/pages/package-timeline/[[org]]/[packageName].vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/pages/package-timeline/[[org]]/[packageName].vue b/app/pages/package-timeline/[[org]]/[packageName].vue index c0b428185b..a4c3c0c883 100644 --- a/app/pages/package-timeline/[[org]]/[packageName].vue +++ b/app/pages/package-timeline/[[org]]/[packageName].vue @@ -337,7 +337,7 @@ useSeoMeta({
      -
    1. +
    2. From e4815da5c28de2a59122d0676544de42fb74dd96 Mon Sep 17 00:00:00 2001 From: graphieros Date: Fri, 1 May 2026 11:18:47 +0200 Subject: [PATCH 05/35] fix: translation keys ordering --- i18n/locales/en.json | 2 +- i18n/locales/fr-FR.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 8e312fe212..23da1b8f86 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -591,8 +591,8 @@ "chart": { "base_scale": "Start y-axis at zero", "copy_alt": { - "version_events": "version {version}: {events}", "key_changes": "Key changes: {version_events}.", + "version_events": "version {version}: {events}", "general_description": "Line chart showing the {metric} of the {package} package, from version {first} to {last}. The {metric} in version {first} is {first_value}, in version {last} is {last_value} ({overall_progress_percentage}% overall). {key_changes} {watermark}." } } diff --git a/i18n/locales/fr-FR.json b/i18n/locales/fr-FR.json index b14f02e9a5..16ca327f04 100644 --- a/i18n/locales/fr-FR.json +++ b/i18n/locales/fr-FR.json @@ -579,8 +579,8 @@ "chart": { "base_scale": "Positionner les ordonnées à zéro", "copy_alt": { - "version_events": "version {version}: {events}", "key_changes": "Principaux changements: {version_events}", + "version_events": "version {version}: {events}", "general_description": "Graphique en ligne montrant la métrique {metric} pour le paquet {package}, depuis la version {first} à {last}. La valeur de la métrique {metric} pour la version {first} est {first_value}, et {last_value} pour la version {last} ({overall_progress_percentage}% overall). {key_changes} {watermark}." } } From 256a4574a4e302f32e51d3316251fe8ffac7876d Mon Sep 17 00:00:00 2001 From: graphieros Date: Fri, 1 May 2026 11:35:45 +0200 Subject: [PATCH 06/35] fix: various --- app/components/Package/TimelineChart.vue | 77 +++++++++++------------- app/composables/useSettings.ts | 6 ++ app/utils/charts.ts | 3 + i18n/locales/fr-FR.json | 2 +- 4 files changed, 44 insertions(+), 44 deletions(-) diff --git a/app/components/Package/TimelineChart.vue b/app/components/Package/TimelineChart.vue index d6875d094a..653ecc0bde 100644 --- a/app/components/Package/TimelineChart.vue +++ b/app/components/Package/TimelineChart.vue @@ -28,6 +28,7 @@ const props = defineProps<{ selectedVersion: string | null }>() +const { settings } = useSettings() const route = useRoute('timeline') const activeVersion = computed(() => route.params.version) @@ -36,43 +37,6 @@ const packageName = computed(() => route.params.org ? `${route.params.org}/${route.params.packageName}` : route.params.packageName, ) -function addTimelineEntries( - entries: ConvertedTimelineSizeCacheEntry[], - timelineEntries: TimelineVersion[], -): EnrichedTimelineSizeCacheEntry[] { - const timelineEntryByVersion = new Map(timelineEntries.map(entry => [entry.version, entry])) - - return entries.map(entry => { - const version = entry.name.split('@')[1] ?? '' - const timelineEntry = timelineEntryByVersion.get(version) - - return { - ...entry, - version, - time: timelineEntry?.time, - license: timelineEntry?.license, - type: timelineEntry?.type, - hasTypes: timelineEntry?.hasTypes, - hasTrustedPublisher: timelineEntry?.hasTrustedPublisher, - hasProvenance: timelineEntry?.hasProvenance, - tags: timelineEntry?.tags ?? [], - events: [], - hasPositive: false, - hasNegative: false, - } - }) -} - -function convertMapEntries( - entries: Array<{ key: string; value: TimelineSizeCacheValue }>, -): ConvertedTimelineSizeCacheEntry[] { - return entries.map(({ key, value }) => ({ - name: key, - totalSize: value.totalSize, - dependencyCount: value.dependencyCount, - })) -} - function addEvaluationFlags( entries: EnrichedTimelineSizeCacheEntry[], versionSubEvents: Map, @@ -90,9 +54,33 @@ function addEvaluationFlags( } const convertedData = computed(() => { - const base = convertMapEntries(Array.from(props.sizeCache, ([key, value]) => ({ key, value }))) - const withTimelineEntries = addTimelineEntries(base, props.timelineEntries) - return addEvaluationFlags(withTimelineEntries, props.versionSubEvents).toReversed() + const entries = props.timelineEntries.flatMap(timelineEntry => { + const key = `${packageName.value}@${timelineEntry.version}` + const value = props.sizeCache.get(key) + + if (!value) { + return [] + } + + return { + name: key, + totalSize: value.totalSize, + dependencyCount: value.dependencyCount, + version: timelineEntry.version, + time: timelineEntry.time, + license: timelineEntry.license, + type: timelineEntry.type, + hasTypes: timelineEntry.hasTypes, + hasTrustedPublisher: timelineEntry.hasTrustedPublisher, + hasProvenance: timelineEntry.hasProvenance, + tags: timelineEntry.tags ?? [], + events: [], + hasPositive: false, + hasNegative: false, + } + }) + + return addEvaluationFlags(entries, props.versionSubEvents).toReversed() }) const versions = computed(() => convertedData.value.map(d => d.name.split('@')[1] ?? '')) @@ -230,7 +218,7 @@ const watermarkColors = computed(() => ({ const mobileBreakpointWidth = 640 const isMobile = computed(() => width.value > 0 && width.value < mobileBreakpointWidth) -const isZeroBase = shallowRef(false) + const commonScaleSteps = computed(() => { if (activeTab.value === 'totalSize') { return seriesTotalSize.value.max - seriesTotalSize.value.min > 5 ? 6 : 3 @@ -283,7 +271,7 @@ const config = computed(() => { formatter: ({ value }) => { return formatter.value.format(value ?? 0) }, - scaleMin: isZeroBase.value + scaleMin: settings.value.timelineChart.isZeroBased ? 0 : activeTab.value === 'totalSize' ? seriesTotalSize.value.min @@ -478,7 +466,10 @@ const indexSelection = computed(() => { - +
diff --git a/app/composables/useSettings.ts b/app/composables/useSettings.ts index 1fc879a0a4..4f9963316e 100644 --- a/app/composables/useSettings.ts +++ b/app/composables/useSettings.ts @@ -50,6 +50,9 @@ export interface AppSettings { anomaliesFixed: boolean predictionPoints: number } + timelineChart: { + isZeroBased: boolean + } } const DEFAULT_SETTINGS: AppSettings = { @@ -77,6 +80,9 @@ const DEFAULT_SETTINGS: AppSettings = { anomaliesFixed: true, predictionPoints: 4, }, + timelineChart: { + isZeroBased: false, + }, } const STORAGE_KEY = 'npmx-settings' diff --git a/app/utils/charts.ts b/app/utils/charts.ts index d6a69c3126..0d0c5b1a5b 100644 --- a/app/utils/charts.ts +++ b/app/utils/charts.ts @@ -750,6 +750,9 @@ export function createAltTextForTimelineChart({ const withEvents = dataset.filter(d => d.events.length) const first = dataset[0] const last = dataset.at(-1) + + if (!first || !last) return '' + const firstValue = config.metric === 'totalSize' ? first?.totalSize : first?.dependencyCount const lastValue = config.metric === 'totalSize' ? last?.totalSize : last?.dependencyCount const overall_progress_percentage = Math.round(((lastValue ?? 0) / (firstValue ?? 1) - 1) * 100) diff --git a/i18n/locales/fr-FR.json b/i18n/locales/fr-FR.json index 16ca327f04..bb54fb88a1 100644 --- a/i18n/locales/fr-FR.json +++ b/i18n/locales/fr-FR.json @@ -581,7 +581,7 @@ "copy_alt": { "key_changes": "Principaux changements: {version_events}", "version_events": "version {version}: {events}", - "general_description": "Graphique en ligne montrant la métrique {metric} pour le paquet {package}, depuis la version {first} à {last}. La valeur de la métrique {metric} pour la version {first} est {first_value}, et {last_value} pour la version {last} ({overall_progress_percentage}% overall). {key_changes} {watermark}." + "general_description": "Graphique en ligne montrant la métrique {metric} pour le paquet {package}, depuis la version {first} à {last}. La valeur de la métrique {metric} pour la version {first} est {first_value}, et {last_value} pour la version {last} ({overall_progress_percentage}% dans l'ensemble). {key_changes} {watermark}." } } }, From cfa22202d555d5954bf06686821c8eca4816f73b Mon Sep 17 00:00:00 2001 From: graphieros Date: Fri, 1 May 2026 11:38:57 +0200 Subject: [PATCH 07/35] fix: various --- app/components/Package/TimelineChart.vue | 1 - app/pages/package-timeline/[[org]]/[packageName].vue | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/app/components/Package/TimelineChart.vue b/app/components/Package/TimelineChart.vue index 653ecc0bde..d61397e992 100644 --- a/app/components/Package/TimelineChart.vue +++ b/app/components/Package/TimelineChart.vue @@ -13,7 +13,6 @@ import { loadFile, applyEllipsis, copyAltTextForTimelineChart, - type ConvertedTimelineSizeCacheEntry, type EnrichedTimelineSizeCacheEntry, type TimelineSizeCacheValue, } from '~/utils/charts' diff --git a/app/pages/package-timeline/[[org]]/[packageName].vue b/app/pages/package-timeline/[[org]]/[packageName].vue index a4c3c0c883..8c0f68f95f 100644 --- a/app/pages/package-timeline/[[org]]/[packageName].vue +++ b/app/pages/package-timeline/[[org]]/[packageName].vue @@ -321,7 +321,7 @@ useSeoMeta({ page="timeline" /> -
+
From 0d1589b26f7ba3edb64a963056bb51a0d7e64f45 Mon Sep 17 00:00:00 2001 From: graphieros Date: Fri, 1 May 2026 11:43:37 +0200 Subject: [PATCH 08/35] fix: add missing translation --- app/components/Package/TimelineChart.vue | 2 +- i18n/locales/en.json | 1 + i18n/locales/fr-FR.json | 1 + i18n/schema.json | 3 +++ 4 files changed, 6 insertions(+), 1 deletion(-) diff --git a/app/components/Package/TimelineChart.vue b/app/components/Package/TimelineChart.vue index d61397e992..c950af4e7c 100644 --- a/app/components/Package/TimelineChart.vue +++ b/app/components/Package/TimelineChart.vue @@ -451,7 +451,7 @@ const indexSelection = computed(() => {
- + {{ $t('package.stats.install_size') }} diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 23da1b8f86..1f5d570fd5 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -589,6 +589,7 @@ "provenance_added": "Provenance enabled", "provenance_removed": "Provenance removed", "chart": { + "tab_aria_label": "Metric selection", "base_scale": "Start y-axis at zero", "copy_alt": { "key_changes": "Key changes: {version_events}.", diff --git a/i18n/locales/fr-FR.json b/i18n/locales/fr-FR.json index bb54fb88a1..d64c7edbd9 100644 --- a/i18n/locales/fr-FR.json +++ b/i18n/locales/fr-FR.json @@ -577,6 +577,7 @@ "provenance_added": "Preuve de provenance ajoutée", "provenance_removed": "Preuve de provenance enlevée", "chart": { + "tab_aria_label": "Sélection de métrique", "base_scale": "Positionner les ordonnées à zéro", "copy_alt": { "key_changes": "Principaux changements: {version_events}", diff --git a/i18n/schema.json b/i18n/schema.json index a3a00bc311..b2f348177d 100644 --- a/i18n/schema.json +++ b/i18n/schema.json @@ -1771,6 +1771,9 @@ "chart": { "type": "object", "properties": { + "tab_aria_label": { + "type": "string" + }, "base_scale": { "type": "string" }, From e8dc35315f82b13c073497e68dd6685f4d70b683 Mon Sep 17 00:00:00 2001 From: graphieros Date: Fri, 1 May 2026 11:48:24 +0200 Subject: [PATCH 09/35] fix: remove tab ids --- app/components/Package/TimelineChart.vue | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/app/components/Package/TimelineChart.vue b/app/components/Package/TimelineChart.vue index c950af4e7c..5b262b4abd 100644 --- a/app/components/Package/TimelineChart.vue +++ b/app/components/Package/TimelineChart.vue @@ -452,14 +452,10 @@ const indexSelection = computed(() => {
- + {{ $t('package.stats.install_size') }} - + {{ $t('compare.dependencies') }} From 5b97c777e5635f7fdda146f5169118c3fa7d1751 Mon Sep 17 00:00:00 2001 From: graphieros Date: Fri, 1 May 2026 11:51:10 +0200 Subject: [PATCH 10/35] fix: follow the rabbit --- app/components/Package/TimelineChart.vue | 2 +- app/utils/charts.ts | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/app/components/Package/TimelineChart.vue b/app/components/Package/TimelineChart.vue index 5b262b4abd..ac131f836b 100644 --- a/app/components/Package/TimelineChart.vue +++ b/app/components/Package/TimelineChart.vue @@ -82,7 +82,7 @@ const convertedData = computed(() => { return addEvaluationFlags(entries, props.versionSubEvents).toReversed() }) -const versions = computed(() => convertedData.value.map(d => d.name.split('@')[1] ?? '')) +const versions = computed(() => convertedData.value.map(d => d.version)) const activeVersionIndex = computed(() => { if (!activeVersion.value) return -1 diff --git a/app/utils/charts.ts b/app/utils/charts.ts index 0d0c5b1a5b..7c5a86ff3b 100644 --- a/app/utils/charts.ts +++ b/app/utils/charts.ts @@ -755,7 +755,10 @@ export function createAltTextForTimelineChart({ const firstValue = config.metric === 'totalSize' ? first?.totalSize : first?.dependencyCount const lastValue = config.metric === 'totalSize' ? last?.totalSize : last?.dependencyCount - const overall_progress_percentage = Math.round(((lastValue ?? 0) / (firstValue ?? 1) - 1) * 100) + const baseline = firstValue ?? 0 + const current = lastValue ?? baseline + const overall_progress_percentage = + baseline > 0 ? Math.round(((current - baseline) / baseline) * 100) : 0 const version_events = withEvents .map(item => From dd3b6e6079ae8fcf968199a001ad577b9d3a61ca Mon Sep 17 00:00:00 2001 From: graphieros Date: Fri, 1 May 2026 11:59:38 +0200 Subject: [PATCH 11/35] fix: allow usage of tabs without panels --- app/components/Package/TimelineChart.vue | 6 +++--- app/components/Tab/Item.vue | 5 ++++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/app/components/Package/TimelineChart.vue b/app/components/Package/TimelineChart.vue index ac131f836b..10ad7b2d5c 100644 --- a/app/components/Package/TimelineChart.vue +++ b/app/components/Package/TimelineChart.vue @@ -450,12 +450,12 @@ const indexSelection = computed(() => { @@ -794,4 +844,25 @@ const indexSelection = computed(() => { background: var(--bg-elevated) !important; box-shadow: none !important; } + +/* Override default placement of the refresh button to have it to the minimap's side */ +@media screen and (min-width: 767px) { + :deep(.vue-data-ui-refresh-button) { + top: -0.6rem !important; + left: calc(100% + 4rem) !important; + } +} + +@keyframes indeterminate { + 0% { + translate: -100%; + } + 100% { + translate: 400%; + } +} + +.animate-indeterminate { + animation: indeterminate 1.5s ease-in-out infinite; +} diff --git a/app/composables/useSettings.ts b/app/composables/useSettings.ts index 4f9963316e..c5d5734ff6 100644 --- a/app/composables/useSettings.ts +++ b/app/composables/useSettings.ts @@ -52,6 +52,7 @@ export interface AppSettings { } timelineChart: { isZeroBased: boolean + showZoom: boolean } } @@ -82,6 +83,7 @@ const DEFAULT_SETTINGS: AppSettings = { }, timelineChart: { isZeroBased: false, + showZoom: false, }, } diff --git a/app/pages/package-timeline/[[org]]/[packageName].vue b/app/pages/package-timeline/[[org]]/[packageName].vue index d7f7d8319b..4fccfefac8 100644 --- a/app/pages/package-timeline/[[org]]/[packageName].vue +++ b/app/pages/package-timeline/[[org]]/[packageName].vue @@ -327,17 +327,18 @@ useSeoMeta({
- +
- -
-
-
-
  1. @@ -438,18 +439,3 @@ useSeoMeta({
- - diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 1f5d570fd5..5c9b4924d5 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -590,7 +590,8 @@ "provenance_removed": "Provenance removed", "chart": { "tab_aria_label": "Metric selection", - "base_scale": "Start y-axis at zero", + "base_scale": "start y-axis at zero", + "zoom": "zoom", "copy_alt": { "key_changes": "Key changes: {version_events}.", "version_events": "version {version}: {events}", diff --git a/i18n/locales/fr-FR.json b/i18n/locales/fr-FR.json index d64c7edbd9..ff965fd0a9 100644 --- a/i18n/locales/fr-FR.json +++ b/i18n/locales/fr-FR.json @@ -578,7 +578,8 @@ "provenance_removed": "Preuve de provenance enlevée", "chart": { "tab_aria_label": "Sélection de métrique", - "base_scale": "Positionner les ordonnées à zéro", + "base_scale": "positionner les ordonnées à zéro", + "zoom": "zoom", "copy_alt": { "key_changes": "Principaux changements: {version_events}", "version_events": "version {version}: {events}", diff --git a/i18n/schema.json b/i18n/schema.json index b2f348177d..f2c3470cbe 100644 --- a/i18n/schema.json +++ b/i18n/schema.json @@ -1777,6 +1777,9 @@ "base_scale": { "type": "string" }, + "zoom": { + "type": "string" + }, "copy_alt": { "type": "object", "properties": { From 21a8cb9e09fcadcf869020a4ae0e2de37b63d558 Mon Sep 17 00:00:00 2001 From: graphieros Date: Fri, 1 May 2026 13:28:14 +0200 Subject: [PATCH 15/35] fix: follow the rabbit --- app/components/Package/TimelineChart.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/components/Package/TimelineChart.vue b/app/components/Package/TimelineChart.vue index b1bcbebe3a..fb7c120510 100644 --- a/app/components/Package/TimelineChart.vue +++ b/app/components/Package/TimelineChart.vue @@ -93,7 +93,7 @@ const activeVersionIndex = computed(() => { const seriesTotalSize = computed(() => { const values = convertedData.value.map(d => d.totalSize) if (!values.length) { - return { values, min: 0, max: 1 } + return { values, min: 0, max: 0 } } return { values, @@ -105,7 +105,7 @@ const seriesTotalSize = computed(() => { const seriesDependencies = computed(() => { const values = convertedData.value.map(d => d.dependencyCount) if (!values.length) { - return { values, min: 0, max: 1 } + return { values, min: 0, max: 0 } } return { values, From b23b3b4d7171996fa5abc3d88f56e0ec9afb2184 Mon Sep 17 00:00:00 2001 From: graphieros Date: Fri, 1 May 2026 13:31:01 +0200 Subject: [PATCH 16/35] fix: add missing prop in component test --- test/nuxt/a11y.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/nuxt/a11y.spec.ts b/test/nuxt/a11y.spec.ts index 14eb6f7b97..ab71a34b43 100644 --- a/test/nuxt/a11y.spec.ts +++ b/test/nuxt/a11y.spec.ts @@ -1009,6 +1009,7 @@ describe('component accessibility audits', () => { versionSubEvents: new Map(), timelineEntries: [], selectedVersion: null, + loading: false, }, }) const results = await runAxe(wrapper) From d61877e98fce7f26c68e318a378ecfa6e3fe961d Mon Sep 17 00:00:00 2001 From: graphieros Date: Fri, 1 May 2026 15:00:57 +0200 Subject: [PATCH 17/35] chore: bump vue-data-ui from 3.18.2 to 3.18.3 --- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 27ed81df86..cf2560da74 100644 --- a/package.json +++ b/package.json @@ -109,7 +109,7 @@ "vite-plugin-pwa": "1.2.0", "vite-plus": "0.1.16", "vue": "3.5.33", - "vue-data-ui": "3.18.2", + "vue-data-ui": "3.18.3", "vue-router": "5.0.4" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ce31753f13..91e406c6c4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -249,8 +249,8 @@ importers: specifier: 3.5.33 version: 3.5.33(typescript@6.0.2) vue-data-ui: - specifier: 3.18.2 - version: 3.18.2(vue@3.5.33) + specifier: 3.18.3 + version: 3.18.3(vue@3.5.33) vue-router: specifier: 5.0.4 version: 5.0.4(@vue/compiler-sfc@3.5.33)(vue@3.5.33) @@ -11129,8 +11129,8 @@ packages: vue-component-type-helpers@3.2.7: resolution: {integrity: sha512-+gPp5YGmhfsj1IN+xUo7y0fb4clfnOiiUA39y07yW1VzCRjzVgwLbtmdWlghh7mXrPsEaYc7rrIir/HT6C8vYQ==} - vue-data-ui@3.18.2: - resolution: {integrity: sha512-BJP+YMrJeAdVnT2rmBsZBe+rHksReCHrzFM8MYXAgndgAdPJlzsLigylwflLhm9sndQeAt6ihCslX0VIU+nyUQ==} + vue-data-ui@3.18.3: + resolution: {integrity: sha512-pLOOh7jvZ5UEm1Y4CUu198d316DXvE6Mk/kP+zhtgMMCLKXMhLiAWLA0dabEvrLHSPRo1rWAzhOgwL2PqyqUeg==} peerDependencies: jspdf: '>=3.0.1' vue: '>=3.3.0' @@ -23836,7 +23836,7 @@ snapshots: vue-component-type-helpers@3.2.7: {} - vue-data-ui@3.18.2(vue@3.5.33): + vue-data-ui@3.18.3(vue@3.5.33): dependencies: vue: 3.5.33(typescript@6.0.2) From b6c5740e5ee437be2ffb54099670385fa16d0c30 Mon Sep 17 00:00:00 2001 From: graphieros Date: Fri, 1 May 2026 15:09:50 +0200 Subject: [PATCH 18/35] fix: always keep x-axis labels rotated --- app/components/Package/TimelineChart.vue | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/components/Package/TimelineChart.vue b/app/components/Package/TimelineChart.vue index fb7c120510..ba7abd4321 100644 --- a/app/components/Package/TimelineChart.vue +++ b/app/components/Package/TimelineChart.vue @@ -271,6 +271,8 @@ const config = computed(() => { modulo: 24, showOnlyAtModulo: versions.value.length > 24, values: versions.value.map(v => applyEllipsis(v, 20)), + rotation: -30, + autoRotate: false, }, yAxis: { commonScaleSteps: commonScaleSteps.value, From 7f0e1183233bc3f04c1944be5d9ebecc13919bbe Mon Sep 17 00:00:00 2001 From: graphieros Date: Fri, 1 May 2026 15:12:17 +0200 Subject: [PATCH 19/35] fix: always keep x-axis labels rotated --- app/components/Package/TimelineChart.vue | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/components/Package/TimelineChart.vue b/app/components/Package/TimelineChart.vue index ba7abd4321..3be10d13ad 100644 --- a/app/components/Package/TimelineChart.vue +++ b/app/components/Package/TimelineChart.vue @@ -272,7 +272,9 @@ const config = computed(() => { showOnlyAtModulo: versions.value.length > 24, values: versions.value.map(v => applyEllipsis(v, 20)), rotation: -30, - autoRotate: false, + autoRotate: { + enable: false, + }, }, yAxis: { commonScaleSteps: commonScaleSteps.value, From 7ebfcb7e58b9b1716bddb0c8b9e6b93e28c399bd Mon Sep 17 00:00:00 2001 From: graphieros Date: Fri, 1 May 2026 15:17:45 +0200 Subject: [PATCH 20/35] fix: set lower z-index for the chart container --- app/pages/package-timeline/[[org]]/[packageName].vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/pages/package-timeline/[[org]]/[packageName].vue b/app/pages/package-timeline/[[org]]/[packageName].vue index 4fccfefac8..a007efde15 100644 --- a/app/pages/package-timeline/[[org]]/[packageName].vue +++ b/app/pages/package-timeline/[[org]]/[packageName].vue @@ -324,7 +324,7 @@ useSeoMeta({ page="timeline" /> -
+
Date: Fri, 1 May 2026 15:34:34 +0200 Subject: [PATCH 21/35] fix: reduce x-axis labels font-size --- app/components/Package/TimelineChart.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/components/Package/TimelineChart.vue b/app/components/Package/TimelineChart.vue index 3be10d13ad..1b04634cf3 100644 --- a/app/components/Package/TimelineChart.vue +++ b/app/components/Package/TimelineChart.vue @@ -267,7 +267,7 @@ const config = computed(() => { }, xAxisLabels: { color: colors.value.fgSubtle, - fontSize: 12, + fontSize: isMobile.value ? 12 : 10, modulo: 24, showOnlyAtModulo: versions.value.length > 24, values: versions.value.map(v => applyEllipsis(v, 20)), @@ -627,7 +627,7 @@ const indexSelection = computed(() => { class="pointer-events-none" > Date: Fri, 1 May 2026 16:36:03 +0200 Subject: [PATCH 22/35] chore: bump vue-data-ui from 3.18.3 to 3.18.4 --- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index cf2560da74..a12a2e070c 100644 --- a/package.json +++ b/package.json @@ -109,7 +109,7 @@ "vite-plugin-pwa": "1.2.0", "vite-plus": "0.1.16", "vue": "3.5.33", - "vue-data-ui": "3.18.3", + "vue-data-ui": "3.18.4", "vue-router": "5.0.4" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 91e406c6c4..6681be42e4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -249,8 +249,8 @@ importers: specifier: 3.5.33 version: 3.5.33(typescript@6.0.2) vue-data-ui: - specifier: 3.18.3 - version: 3.18.3(vue@3.5.33) + specifier: 3.18.4 + version: 3.18.4(vue@3.5.33) vue-router: specifier: 5.0.4 version: 5.0.4(@vue/compiler-sfc@3.5.33)(vue@3.5.33) @@ -11129,8 +11129,8 @@ packages: vue-component-type-helpers@3.2.7: resolution: {integrity: sha512-+gPp5YGmhfsj1IN+xUo7y0fb4clfnOiiUA39y07yW1VzCRjzVgwLbtmdWlghh7mXrPsEaYc7rrIir/HT6C8vYQ==} - vue-data-ui@3.18.3: - resolution: {integrity: sha512-pLOOh7jvZ5UEm1Y4CUu198d316DXvE6Mk/kP+zhtgMMCLKXMhLiAWLA0dabEvrLHSPRo1rWAzhOgwL2PqyqUeg==} + vue-data-ui@3.18.4: + resolution: {integrity: sha512-gEgYCI6WGF2ogwpuItOJOeIDc1bxYzWWkWMltYEGyzXeREApClbFWlX7xH6GuBzUgftO0SqWxje7+qljX34QQQ==} peerDependencies: jspdf: '>=3.0.1' vue: '>=3.3.0' @@ -23836,7 +23836,7 @@ snapshots: vue-component-type-helpers@3.2.7: {} - vue-data-ui@3.18.3(vue@3.5.33): + vue-data-ui@3.18.4(vue@3.5.33): dependencies: vue: 3.5.33(typescript@6.0.2) From 46348633939418d4f724048ddbad94893dc5a797 Mon Sep 17 00:00:00 2001 From: graphieros Date: Fri, 1 May 2026 16:36:34 +0200 Subject: [PATCH 23/35] fix: impact zoom index offsets --- app/components/Package/TimelineChart.vue | 70 +++++++++++++++++------- 1 file changed, 49 insertions(+), 21 deletions(-) diff --git a/app/components/Package/TimelineChart.vue b/app/components/Package/TimelineChart.vue index 1b04634cf3..bf96667f5d 100644 --- a/app/components/Package/TimelineChart.vue +++ b/app/components/Package/TimelineChart.vue @@ -432,6 +432,7 @@ function getDatapointPlots( item: TimelineDatasetItem, predicate: (datapoint: TimelineSourceItem, index: number) => boolean, markerKey: string, + zoomOffset: number, ): TimelineMarkerItem[] { if (!item || !Array.isArray(item.source) || !Array.isArray(item.plots)) { return [] @@ -440,9 +441,10 @@ function getDatapointPlots( const timelineItem = item as TimelineSvgDataItem return timelineItem.source.flatMap((datapoint, index) => { - const plot = timelineItem.plots[index] + const plotIndex = index - zoomOffset + const plot = timelineItem.plots[plotIndex] - if (!plot || !predicate(datapoint, index)) { + if (plotIndex < 0 || !plot || !predicate(datapoint, index)) { return [] } @@ -461,16 +463,35 @@ function getDatapointPlots( }) } -function getLatestDatapointPlot(item: TimelineDatasetItem): TimelinePlotItem | null { - return item?.plots?.[activeVersionIndex.value] ?? null +function getActiveVersionDatapointPlot( + item: TimelineDatasetItem, + zoomOffset: number, +): TimelinePlotItem | null { + return item?.plots?.[activeVersionIndex.value - zoomOffset] ?? null } -function getPositiveDatapointPlots(item: TimelineDatasetItem): TimelineMarkerItem[] { - return getDatapointPlots(item, datapoint => datapoint.hasPositive === true, 'positive') +function getPositiveDatapointPlots( + item: TimelineDatasetItem, + zoomOffset: number, +): TimelineMarkerItem[] { + return getDatapointPlots( + item, + datapoint => datapoint.hasPositive === true, + 'positive', + zoomOffset, + ) } -function getNegativeDatapointPlots(item: TimelineDatasetItem): TimelineMarkerItem[] { - return getDatapointPlots(item, datapoint => datapoint.hasNegative === true, 'negative') +function getNegativeDatapointPlots( + item: TimelineDatasetItem, + zoomOffset: number, +): TimelineMarkerItem[] { + return getDatapointPlots( + item, + datapoint => datapoint.hasNegative === true, + 'negative', + zoomOffset, + ) } const indexSelection = computed(() => { @@ -507,13 +528,13 @@ const indexSelection = computed(() => { -