Skip to content

feat(ui): generalize downloads chart and add social likes option - #1310

Merged
danielroe merged 4 commits into
mainfrom
feat/chart-likes
Feb 10, 2026
Merged

feat(ui): generalize downloads chart and add social likes option#1310
danielroe merged 4 commits into
mainfrom
feat/chart-likes

Conversation

@serhalp

Copy link
Copy Markdown
Member

Generalize the download analytics chart into a trends chart that supports both downloads and likes, extensibly allowing for more facets in the future.

npmx.chart.package.likes.mp4
  • Add a facet selector (Downloads / Likes) shown on Compare page and in the single-package chart modal
  • Add server endpoint and utility for fetching per-package likes evolution from ATProto

Note

Full disclosure: I don't really know what I'm doing with the ATProto stuff. I tried to follow existing patterns. It seems to work.

Generalize the download analytics chart into a trends chart that supports both downloads and likes,
extensibly allowing for more facets in the future.
- Add a facet selector (Downloads / Likes) shown on Compare page and in the single-package chart modal
- Add server endpoint and utility for fetching per-package likes evolution from ATProto
@vercel

vercelBot commented Feb 10, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
npmx.devReadyReadyPreview, CommentFeb 10, 2026 3:43am
2 Skipped Deployments
ProjectDeploymentActionsUpdated (UTC)
docs.npmx.devIgnoredIgnoredPreviewFeb 10, 2026 3:43am
npmx-lunariaIgnoredIgnoredFeb 10, 2026 3:43am

Request Review

@github-actions

github-actionsBot commented Feb 10, 2026

Copy link
Copy Markdown

Lunaria Status Overview

🌕 This pull request will trigger status changes.

Learn more

By default, every PR changing files present in the Lunaria configuration's files property will be considered and trigger status changes accordingly.

You can change this by adding one of the keywords present in the ignoreKeywords property in your Lunaria configuration file in the PR's title (ignoring all files) or by including a tracker directive in the merged commit's description.

Tracked Files

FileNote
lunaria/files/ar-EG.jsonLocalization changed, will be marked as complete.
lunaria/files/az-AZ.jsonLocalization changed, will be marked as complete.
lunaria/files/bn-IN.jsonLocalization changed, will be marked as complete.
lunaria/files/cs-CZ.jsonLocalization changed, will be marked as complete.
lunaria/files/de-DE.jsonLocalization changed, will be marked as complete.
lunaria/files/en-GB.jsonLocalization changed, will be marked as complete.
lunaria/files/en-US.jsonSource changed, localizations will be marked as outdated.
lunaria/files/es-419.jsonLocalization changed, will be marked as complete.
lunaria/files/es-ES.jsonLocalization changed, will be marked as complete.
lunaria/files/fr-FR.jsonLocalization changed, will be marked as complete.
lunaria/files/hi-IN.jsonLocalization changed, will be marked as complete.
lunaria/files/hu-HU.jsonLocalization changed, will be marked as complete.
lunaria/files/id-ID.jsonLocalization changed, will be marked as complete.
lunaria/files/it-IT.jsonLocalization changed, will be marked as complete.
lunaria/files/ja-JP.jsonLocalization changed, will be marked as complete.
lunaria/files/ne-NP.jsonLocalization changed, will be marked as complete.
lunaria/files/no-NO.jsonLocalization changed, will be marked as complete.
lunaria/files/pl-PL.jsonLocalization changed, will be marked as complete.
lunaria/files/pt-BR.jsonLocalization changed, will be marked as complete.
lunaria/files/ru-RU.jsonLocalization changed, will be marked as complete.
lunaria/files/te-IN.jsonLocalization changed, will be marked as complete.
lunaria/files/uk-UA.jsonLocalization changed, will be marked as complete.
lunaria/files/zh-CN.jsonLocalization changed, will be marked as complete.
lunaria/files/zh-TW.jsonLocalization changed, will be marked as complete.
Warnings reference
IconDescription
🔄️The source for this localization has been updated since the creation of this pull request, make sure all changes in the source have been applied.

@codecov

codecovBot commented Feb 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 56.06936% with 76 lines in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing linesPatch %Lines
app/components/Package/TrendsChart.vue55.55%36 Missing and 8 partials ⚠️
app/composables/useCharts.ts54.28%22 Missing and 10 partials ⚠️

📢 Thoughts on this report? Let us know!

@serhalp
serhalp marked this pull request as ready for review February 10, 2026 02:58
Comment threadi18n/schema.json

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

It's kinda nice to see this as a diff in PRs actually 😁

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

it is!

what genius built it?

@coderabbitai

coderabbitaiBot commented Feb 10, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

This PR replaces DownloadAnalytics with a new TrendsChart component (enabled with show-facet-selector), generalises per-period types from *DownloadPoint to *DataPoint and renames the value field from downloadsvalue. It introduces a metric-driven state (downloads, likes) and per-metric loading/caching in TrendsChart, adds a showFacetSelector prop, and adds a modalTitle prop to ChartModal. useCharts was updated (renamed types, new fetchPackageLikesEvolution, exposed caches/clearClientCaches), server-side likes evolution API and PackageLikesUtils were added, many i18n keys were moved/removed, and tests were updated to the new shapes and components.

Possibly related PRs

Suggested reviewers

  • danielroe
🚥 Pre-merge checks | ✅ 1
✅ Passed checks (1 passed)
Check nameStatusExplanation
Description check✅ PassedThe pull request description is directly related to the changeset, detailing the generalization of the download analytics chart into a trends chart supporting downloads and likes with a facet selector.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/chart-likes

No actionable comments were generated in the recent review. 🎉


Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitaicoderabbitaiBot left a comment

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.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
i18n/locales/fr-FR.json (1)

317-446: ⚠️ Potential issue | 🟠 Major

Duplicate package.trends keys will override earlier translations.
JSON allows only one trends object at this level; the later block wins and the earlier block is ignored by parsers that accept duplicates. Please merge them into a single block so translations are deterministic.

🛠️ Suggested fix (merge into a single package.trends block)
 "trends": {
"granularity": "Granularité",
"granularity_daily": "Quotidien",
"granularity_weekly": "Hebdomadaire",
"granularity_monthly": "Mensuel",
"granularity_yearly": "Annuel",
"start_date": "Début",
"end_date": "Fin",
"loading": "Chargement...",
"date_range": "{start} au {end}",
"date_range_multiline": "{start}\nau {end}",
"download_file": "Télécharger {fileType}",
"toggle_annotator": "Afficher/Masquer l'annotateur",
"legend_estimation": "Estimation",
"no_data": "Données non disponibles",
"y_axis_label": "{facet} {granularity}",
- "items": {- "downloads": "Téléchargements"- }+ "facet": "Facette",+ "title": "Tendances",+ "items": {+ "downloads": "Téléchargements",+ "likes": "J'aime"+ }
},
@@
- "trends": {- "granularity": "Granularité",- "granularity_daily": "Quotidien",- "granularity_weekly": "Hebdomadaire",- "granularity_monthly": "Mensuel",- "granularity_yearly": "Annuel",- "start_date": "Début",- "end_date": "Fin",- "loading": "Chargement...",- "date_range": "{start} au {end}",- "date_range_multiline": "{start}\nau {end}",- "download_file": "Télécharger {fileType}",- "toggle_annotator": "Afficher/Masquer l'annotateur",- "legend_estimation": "Estimation",- "no_data": "Données non disponibles",- "y_axis_label": "{facet} {granularity}",- "facet": "Facette",- "title": "Tendances",- "items": {- "downloads": "Téléchargements",- "likes": "J'aime"- }- },
🧹 Nitpick comments (3)
app/components/Package/TrendsChart.vue (2)

602-604: Non-null assertion on find() result may cause runtime error.

METRICS.value.find(m => m.id === selectedMetric.value) can return undefined if no metric matches. Using ! here assumes the invariant always holds, but if selectedMetric were ever set to an invalid value (e.g., via external input or a bug), this would throw at runtime.

Consider adding a fallback or guard:

🛡️ Proposed defensive fix
 const activeMetricState = computed(() => metricStates[selectedMetric.value])
-const activeMetricDef = computed(() => METRICS.value.find(m => m.id === selectedMetric.value)!)+const activeMetricDef = computed(() => {+ const metric = METRICS.value.find(m => m.id === selectedMetric.value)+ if (!metric) {+ throw new Error(`Unknown metric: ${selectedMetric.value}`)+ }+ return metric+})

674-676: Repeated non-null assertion on find() result.

Same concern as activeMetricDef — if metricId doesn't match any entry in METRICS, the ! assertion will throw.

🛡️ Proposed defensive fix
 const state = metricStates[metricId]
- const metric = METRICS.value.find(m => m.id === metricId)!+ const metric = METRICS.value.find(m => m.id === metricId)+ if (!metric) {+ console.error(`Unknown metric: ${metricId}`)+ state.pending = false+ return+ }
const currentToken = ++state.requestToken
app/composables/useCharts.ts (1)

376-394: Consider adding TTL-based invalidation to match caching patterns used elsewhere.

The likesEvolutionCache lacks expiration logic and persists for the entire session. Once cached, new likes won't appear until the page is refreshed. Whilst this is acceptable for brief sessions, the codebase already establishes TTL patterns in other composables (e.g. useRepoMeta.ts uses a 10-minute TTL for repository metadata). A similar TTL here would ensure data freshness aligns with how frequently likes data changes.

Comment threadi18n/locales/fr-FR.json
Comment on lines +1 to +12
export default defineEventHandler(async event => {
const packageName = getRouterParam(event, 'pkg')
if (!packageName) {
throw createError({
status: 400,
message: 'package name not provided',
})
}

const likesUtil = new PackageLikesUtils()
return await likesUtil.getLikesEvolution(packageName)
})

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash# Check the codebase structure and find similar patternsecho"=== File structure ==="
ls -la server/api/social/ 2>/dev/null ||echo"Directory not found"
find server -type f -name "[...*.ts"2>/dev/null | head -20
echo -e "\n=== Checking other catch-all routes ==="
rg -l "\[\.\.\." server/api --type=ts 2>/dev/null | head -10

Repository: npmx-dev/npmx.dev

Length of output: 1313


🏁 Script executed:

#!/bin/bash# Check how PackageLikesUtils is used elsewhereecho"=== PackageLikesUtils usage ==="
rg -n "PackageLikesUtils" server --type=ts -A 2 -B 1
echo -e "\n=== Check if getLikesEvolution is used elsewhere ==="
rg -n "getLikesEvolution" server --type=ts -B 2 -A 2

Repository: npmx-dev/npmx.dev

Length of output: 2721


🏁 Script executed:

#!/bin/bash# Check similar route handlers to see if they normalize router paramsecho"=== Other handlers with getRouterParam ==="
rg -n "getRouterParam" server/api --type=ts -B 1 -A 1 | head -50
echo -e "\n=== Check for Array.isArray patterns with router params ==="
rg -n "Array\.isArray.*getRouterParam|Array\.isArray.*router" server --type=ts

Repository: npmx-dev/npmx.dev

Length of output: 3893


🏁 Script executed:

#!/bin/bash# Look at the actual file in context and search for related utilitiesecho"=== Content of the likes-evolution handler ==="
cat server/api/social/likes-evolution/\[...pkg\].get.ts 2>/dev/null || find server -name "*likes*evolution*" -type f
echo -e "\n=== Search for how routes handle scoped packages ==="
rg -n "@.*/" server/api --type=ts | head -20

Repository: npmx-dev/npmx.dev

Length of output: 2767


🏁 Script executed:

#!/bin/bash# Find parsePackageParams definition and usageecho"=== parsePackageParams definition ==="
rg -n "parsePackageParams" server --type=ts -B 2 -A 10 | head -80
echo -e "\n=== Check if parsePackageParams is used elsewhere ==="
rg -l "parsePackageParams" server --type=ts

Repository: npmx-dev/npmx.dev

Length of output: 6692


🏁 Script executed:

#!/bin/bash# Compare the two handlers side by sideecho"=== Likes handler (full content) ==="
cat server/api/social/likes/\[...pkg\].get.ts 2>/dev/null
echo -e "\n=== Likes-evolution handler (full content) ==="
cat server/api/social/likes-evolution/\[...pkg\].get.ts 2>/dev/null

Repository: npmx-dev/npmx.dev

Length of output: 1391


Use consistent package parameter parsing like the likes handler.

The handler should match the pattern in server/api/social/likes/[...pkg].get.ts: split the parameter, parse with parsePackageParams, validate with PackageRouteParamsSchema, and decode URI components. This ensures proper handling of scoped packages (e.g. @scope/package), versioning patterns (e.g. package/v/1.0.0), and URL-encoded characters.

✅ Suggested fix
+import * as v from 'valibot'+import { PackageRouteParamsSchema } from '#shared/schemas/package'+import { parsePackageParams } from '~/server/utils/parse-package-params'+
export default defineEventHandler(async event => {
- const packageName = getRouterParam(event, 'pkg')- if (!packageName) {+ const pkgParamSegments = getRouterParam(event, 'pkg')?.split('/') ?? []+ const { rawPackageName } = parsePackageParams(pkgParamSegments)++ if (!rawPackageName) {
throw createError({
status: 400,
message: 'package name not provided',
})
}
- const likesUtil = new PackageLikesUtils()- return await likesUtil.getLikesEvolution(packageName)+ const { packageName } = v.parse(PackageRouteParamsSchema, {+ packageName: decodeURIComponent(rawPackageName),+ })++ const likesUtil = new PackageLikesUtils()+ return await likesUtil.getLikesEvolution(packageName)
})

@graphierosgraphieros left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is beautiful refactoring ✨
Huge fan

@@ -1858,21 +1945,17 @@ const chartConfig = computed(() => {
<div class="min-h-[260px]" />

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I forgot about this placeholder.
We can probably have it fit the resolved size, to mitigate CLS when switching facets for the first time

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

would you like to follow up on that? 😁

createdIso?: string | null

/** When true, shows facet selector (e.g. Downloads / Likes). */
showFacetSelector?: boolean

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.

Is this needed? Seems like it's never false

Comment threadi18n/schema.json

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

it is!

what genius built it?

@danielroe
danielroe added this pull request to the merge queueFeb 10, 2026
Merged via the queue into main with commit c1f33acFeb 10, 2026
20 checks passed
@danielroe
danielroe deleted the feat/chart-likes branch February 10, 2026 13:05
@serhalpserhalp mentioned this pull request Feb 14, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@serhalp@Tobbe@danielroe@graphieros