Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
238 changes: 193 additions & 45 deletions app/components/Package/TrendsChart.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useCssVariables } from '~/composables/useColors'
import { OKLCH_NEUTRAL_FALLBACK, transparentizeOklch } from '~/utils/colors'
import { getFrameworkColor, isListedFramework } from '~/utils/frameworks'
import { drawNpmxLogoAndTaglineWatermark } from '~/composables/useChartWatermark'
import type { RepoRef } from '#shared/utils/git-providers'
import type {
ChartTimeGranularity,
DailyDataPoint,
Expand Down Expand Up @@ -35,6 +36,7 @@ const props = withDefaults(
* Used when `weeklyDownloads` is not provided.
*/
packageNames?: string[]
repoRef?: RepoRef | null | undefined
createdIso?: string | null

/** When true, shows facet selector (e.g. Downloads / Likes). */
Expand Down Expand Up @@ -332,6 +334,32 @@ const effectivePackageNames = computed<string[]>(() => {
return single ? [single] : []
})

const {
fetchPackageDownloadEvolution,
fetchPackageLikesEvolution,
fetchRepoContributorsEvolution,
fetchRepoRefsForPackages,
} = useCharts()

const repoRefsByPackage = shallowRef<Record<string, RepoRef | null>>({})
const repoRefsRequestToken = shallowRef(0)

watch(
() => effectivePackageNames.value,
async names => {
if (!import.meta.client) return
if (!isMultiPackageMode.value) {
repoRefsByPackage.value = {}
return
}
const currentToken = ++repoRefsRequestToken.value
const refs = await fetchRepoRefsForPackages(names)
if (currentToken !== repoRefsRequestToken.value) return
repoRefsByPackage.value = refs
},
{ immediate: true },
)

const selectedGranularity = usePermalink<ChartTimeGranularity>('granularity', DEFAULT_GRANULARITY, {
permanent: props.permalink,
})
Expand Down Expand Up @@ -571,35 +599,112 @@ function applyDateRange<T extends Record<string, unknown>>(base: T): T & DateRan
return next
}

const { fetchPackageDownloadEvolution, fetchPackageLikesEvolution } = useCharts()

type MetricId = 'downloads' | 'likes'
type MetricId = 'downloads' | 'likes' | 'contributors'
const DEFAULT_METRIC_ID: MetricId = 'downloads'

type MetricContext = {
packageName: string
repoRef?: RepoRef | null
}

type MetricDef = {
id: MetricId
label: string
fetch: (pkg: string, options: EvolutionOptions) => Promise<EvolutionData>
fetch: (context: MetricContext, options: EvolutionOptions) => Promise<EvolutionData>
supportsMulti?: boolean
}

const METRICS = computed<MetricDef[]>(() => [
{
id: 'downloads',
label: $t('package.trends.items.downloads'),
fetch: (pkg, opts) =>
fetchPackageDownloadEvolution(pkg, props.createdIso ?? null, opts) as Promise<EvolutionData>,
},
{
id: 'likes',
label: $t('package.trends.items.likes'),
fetch: (pkg, opts) => fetchPackageLikesEvolution(pkg, opts) as Promise<EvolutionData>,
},
])
const hasContributorsFacet = computed(() => {
if (isMultiPackageMode.value) {
return Object.values(repoRefsByPackage.value).some(ref => ref?.provider === 'github')
}
const ref = props.repoRef
return ref?.provider === 'github' && ref.owner && ref.repo
})

const METRICS = computed<MetricDef[]>(() => {
const metrics: MetricDef[] = [
{
id: 'downloads',
label: $t('package.trends.items.downloads'),
fetch: ({ packageName }, opts) =>
fetchPackageDownloadEvolution(
packageName,
props.createdIso ?? null,
opts,
) as Promise<EvolutionData>,
supportsMulti: true,
},
{
id: 'likes',
label: $t('package.trends.items.likes'),
fetch: ({ packageName }, opts) => fetchPackageLikesEvolution(packageName, opts),
supportsMulti: true,
},
]

if (hasContributorsFacet.value) {
metrics.push({
id: 'contributors',
label: $t('package.trends.items.contributors'),
fetch: ({ repoRef }, opts) => fetchRepoContributorsEvolution(repoRef, opts),
supportsMulti: true,
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

return metrics
})

const selectedMetric = usePermalink<MetricId>('facet', DEFAULT_METRIC_ID, {
permanent: props.permalink,
})

const effectivePackageNamesForMetric = computed<string[]>(() => {
if (!isMultiPackageMode.value) return effectivePackageNames.value
if (selectedMetric.value !== 'contributors') return effectivePackageNames.value
return effectivePackageNames.value.filter(
name => repoRefsByPackage.value[name]?.provider === 'github',
)
})

const skippedPackagesWithoutGitHub = computed(() => {
if (!isMultiPackageMode.value) return []
if (selectedMetric.value !== 'contributors') return []
if (!effectivePackageNames.value.length) return []

return effectivePackageNames.value.filter(
name => repoRefsByPackage.value[name]?.provider !== 'github',
)
})

const availableGranularities = computed<ChartTimeGranularity[]>(() => {
if (selectedMetric.value === 'contributors') {
return ['weekly', 'monthly', 'yearly']
}

return ['daily', 'weekly', 'monthly', 'yearly']
})
Comment on lines +626 to +687
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Disable estimation/extrapolation for contributors.

Contributors are absolute counts, so the monthly/yearly extrapolation and estimation overlay will inflate the latest period and mislead. Gate those behaviours on selectedMetric !== 'contributors'.

🛠️ Suggested fix
-const isEstimationGranularity = computed(
-  () => displayedGranularity.value === 'monthly' || displayedGranularity.value === 'yearly',
-)
+const isEstimationGranularity = computed(
+  () =>
+    selectedMetric.value !== 'contributors'
+    && (displayedGranularity.value === 'monthly' || displayedGranularity.value === 'yearly'),
+)
 function extrapolateLastValue(lastValue: number) {
+  if (selectedMetric.value === 'contributors') return lastValue
   if (displayedGranularity.value !== 'monthly' && displayedGranularity.value !== 'yearly')
     return lastValue


watch(
() => [selectedMetric.value, availableGranularities.value] as const,
() => {
if (!availableGranularities.value.includes(selectedGranularity.value)) {
selectedGranularity.value = 'weekly'
}
},
{ immediate: true },
)

watch(
() => METRICS.value,
metrics => {
if (!metrics.some(m => m.id === selectedMetric.value)) {
selectedMetric.value = DEFAULT_METRIC_ID
}
},
{ immediate: true },
)

// Per-metric state keyed by metric id
const metricStates = reactive<
Record<
Expand All @@ -624,10 +729,18 @@ const metricStates = reactive<
evolutionsByPackage: {},
requestToken: 0,
},
contributors: {
pending: false,
evolution: [],
evolutionsByPackage: {},
requestToken: 0,
},
})

const activeMetricState = computed(() => metricStates[selectedMetric.value])
const activeMetricDef = computed(() => METRICS.value.find(m => m.id === selectedMetric.value)!)
const activeMetricDef = computed(
() => METRICS.value.find(m => m.id === selectedMetric.value) ?? METRICS.value[0],
)
const pending = computed(() => activeMetricState.value.pending)

const isMounted = shallowRef(false)
Expand Down Expand Up @@ -695,21 +808,33 @@ watch(
async function loadMetric(metricId: MetricId) {
if (!import.meta.client) return

const packageNames = effectivePackageNames.value
if (!packageNames.length) return

const state = metricStates[metricId]
const metric = METRICS.value.find(m => m.id === metricId)!
const currentToken = ++state.requestToken
state.pending = true

const fetchFn = (pkg: string) => metric.fetch(pkg, options.value)
const fetchFn = (context: MetricContext) => metric.fetch(context, options.value)

try {
const packageNames = effectivePackageNamesForMetric.value
if (!packageNames.length) {
if (isMultiPackageMode.value) state.evolutionsByPackage = {}
else state.evolution = []
displayedGranularity.value = selectedGranularity.value
return
}

if (isMultiPackageMode.value) {
if (metric.supportsMulti === false) {
state.evolutionsByPackage = {}
displayedGranularity.value = selectedGranularity.value
return
}

const settled = await Promise.allSettled(
packageNames.map(async pkg => {
const result = await fetchFn(pkg)
const repoRef = metricId === 'contributors' ? repoRefsByPackage.value[pkg] : null
const result = await fetchFn({ packageName: pkg, repoRef })
return { pkg, result: (result ?? []) as EvolutionData }
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}),
)
Expand Down Expand Up @@ -750,7 +875,7 @@ async function loadMetric(metricId: MetricId) {
}
}

const result = await fetchFn(pkg)
const result = await fetchFn({ packageName: pkg, repoRef: props.repoRef })
if (currentToken !== state.requestToken) return

state.evolution = (result ?? []) as EvolutionData
Expand Down Expand Up @@ -778,9 +903,13 @@ const debouncedLoadNow = useDebounceFn(() => {
const fetchTriggerKey = computed(() => {
const names = effectivePackageNames.value.join(',')
const o = options.value
const repoKey = props.repoRef
? `${props.repoRef.provider}:${props.repoRef.owner}/${props.repoRef.repo}`
: ''
return [
isMultiPackageMode.value ? 'M' : 'S',
names,
repoKey,
String(props.createdIso ?? ''),
String(o.granularity ?? ''),
String('weeks' in o ? (o.weeks ?? '') : ''),
Expand All @@ -800,6 +929,18 @@ watch(
{ flush: 'post' },
)

watch(
() => repoRefsByPackage.value,
() => {
if (!import.meta.client) return
if (!isMounted.value) return
if (!isMultiPackageMode.value) return
if (selectedMetric.value !== 'contributors') return
debouncedLoadNow()
},
{ deep: true },
)

const effectiveDataSingle = computed<EvolutionData>(() => {
const state = activeMetricState.value
if (
Expand Down Expand Up @@ -837,7 +978,7 @@ const chartData = computed<{
}

const state = activeMetricState.value
const names = effectivePackageNames.value
const names = effectivePackageNamesForMetric.value
const granularity = displayedGranularity.value

const timestampSet = new Set<number>()
Expand Down Expand Up @@ -936,6 +1077,13 @@ function getGranularityLabel(granularity: ChartTimeGranularity) {
return granularityLabels.value[granularity]
}

const granularityItems = computed(() =>
availableGranularities.value.map(granularity => ({
label: granularityLabels.value[granularity],
value: granularity,
})),
)

function clampRatio(value: number): number {
if (value < 0) return 0
if (value > 1) return 1
Expand Down Expand Up @@ -1114,20 +1262,20 @@ function drawEstimationLine(svg: Record<string, any>) {

lines.push(`
<line
x1="${previousPoint.x}"
y1="${previousPoint.y}"
x2="${lastPoint.x}"
y2="${lastPoint.y}"
stroke="${colors.value.bg}"
x1="${previousPoint.x}"
y1="${previousPoint.y}"
x2="${lastPoint.x}"
y2="${lastPoint.y}"
stroke="${colors.value.bg}"
stroke-width="3"
opacity="1"
/>
<line
x1="${previousPoint.x}"
y1="${previousPoint.y}"
x2="${lastPoint.x}"
y2="${lastPoint.y}"
stroke="${stroke}"
<line
x1="${previousPoint.x}"
y1="${previousPoint.y}"
x2="${lastPoint.x}"
y2="${lastPoint.y}"
stroke="${stroke}"
stroke-width="3"
stroke-dasharray="4 8"
stroke-linecap="round"
Expand Down Expand Up @@ -1240,7 +1388,7 @@ function drawSvgPrintLegend(svg: Record<string, any>) {
!isZoomed.value
) {
seriesNames.push(`
<line
<line
x1="${svg.drawingArea.left + 12}"
y1="${svg.drawingArea.top + 24 * data.length}"
x2="${svg.drawingArea.left + 24}"
Expand Down Expand Up @@ -1327,7 +1475,7 @@ const chartConfig = computed(() => {
axis: {
yLabel: $t('package.trends.y_axis_label', {
granularity: getGranularityLabel(selectedGranularity.value),
facet: activeMetricDef.value.label,
facet: activeMetricDef.value?.label,
}),
yLabelOffsetX: 12,
fontSize: isMobile.value ? 32 : 24,
Expand Down Expand Up @@ -1463,12 +1611,7 @@ watch(selectedMetric, value => {
id="granularity"
v-model="selectedGranularity"
:disabled="activeMetricState.pending"
:items="[
{ label: $t('package.trends.granularity_daily'), value: 'daily' },
{ label: $t('package.trends.granularity_weekly'), value: 'weekly' },
{ label: $t('package.trends.granularity_monthly'), value: 'monthly' },
{ label: $t('package.trends.granularity_yearly'), value: 'yearly' },
]"
:items="granularityItems"
/>

<div class="grid grid-cols-2 gap-2 flex-1">
Expand Down Expand Up @@ -1526,10 +1669,15 @@ watch(selectedMetric, value => {
<span class="i-carbon:reset w-5 h-5" aria-hidden="true" />
</button>
</div>

<p v-if="skippedPackagesWithoutGitHub.length > 0" class="text-2xs font-mono text-fg-subtle">
{{ $t('package.trends.contributors_skip', { count: skippedPackagesWithoutGitHub.length }) }}
{{ skippedPackagesWithoutGitHub.join(', ') }}
</p>
</div>

<h2 id="trends-chart-title" class="sr-only">
{{ $t('package.trends.title') }} — {{ activeMetricDef.label }}
{{ $t('package.trends.title') }} — {{ activeMetricDef?.label }}
</h2>

<!-- Chart panel (active metric) -->
Expand Down
Loading
Loading