diff --git a/app/components/Package/TimelineChart.vue b/app/components/Package/TimelineChart.vue new file mode 100644 index 0000000000..e48f1821b9 --- /dev/null +++ b/app/components/Package/TimelineChart.vue @@ -0,0 +1,920 @@ + + + + + diff --git a/app/components/SkeletonBlock.vue b/app/components/SkeletonBlock.vue index 7467e4c103..05ef97ae8c 100644 --- a/app/components/SkeletonBlock.vue +++ b/app/components/SkeletonBlock.vue @@ -1,3 +1,3 @@ diff --git a/app/components/Tab/Item.vue b/app/components/Tab/Item.vue index 6b0f4c3aff..4337c061ce 100644 --- a/app/components/Tab/Item.vue +++ b/app/components/Tab/Item.vue @@ -8,10 +8,12 @@ const props = withDefaults( value: string icon?: IconClass tabId?: string + controlsPanel?: boolean variant?: 'primary' | 'secondary' size?: 'sm' | 'md' }>(), { + controlsPanel: true, variant: 'secondary', size: 'md', }, @@ -22,12 +24,13 @@ const attrs = useAttrs() const selected = inject>('tabs-selected') const getTabId = inject<(value: string) => string>('tabs-tab-id') const getPanelId = inject<(value: string) => string>('tabs-panel-id') + if (!selected || !getTabId || !getPanelId) { throw new Error('TabItem must be used inside a TabRoot component') } const isSelected = computed(() => selected.value === props.value) const resolvedTabId = computed(() => props.tabId ?? getTabId(props.value)) -const resolvedPanelId = computed(() => getPanelId(props.value)) +const resolvedPanelId = computed(() => (props.controlsPanel ? getPanelId(props.value) : undefined)) const select = () => { selected.value = props.value } diff --git a/app/composables/useSettings.ts b/app/composables/useSettings.ts index 1fc879a0a4..c5d5734ff6 100644 --- a/app/composables/useSettings.ts +++ b/app/composables/useSettings.ts @@ -50,6 +50,10 @@ export interface AppSettings { anomaliesFixed: boolean predictionPoints: number } + timelineChart: { + isZeroBased: boolean + showZoom: boolean + } } const DEFAULT_SETTINGS: AppSettings = { @@ -77,6 +81,10 @@ const DEFAULT_SETTINGS: AppSettings = { anomaliesFixed: true, predictionPoints: 4, }, + timelineChart: { + isZeroBased: false, + showZoom: false, + }, } const STORAGE_KEY = 'npmx-settings' diff --git a/app/pages/package-timeline/[[org]]/[packageName].vue b/app/pages/package-timeline/[[org]]/[packageName].vue index bb9f577eb0..a007efde15 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' @@ -111,14 +112,17 @@ function sizeKey(ver: string) { } async function fetchSizes(offset: number) { + const requestedPackage = packageName.value sizeFetchesInFlight.value++ try { const data = await $fetch( - `/api/registry/timeline/sizes/${packageName.value}`, + `/api/registry/timeline/sizes/${requestedPackage}`, { query: { offset, limit: PAGE_SIZE } }, ) + if (requestedPackage !== packageName.value) return + for (const entry of data.sizes) { - sizeCache.set(sizeKey(entry.version), { + sizeCache.set(`${requestedPackage}@${entry.version}`, { totalSize: entry.totalSize, dependencyCount: entry.dependencyCount, }) @@ -143,13 +147,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 +305,8 @@ const versionSubEvents = computed(() => { return result }) +const selectedVersion = shallowRef(null) + useSeoMeta({ title: () => `Timeline - ${packageName.value} - npmx`, description: () => `Version timeline for ${packageName.value}`, @@ -325,12 +324,21 @@ useSeoMeta({ page="timeline" /> -
- -
-
+
+
+
+ +
+
+
  1. @@ -346,6 +354,10 @@ useSeoMeta({ class="text-sm font-medium" :class="entry.version === version ? 'text-accent' : ''" dir="ltr" + @mouseenter="selectedVersion = entry.version" + @mouseleave="selectedVersion = null" + @focus="selectedVersion = entry.version" + @blur="selectedVersion = null" > {{ entry.version }} @@ -427,18 +439,3 @@ useSeoMeta({
- - diff --git a/app/utils/charts.ts b/app/utils/charts.ts index 3cdbe3fd34..7c5a86ff3b 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,68 @@ 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) + + if (!first || !last) return '' + + const firstValue = config.metric === 'totalSize' ? first?.totalSize : first?.dependencyCount + const lastValue = config.metric === 'totalSize' ? last?.totalSize : last?.dependencyCount + 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 => + 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/i18n/locales/en.json b/i18n/locales/en.json index 6851ed5511..f70f43d36d 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -587,7 +587,18 @@ "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": { + "tab_aria_label": "Metric selection", + "base_scale": "start y-axis at zero", + "zoom": "zoom", + "reset_minimap": "reset minimap", + "copy_alt": { + "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}." + } + } }, "dependencies": { "title": "Dependency ({count}) | Dependencies ({count})", diff --git a/i18n/locales/fr-FR.json b/i18n/locales/fr-FR.json index 0e40fa415b..8d89797aff 100644 --- a/i18n/locales/fr-FR.json +++ b/i18n/locales/fr-FR.json @@ -575,7 +575,18 @@ "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": { + "tab_aria_label": "Sélection de métrique", + "base_scale": "positionner les ordonnées à zéro", + "zoom": "zoom", + "reset_minimap": "Réinitialiser la mini-carte", + "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}% dans l'ensemble). {key_changes} {watermark}." + } + } }, "dependencies": { "title": "Dépendances ({count})", diff --git a/i18n/schema.json b/i18n/schema.json index 7b6a3b3945..32c6c25eac 100644 --- a/i18n/schema.json +++ b/i18n/schema.json @@ -1767,6 +1767,39 @@ }, "provenance_removed": { "type": "string" + }, + "chart": { + "type": "object", + "properties": { + "tab_aria_label": { + "type": "string" + }, + "base_scale": { + "type": "string" + }, + "zoom": { + "type": "string" + }, + "reset_minimap": { + "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 diff --git a/package.json b/package.json index 27ed81df86..35341301ac 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.5", "vue-router": "5.0.4" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ce31753f13..3e4c00cb3c 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.5 + version: 3.18.5(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.5: + resolution: {integrity: sha512-AnfGe8YKriAE8QsxhBskKP7KPXoXrf07WM+rxK10nZcS1AOHaKeFbdgDQN1fhTpIWoGY0qVDuEtzcpLqQOjbYg==} 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.5(vue@3.5.33): dependencies: vue: 3.5.33(typescript@6.0.2) 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. * diff --git a/test/nuxt/a11y.spec.ts b/test/nuxt/a11y.spec.ts index 901c85e562..ab71a34b43 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,22 @@ 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, + loading: false, + }, + }) + 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