Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
80d5e88
chore: add translations
graphieros May 1, 2026
2fca3d6
feat: add timeline chart
graphieros May 1, 2026
96953f6
chore: add tests
graphieros May 1, 2026
9e7a266
fix: remove unused iterators
graphieros May 1, 2026
e4815da
fix: translation keys ordering
graphieros May 1, 2026
256a457
fix: various
graphieros May 1, 2026
cfa2220
fix: various
graphieros May 1, 2026
0d1589b
fix: add missing translation
graphieros May 1, 2026
e8dc353
fix: remove tab ids
graphieros May 1, 2026
5b97c77
fix: follow the rabbit
graphieros May 1, 2026
dd3b6e6
fix: allow usage of tabs without panels
graphieros May 1, 2026
e16c4d9
fix: apply CodeRabbit fix (avoid cache poisoning)
graphieros May 1, 2026
50d2210
fix: add guards
graphieros May 1, 2026
a49c3d2
fix: add zoom toggle; place loader under the chart
graphieros May 1, 2026
21a8cb9
fix: follow the rabbit
graphieros May 1, 2026
b23b3b4
fix: add missing prop in component test
graphieros May 1, 2026
d61877e
chore: bump vue-data-ui from 3.18.2 to 3.18.3
graphieros May 1, 2026
b6c5740
fix: always keep x-axis labels rotated
graphieros May 1, 2026
7f0e118
fix: always keep x-axis labels rotated
graphieros May 1, 2026
7ebfcb7
fix: set lower z-index for the chart container
graphieros May 1, 2026
476b280
fix: reduce x-axis labels font-size
graphieros May 1, 2026
7721bba
chore: bump vue-data-ui from 3.18.3 to 3.18.4
graphieros May 1, 2026
4634863
fix: impact zoom index offsets
graphieros May 1, 2026
07f00bb
fix: add missing translation
graphieros May 1, 2026
1c15660
fix: set a decent radius to datapoint plots
graphieros May 1, 2026
55ba0f9
fix: apply same transition to datapoint plot circles
graphieros May 1, 2026
11cf6bc
chore: remove commented code
graphieros May 1, 2026
6e6e6fa
chore: bump vue-data-ui from 3.18.4 to 3.18.5
graphieros May 1, 2026
63092dc
fix: persist zoom state when toggling controls
graphieros May 1, 2026
01bbf5e
[autofix.ci] apply automated fixes
autofix-ci[bot] May 1, 2026
153eaf3
fix: bad import
graphieros May 1, 2026
7a0ecfa
Merge branch 'timeline-chart' of https://github.com/npmx-dev/npmx.dev…
graphieros May 1, 2026
914734a
fix: bad import
graphieros May 1, 2026
c15470b
[autofix.ci] apply automated fixes
autofix-ci[bot] May 1, 2026
177260c
fix: bad import imposed by autoformatting
graphieros May 1, 2026
d823d5a
Merge branch 'timeline-chart' of https://github.com/npmx-dev/npmx.dev…
graphieros May 1, 2026
afffa73
feat: add chart icon in skeleton
graphieros May 1, 2026
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
920 changes: 920 additions & 0 deletions app/components/Package/TimelineChart.vue

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion app/components/SkeletonBlock.vue
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
<template>
<div class="bg-bg-elevated rounded animate-skeleton-pulse" />
<div class="bg-bg-elevated rounded animate-skeleton-pulse"><slot /></div>
</template>
5 changes: 4 additions & 1 deletion app/components/Tab/Item.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
Expand All @@ -22,12 +24,13 @@ const attrs = useAttrs()
const selected = inject<WritableComputedRef<string>>('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
}
Expand Down
8 changes: 8 additions & 0 deletions app/composables/useSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ export interface AppSettings {
anomaliesFixed: boolean
predictionPoints: number
}
timelineChart: {
isZeroBased: boolean
showZoom: boolean
}
}

const DEFAULT_SETTINGS: AppSettings = {
Expand Down Expand Up @@ -77,6 +81,10 @@ const DEFAULT_SETTINGS: AppSettings = {
anomaliesFixed: true,
predictionPoints: 4,
},
timelineChart: {
isZeroBased: false,
showZoom: false,
},
}

const STORAGE_KEY = 'npmx-settings'
Expand Down
53 changes: 25 additions & 28 deletions app/pages/package-timeline/[[org]]/[packageName].vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -111,14 +112,17 @@ function sizeKey(ver: string) {
}

async function fetchSizes(offset: number) {
const requestedPackage = packageName.value
sizeFetchesInFlight.value++
try {
const data = await $fetch<TimelineSizeResponse>(
`/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,
})
Expand All @@ -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.
Expand Down Expand Up @@ -308,6 +305,8 @@ const versionSubEvents = computed(() => {
return result
})

const selectedVersion = shallowRef<string | null>(null)

useSeoMeta({
title: () => `Timeline - ${packageName.value} - npmx`,
description: () => `Version timeline for ${packageName.value}`,
Expand All @@ -325,12 +324,21 @@ useSeoMeta({
page="timeline"
/>

<div class="container w-full py-8">
<!-- Sizes loading indicator -->
<div v-if="sizesLoading" class="h-0.5 mb-4 rounded-full bg-bg-muted overflow-hidden">
<div class="h-full w-1/3 bg-accent rounded-full animate-indeterminate" />
<div class="sticky top-24 z-1 bg-bg mt-8">
<div class="container w-full">
<div class="mx-auto">
<PackageTimelineChart
:sizeCache
:versionSubEvents
:timelineEntries
:selectedVersion
:loading="sizesLoading"
/>
</div>
</div>
</div>

<div class="container w-full py-8">
<!-- Timeline -->
<ol v-if="timelineEntries.length" class="relative border-s border-border ms-4">
<li v-for="entry in timelineEntries" :key="entry.version" class="mb-6 ms-6">
Expand All @@ -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 }}
</LinkBase>
Expand Down Expand Up @@ -427,18 +439,3 @@ useSeoMeta({
</div>
</main>
</template>

<style scoped>
@keyframes indeterminate {
0% {
translate: -100%;
}
100% {
translate: 400%;
}
}

.animate-indeterminate {
animation: indeterminate 1.5s ease-in-out infinite;
}
</style>
94 changes: 94 additions & 0 deletions app/utils/charts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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<void>
$t: TrendTranslateFunction
numberFormatter: (value: number) => string
}

// Used for TrendsChart.vue
export function createAltTextForTrendLineChart({
dataset,
Expand Down Expand Up @@ -705,6 +737,68 @@ export async function copyAltTextForCompareScatterChart({
await config.copy(altText)
}

// Used for TimelineChart.vue
export function createAltTextForTimelineChart({
dataset,
config,
}: AltCopyArgs<EnrichedTimelineSizeCacheEntry[], TimelineChartConfig>) {
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

Comment thread
coderabbitai[bot] marked this conversation as resolved.
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<EnrichedTimelineSizeCacheEntry[], TimelineChartConfig>) {
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) {
Expand Down
13 changes: 12 additions & 1 deletion i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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})",
Expand Down
13 changes: 12 additions & 1 deletion i18n/locales/fr-FR.json
Original file line number Diff line number Diff line change
Expand Up @@ -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})",
Expand Down
33 changes: 33 additions & 0 deletions i18n/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading