Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
271 changes: 208 additions & 63 deletions app/components/Package/TrendsChart.vue

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions app/components/Package/WeeklyDownloadStats.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ import { VueUiSparkline } from 'vue-data-ui/vue-ui-sparkline'
import { useCssVariables } from '~/composables/useColors'
import type { WeeklyDataPoint } from '~/types/chart'
import { OKLCH_NEUTRAL_FALLBACK, lightenOklch } from '~/utils/colors'
import type { RepoRef } from '#shared/utils/git-providers'

const props = defineProps<{
packageName: string
createdIso: string | null
repoRef?: RepoRef | null | undefined
}>()

const router = useRouter()
Expand Down Expand Up @@ -315,6 +317,7 @@ const config = computed(() => {
:weeklyDownloads="weeklyDownloads"
:inModal="true"
:packageName="props.packageName"
:repoRef="props.repoRef"
:createdIso="createdIso"
permalink
show-facet-selector
Expand Down
240 changes: 240 additions & 0 deletions app/composables/useCharts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import type {
WeeklyDataPoint,
YearlyDataPoint,
} from '~/types/chart'
import type { RepoRef } from '#shared/utils/git-providers'
import { parseRepoUrl } from '#shared/utils/git-providers'
import type { PackageMetaResponse } from '#shared/types'
import { encodePackageName } from '#shared/utils/npm'
import { fetchNpmDownloadsRange } from '~/utils/npm/api'

export type PackumentLikeForTime = {
Expand Down Expand Up @@ -182,11 +186,151 @@ export function buildYearlyEvolutionFromDaily(daily: DailyRawPoint[]): YearlyDat

const npmDailyRangeCache = import.meta.client ? new Map<string, Promise<DailyRawPoint[]>>() : null
const likesEvolutionCache = import.meta.client ? new Map<string, Promise<DailyRawPoint[]>>() : null
const contributorsEvolutionCache = import.meta.client
? new Map<string, Promise<GitHubContributorStats[]>>()
: null
const repoMetaCache = import.meta.client ? new Map<string, Promise<RepoRef | null>>() : null

/** Clears client-side promise caches. Exported for use in tests. */
export function clearClientCaches() {
npmDailyRangeCache?.clear()
likesEvolutionCache?.clear()
contributorsEvolutionCache?.clear()
repoMetaCache?.clear()
}

type GitHubContributorWeek = {
w: number
a: number
d: number
c: number
}

type GitHubContributorStats = {
total: number
weeks: GitHubContributorWeek[]
}

function pad2(value: number): string {
return value.toString().padStart(2, '0')
}

function toIsoMonthKey(date: Date): string {
return `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}`
}

function isOverlappingRange(start: Date, end: Date, rangeStart: Date, rangeEnd: Date): boolean {
return end.getTime() >= rangeStart.getTime() && start.getTime() <= rangeEnd.getTime()
}

function buildWeeklyEvolutionFromContributorCounts(
weeklyCounts: Map<number, number>,
rangeStart: Date,
rangeEnd: Date,
): WeeklyDataPoint[] {
return Array.from(weeklyCounts.entries())
.sort(([a], [b]) => a - b)
.map(([weekStartSeconds, value]) => {
const weekStartDate = new Date(weekStartSeconds * 1000)
const weekEndDate = addDays(weekStartDate, 6)

if (!isOverlappingRange(weekStartDate, weekEndDate, rangeStart, rangeEnd)) return null

const clampedWeekEndDate = weekEndDate.getTime() > rangeEnd.getTime() ? rangeEnd : weekEndDate

const weekStartIso = toIsoDateString(weekStartDate)
const weekEndIso = toIsoDateString(clampedWeekEndDate)

return {
value,
weekKey: `${weekStartIso}_${weekEndIso}`,
weekStart: weekStartIso,
weekEnd: weekEndIso,
timestampStart: weekStartDate.getTime(),
timestampEnd: clampedWeekEndDate.getTime(),
}
})
.filter((item): item is WeeklyDataPoint => Boolean(item))
}

function buildMonthlyEvolutionFromContributorCounts(
monthlyCounts: Map<string, number>,
rangeStart: Date,
rangeEnd: Date,
): MonthlyDataPoint[] {
return Array.from(monthlyCounts.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([month, value]) => {
const [year, monthNumber] = month.split('-').map(Number)
if (!year || !monthNumber) return null

const monthStartDate = new Date(Date.UTC(year, monthNumber - 1, 1))
const monthEndDate = new Date(Date.UTC(year, monthNumber, 0))

if (!isOverlappingRange(monthStartDate, monthEndDate, rangeStart, rangeEnd)) return null

return {
month,
value,
timestamp: monthStartDate.getTime(),
}
})
.filter((item): item is MonthlyDataPoint => Boolean(item))
}

function buildYearlyEvolutionFromContributorCounts(
yearlyCounts: Map<string, number>,
rangeStart: Date,
rangeEnd: Date,
): YearlyDataPoint[] {
return Array.from(yearlyCounts.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([year, value]) => {
const yearNumber = Number(year)
if (!yearNumber) return null

const yearStartDate = new Date(Date.UTC(yearNumber, 0, 1))
const yearEndDate = new Date(Date.UTC(yearNumber, 11, 31))

if (!isOverlappingRange(yearStartDate, yearEndDate, rangeStart, rangeEnd)) return null

return {
year,
value,
timestamp: yearStartDate.getTime(),
}
})
.filter((item): item is YearlyDataPoint => Boolean(item))
}

function buildContributorCounts(stats: GitHubContributorStats[]) {
const weeklyCounts = new Map<number, number>()
const monthlyCounts = new Map<string, number>()
const yearlyCounts = new Map<string, number>()

for (const contributor of stats ?? []) {
const monthSet = new Set<string>()
const yearSet = new Set<string>()

for (const week of contributor?.weeks ?? []) {
if (!week || week.c <= 0) continue

weeklyCounts.set(week.w, (weeklyCounts.get(week.w) ?? 0) + 1)

const weekStartDate = new Date(week.w * 1000)
monthSet.add(toIsoMonthKey(weekStartDate))
yearSet.add(String(weekStartDate.getUTCFullYear()))
}

for (const key of monthSet) {
monthlyCounts.set(key, (monthlyCounts.get(key) ?? 0) + 1)
}
for (const key of yearSet) {
yearlyCounts.set(key, (yearlyCounts.get(key) ?? 0) + 1)
}
}

return { weeklyCounts, monthlyCounts, yearlyCounts }
}

async function fetchDailyRangeCached(packageName: string, startIso: string, endIso: string) {
Expand Down Expand Up @@ -377,9 +521,105 @@ export function useCharts() {
return buildYearlyEvolutionFromDaily(filteredDaily)
}

async function fetchRepoContributorsEvolution(
repoRef: MaybeRefOrGetter<RepoRef | null | undefined>,
evolutionOptions: MaybeRefOrGetter<EvolutionOptions>,
): Promise<DailyDataPoint[] | WeeklyDataPoint[] | MonthlyDataPoint[] | YearlyDataPoint[]> {
const resolvedRepoRef = toValue(repoRef)
if (!resolvedRepoRef || resolvedRepoRef.provider !== 'github') return []

const resolvedOptions = toValue(evolutionOptions)

const cache = contributorsEvolutionCache
const cacheKey = `${resolvedRepoRef.owner}/${resolvedRepoRef.repo}`

let statsPromise: Promise<GitHubContributorStats[]>

if (cache?.has(cacheKey)) {
statsPromise = cache.get(cacheKey)!
} else {
statsPromise = $fetch<GitHubContributorStats[]>(
`/api/github/contributors-evolution/${resolvedRepoRef.owner}/${resolvedRepoRef.repo}`,
)
.then(data => (Array.isArray(data) ? data : []))
.catch(error => {
cache?.delete(cacheKey)
throw error
})

cache?.set(cacheKey, statsPromise)
}

const stats = await statsPromise
const { start, end } = resolveDateRange(resolvedOptions, null)

const { weeklyCounts, monthlyCounts, yearlyCounts } = buildContributorCounts(stats)

if (resolvedOptions.granularity === 'week') {
return buildWeeklyEvolutionFromContributorCounts(weeklyCounts, start, end)
}
if (resolvedOptions.granularity === 'month') {
return buildMonthlyEvolutionFromContributorCounts(monthlyCounts, start, end)
}
if (resolvedOptions.granularity === 'year') {
return buildYearlyEvolutionFromContributorCounts(yearlyCounts, start, end)
}

return []
}

async function fetchRepoRefsForPackages(
packageNames: MaybeRefOrGetter<string[]>,
): Promise<Record<string, RepoRef | null>> {
const names = (toValue(packageNames) ?? []).map(n => String(n).trim()).filter(Boolean)
if (!import.meta.client || !names.length) return {}

const settled = await Promise.allSettled(
names.map(async name => {
const cacheKey = name
const cache = repoMetaCache
if (cache?.has(cacheKey)) {
const ref = await cache.get(cacheKey)!
return { name, ref }
}

const promise = $fetch<PackageMetaResponse>(
`/api/registry/package-meta/${encodePackageName(name)}`,
)
.then(meta => {
const repoUrl = meta?.links?.repository
return repoUrl ? parseRepoUrl(repoUrl) : null
})
.catch(error => {
cache?.delete(cacheKey)
throw error
})

cache?.set(cacheKey, promise)
const ref = await promise
return { name, ref }
}),
)

const next: Record<string, RepoRef | null> = {}
for (const [index, entry] of settled.entries()) {
const name = names[index]
if (!name) continue
if (entry.status === 'fulfilled') {
next[name] = entry.value.ref ?? null
} else {
next[name] = null
}
}

return next
}

return {
fetchPackageDownloadEvolution,
fetchPackageLikesEvolution,
fetchRepoContributorsEvolution,
fetchRepoRefsForPackages,
getNpmPackageCreationDate,
}
}
10 changes: 8 additions & 2 deletions app/pages/compare.vue
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,10 @@ useSeoMeta({
</h2>

<div
v-if="status === 'pending' && (!packagesData || packagesData.every(p => p === null))"
v-if="
(status === 'pending' || status === 'idle') &&
(!packagesData || packagesData.every(p => p === null))
"
class="flex items-center justify-center py-12"
>
<LoadingSpinner :text="$t('compare.packages.loading')" />
Expand Down Expand Up @@ -247,9 +250,12 @@ useSeoMeta({
<CompareLineChart :packages="packages.filter(p => p !== NO_DEPENDENCY_ID)" />
</div>

<div v-else class="text-center py-12" role="alert">
<div v-else-if="status === 'error'" class="text-center py-12" role="alert">
<p class="text-fg-muted">{{ $t('compare.packages.error') }}</p>
</div>
<div v-else class="flex items-center justify-center py-12">
<LoadingSpinner :text="$t('compare.packages.loading')" />
</div>
</section>

<!-- Empty state -->
Expand Down
6 changes: 5 additions & 1 deletion app/pages/package/[[org]]/[name].vue
Original file line number Diff line number Diff line change
Expand Up @@ -1380,7 +1380,11 @@ const showSkeleton = shallowRef(false)
</ClientOnly>

<!-- Download stats -->
<PackageWeeklyDownloadStats :packageName :createdIso="pkg?.time?.created ?? null" />
<PackageWeeklyDownloadStats
:packageName
:createdIso="pkg?.time?.created ?? null"
:repoRef="repoRef"
/>
Comment on lines +1383 to +1387
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Fix repoRef nullability for PackageWeeklyDownloadStats.

repoRef can be null from useRepoMeta, but the child prop expects RepoRef | undefined, causing the reported type check failure. Normalise null to undefined (or widen the prop type).

🛠️ Suggested fix
-            :repoRef="repoRef"
+            :repoRef="repoRef ?? undefined"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<PackageWeeklyDownloadStats
:packageName
:createdIso="pkg?.time?.created ?? null"
:repoRef="repoRef"
/>
<PackageWeeklyDownloadStats
:packageName
:createdIso="pkg?.time?.created ?? null"
:repoRef="repoRef ?? undefined"
/>
🧰 Tools
🪛 GitHub Check: 💪 Type check

[failure] 1371-1371:
Type 'RepoRef | null' is not assignable to type 'RepoRef | undefined'.


<!-- Playground links -->
<PackagePlaygrounds
Expand Down
4 changes: 3 additions & 1 deletion i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -362,9 +362,11 @@
"y_axis_label": "{granularity} {facet}",
"facet": "Facet",
"title": "Trends",
"contributors_skip": "Not shown in Contributors (no GitHub repo):",
"items": {
"downloads": "Downloads",
"likes": "Likes"
"likes": "Likes",
"contributors": "Contributors"
}
},
"downloads": {
Expand Down
6 changes: 6 additions & 0 deletions i18n/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1090,6 +1090,9 @@
"title": {
"type": "string"
},
"contributors_skip": {
"type": "string"
},
"items": {
"type": "object",
"properties": {
Expand All @@ -1098,6 +1101,9 @@
},
"likes": {
"type": "string"
},
"contributors": {
"type": "string"
}
},
"additionalProperties": false
Expand Down
4 changes: 3 additions & 1 deletion lunaria/files/en-GB.json
Original file line number Diff line number Diff line change
Expand Up @@ -361,9 +361,11 @@
"y_axis_label": "{granularity} {facet}",
"facet": "Facet",
"title": "Trends",
"contributors_skip": "Not shown in Contributors (no GitHub repo):",
"items": {
"downloads": "Downloads",
"likes": "Likes"
"likes": "Likes",
"contributors": "Contributors"
}
},
"downloads": {
Expand Down
4 changes: 3 additions & 1 deletion lunaria/files/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -361,9 +361,11 @@
"y_axis_label": "{granularity} {facet}",
"facet": "Facet",
"title": "Trends",
"contributors_skip": "Not shown in Contributors (no GitHub repo):",
"items": {
"downloads": "Downloads",
"likes": "Likes"
"likes": "Likes",
"contributors": "Contributors"
}
},
"downloads": {
Expand Down
Loading
Loading