Skip to content

Conversation

@serhalp
Copy link
Contributor

@serhalp serhalp commented Feb 10, 2026

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
Copy link

vercel bot commented Feb 10, 2026

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

Project Deployment Actions Updated (UTC)
npmx.dev Ready Ready Preview, Comment Feb 10, 2026 3:43am
2 Skipped Deployments
Project Deployment Actions Updated (UTC)
docs.npmx.dev Ignored Ignored Preview Feb 10, 2026 3:43am
npmx-lunaria Ignored Ignored Feb 10, 2026 3:43am

Request Review

@github-actions
Copy link

github-actions bot commented Feb 10, 2026

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

File Note
lunaria/files/ar-EG.json Localization changed, will be marked as complete.
lunaria/files/az-AZ.json Localization changed, will be marked as complete.
lunaria/files/bn-IN.json Localization changed, will be marked as complete.
lunaria/files/cs-CZ.json Localization changed, will be marked as complete.
lunaria/files/de-DE.json Localization changed, will be marked as complete.
lunaria/files/en-GB.json Localization changed, will be marked as complete.
lunaria/files/en-US.json Source changed, localizations will be marked as outdated.
lunaria/files/es-419.json Localization changed, will be marked as complete.
lunaria/files/es-ES.json Localization changed, will be marked as complete.
lunaria/files/fr-FR.json Localization changed, will be marked as complete.
lunaria/files/hi-IN.json Localization changed, will be marked as complete.
lunaria/files/hu-HU.json Localization changed, will be marked as complete.
lunaria/files/id-ID.json Localization changed, will be marked as complete.
lunaria/files/it-IT.json Localization changed, will be marked as complete.
lunaria/files/ja-JP.json Localization changed, will be marked as complete.
lunaria/files/ne-NP.json Localization changed, will be marked as complete.
lunaria/files/no-NO.json Localization changed, will be marked as complete.
lunaria/files/pl-PL.json Localization changed, will be marked as complete.
lunaria/files/pt-BR.json Localization changed, will be marked as complete.
lunaria/files/ru-RU.json Localization changed, will be marked as complete.
lunaria/files/te-IN.json Localization changed, will be marked as complete.
lunaria/files/uk-UA.json Localization changed, will be marked as complete.
lunaria/files/zh-CN.json Localization changed, will be marked as complete.
lunaria/files/zh-TW.json Localization changed, will be marked as complete.
Warnings reference
Icon Description
🔄️ 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
Copy link

codecov bot commented Feb 10, 2026

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 lines Patch % Lines
app/components/Package/TrendsChart.vue 55.55% 36 Missing and 8 partials ⚠️
app/composables/useCharts.ts 54.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
@serhalp serhalp requested a review from graphieros February 10, 2026 02:58
Copy link
Contributor Author

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 😁

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 10, 2026

📝 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 name Status Explanation
Description check ✅ Passed The 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.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

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 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
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 patterns
echo "=== 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 elsewhere
echo "=== 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 params
echo "=== 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 utilities
echo "=== 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 usage
echo "=== 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 side
echo "=== 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)
 })

Copy link
Contributor

@graphieros graphieros left a comment

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
Contributor

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

createdIso?: string | null

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

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

Sign up for free to 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.

3 participants