Skip to content

feat: fix known download anomalies with interpolation#1636

Open
jycouet wants to merge 9 commits intonpmx-dev:mainfrom
jycouet:feat/clean-up-graphs
Open

feat: fix known download anomalies with interpolation#1636
jycouet wants to merge 9 commits intonpmx-dev:mainfrom
jycouet:feat/clean-up-graphs

Conversation

@jycouet
Copy link
Contributor

@jycouet jycouet commented Feb 25, 2026

🔗 Linked issue

Closes #1241

🧭 Context

Having nicer charts in general without data going off!

Right now, this PR is the Option 4 with these defaults:

chartFilter: {
  averageWindow: 0,
  smoothingTau: 1,
  anomaliesFixed: true,
},

FYI, I split commits to be able to go back to other options, if you want to try on some packages :)

📚 Description

1/ Hampel

2026.02.24.-.Hampel.mp4

Was good, be it's a lot of variables... And it's hard to get all situations correct

2/ Outlier Sensitivity

2026.02.24.-.Outlier.Sensitivity.mp4

Same thing... I tried with some linear reduction & some tweaks to keep first an last point. Good for some... not the bast for others...

3/ 2 Passes Avg or Smoothing

2026.02.24.-.2.Passes.avg.or.Smoothing.mp4

Std Average / Smoothing are not so good because they change the start or end. But here in the 2 passes process we minimise this effect.

4/ 2 Passes Avg or Smoothing & Manual tuning

2026.02.24.-.Manu.mp4

This is actually the best! Manually declaring where it's wrong. In the vite example it's obvious, so we can just add it. And the community is soooo into it that we could encourage people to enter things there (we could even offer an API at some point).

Keeping the option for Avg & Smooth to have a good mix.


What do you think?!

@vercel
Copy link

vercel bot commented Feb 25, 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 26, 2026 11:48am
2 Skipped Deployments
Project Deployment Actions Updated (UTC)
docs.npmx.dev Ignored Ignored Preview Feb 26, 2026 11:48am
npmx-lunaria Ignored Ignored Feb 26, 2026 11:48am

Request Review

@codecov
Copy link

codecov bot commented Feb 25, 2026

Codecov Report

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

Files with missing lines Patch % Lines
app/utils/download-anomalies.ts 5.45% 42 Missing and 10 partials ⚠️
app/utils/chart-filters.ts 57.14% 18 Missing ⚠️
app/components/Package/TrendsChart.vue 70.58% 4 Missing and 6 partials ⚠️

📢 Thoughts on this report? Let us know!

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 25, 2026

📝 Walkthrough

Walkthrough

Adds client-side download-chart filtering and anomaly mitigation. New utilities implement position-weighted moving average and forward–backward exponential smoothing plus an anomaly interpolation filter and a small anomalies dataset. App settings gain a chartFilter object with averageWindow, smoothingTau and anomaliesFixed defaults. TrendsChart.vue and WeeklyDownloadStats.vue apply the blocklist/anomaly filter and the download filter when the downloads metric is active, and the Trends UI exposes controls for average window, smoothing tau and an "anomalies fixed" toggle. i18n keys and schema entries for the filter controls were added.

Possibly related PRs

Suggested reviewers

  • 43081j
  • danielroe
🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description check ✅ Passed The PR description relates to the changeset by explaining the context, approach (Option 4), and defaults for the chart filtering feature being implemented.
Linked Issues check ✅ Passed The PR implements core requirements from #1241: removes outliers via manual declaration (applyBlocklistFilter), handles zeroed data through interpolation, and provides UI controls for smoothing and averaging. Two-pass smoothing and manual tuning via DOWNLOAD_ANOMALIES fulfil the approach.
Out of Scope Changes check ✅ Passed All changes are within scope: new chart filtering utilities, anomaly detection/interpolation, UI controls, and i18n translations directly support the linked issue objectives without extraneous modifications.

✏️ 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

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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: 1

🧹 Nitpick comments (5)
app/utils/chart-filters.ts (1)

9-10: No-op paths return the original array reference — document the contract.

Both movingAverage and smoothing return the input data array unchanged when the guard condition fires. This means any caller that mutates the returned array will also mutate the original. Current callers in the pipeline are safe, but this is a non-obvious contract that could cause subtle bugs if new callers are added without reading the source.

♻️ Suggested: add a clarifying note to the JSDoc
 /**
  * Bidirectional moving average. …
  * First and last points are preserved.
  *
  * `@param` halfWindow - number of points on each side (0 = disabled)
+ * `@returns` A new array when filtering is applied; the original `data` reference when the guard fires (no-op).
  */
 export function movingAverage<T extends { value: number }>(data: T[], halfWindow: number): T[] {
 /**
  * Forward-backward exponential smoothing (zero-phase). …
  * First and last points are preserved.
  *
  * `@param` tau - time constant (0 = disabled, higher = smoother)
+ * `@returns` A new array when filtering is applied; the original `data` reference when the guard fires (no-op).
  */
 export function smoothing<T extends { value: number }>(data: T[], tau: number): T[] {
app/utils/download-anomalies.ts (2)

15-26: Record<string, any> silently hides missing-field mismatches.

getDateString accesses point.day, point.weekStart, point.month, and point.year without any guard. If a data point is missing the expected field, the function returns undefined coerced to "undefined", which silently produces a no-match rather than a loud error. A discriminated-union parameter type (or at minimum an intersection with { value: number }) would surface the issue at the call site.

♻️ Suggested: narrow the parameter type
-function getDateString(point: Record<string, any>, granularity: ChartTimeGranularity): string {
+function getDateString(
+  point: Record<string, unknown>,
+  granularity: ChartTimeGranularity,
+): string {
   switch (granularity) {
     case 'daily':
-      return point.day
+      return String(point.day ?? '')
     case 'weekly':
-      return point.weekStart
+      return String(point.weekStart ?? '')
     case 'monthly':
-      return `${point.month}-01`
+      return `${String(point.month ?? '')}-01`
     case 'yearly':
-      return `${point.year}-01-01`
+      return `${String(point.year ?? '')}-01-01`
   }
 }

55-66: Monthly/yearly scaling uses calendar approximations — worth documenting.

Math.round((weeklyValue / 7) * 30) treats every month as exactly 30 days and * 365 treats every year as 365 days (ignoring leap years). For anomaly correction purposes these approximations are reasonable, but a brief inline comment would make the intent clear to future contributors who might otherwise reach for a calendar-aware alternative.

♻️ Suggested: add clarifying comments
   case 'monthly':
-    return Math.round((weeklyValue / 7) * 30)
+    return Math.round((weeklyValue / 7) * 30) // approximate: treats all months as 30 days
   case 'yearly':
-    return Math.round((weeklyValue / 7) * 365)
+    return Math.round((weeklyValue / 7) * 365) // approximate: treats all years as 365 days
app/components/Package/TrendsChart.vue (2)

1614-1615: isDownloadsMetric is declared 670+ lines after its first use — consider hoisting.

isDownloadsMetric is referenced inside the effectiveDataSingle (line 946) and chartData (line 990) computed properties, but is not declared until line 1614. Vue's lazy computed evaluation means this works at runtime (no TDZ crash), but the declaration order makes the dependency chain very hard to follow and is contrary to the convention in the rest of the file where helpers are declared before their consumers.

♻️ Suggested: hoist the declaration above `effectiveDataSingle`
+const isDownloadsMetric = computed(() => selectedMetric.value === 'downloads')
+const showFilterControls = shallowRef(false)
+
 const effectiveDataSingle = computed<EvolutionData>(() => {

…and remove the duplicate declarations at lines 1614–1615.


946-955: Download-filter logic is duplicated between effectiveDataSingle and chartData — extract a helper.

The anomaliesFixedapplyBlocklistFilterapplyDownloadFilter chain appears verbatim in both computed properties. Any future change to the pipeline (e.g. adding a new filter step) must be applied in both places.

♻️ Suggested: extract a shared helper
+function applyDownloadsFilters(
+  data: EvolutionData,
+  pkg: string,
+  granularity: ChartTimeGranularity,
+): EvolutionData {
+  let result = data
+  if (settings.value.chartFilter.anomaliesFixed) {
+    result = applyBlocklistFilter(result, pkg, granularity)
+  }
+  return applyDownloadFilter(
+    result as Array<{ value: number }>,
+    settings.value.chartFilter,
+  ) as EvolutionData
+}

Then in effectiveDataSingle:

-  if (isDownloadsMetric.value && data.length) {
-    const pkg = effectivePackageNames.value[0] ?? props.packageName ?? ''
-    if (settings.value.chartFilter.anomaliesFixed) {
-      data = applyBlocklistFilter(data, pkg, displayedGranularity.value)
-    }
-    return applyDownloadFilter(
-      data as Array<{ value: number }>,
-      settings.value.chartFilter,
-    ) as EvolutionData
-  }
+  if (isDownloadsMetric.value && data.length) {
+    const pkg = effectivePackageNames.value[0] ?? props.packageName ?? ''
+    return applyDownloadsFilters(data, pkg, displayedGranularity.value)
+  }

And in chartData:

-    if (isDownloadsMetric.value && data.length) {
-      if (settings.value.chartFilter.anomaliesFixed) {
-        data = applyBlocklistFilter(data, pkg, granularity)
-      }
-      data = applyDownloadFilter(
-        data as Array<{ value: number }>,
-        settings.value.chartFilter,
-      ) as EvolutionData
-    }
+    if (isDownloadsMetric.value && data.length) {
+      data = applyDownloadsFilters(data, pkg, granularity)
+    }

Also applies to: 990-999


ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 7f7c2e9 and 88b2745.

📒 Files selected for processing (6)
  • app/components/Package/TrendsChart.vue
  • app/components/Package/WeeklyDownloadStats.vue
  • app/composables/useSettings.ts
  • app/utils/chart-filters.ts
  • app/utils/download-anomalies.data.ts
  • app/utils/download-anomalies.ts

@github-actions
Copy link

github-actions bot commented Feb 25, 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/en-GB.json Localization changed, will be marked as complete. 🔄️
lunaria/files/en-US.json Source changed, localizations will be marked as outdated.
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.

@graphieros
Copy link
Contributor

graphieros commented Feb 25, 2026

@jycouet

This looks awesome :)
I do like the last option a lot.

A few remarks before I take a look at the code, regarding the labels for the range inputs.
I think the labels should either be more explicit about what they do:

  • Summary label: 'Filters' is probably too broad. Maybe something more precise like "Data correction controls" could be more explicit ?
  • Same for the labels of the range inputs, "Average window" perhaps ? "Smoothing" is fine, maybe something like "Noise reduction" ?

Also, perhaps an info icon with a tooltip could explain what the checkbox does (and only showing the checkbox when the 'database' contains corrections ?)

Finally, I could not test on a newly created package that has only one download datapoint.

Copy link
Contributor

Choose a reason for hiding this comment

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

I would suggest renaming this file to something like 'chart-data-correction'

/**
* Applies moving average then smoothing in sequence.
*/
export function applyDownloadFilter<T extends { value: number }>(
Copy link
Contributor

Choose a reason for hiding this comment

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

I would suggest renaming this function to something like applyDataCorrection

}
}

export function applyBlocklistFilter(
Copy link
Contributor

Choose a reason for hiding this comment

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

I would suggest renaming this function to something like applyBlocklistCorrection

() => loadWeeklyDownloads(),
)

const filteredDownloads = computed<WeeklyDataPoint[]>(() => {
Copy link
Contributor

Choose a reason for hiding this comment

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

I would suggest renaming this var to something like correctedDownloads

data: EvolutionData,
packageName: string,
granularity: ChartTimeGranularity,
): EvolutionData {
Copy link
Contributor

Choose a reason for hiding this comment

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

It would probably be better to have the 3 params as an object, to have call sites more readable

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.

🧹 Nitpick comments (2)
app/components/Package/TrendsChart.vue (2)

1720-1734: Add ARIA attributes for accessible collapsible section.

The toggle button should include aria-expanded and aria-controls attributes for screen reader users to understand the collapsible state and relationship.

♻️ Suggested accessibility improvement
       <button
         type="button"
         class="self-start flex items-center gap-1 text-2xs font-mono text-fg-subtle hover:text-fg transition-colors"
+        :aria-expanded="showFilterControls"
+        aria-controls="download-filter-controls"
         `@click`="showFilterControls = !showFilterControls"
       >
         <span
           class="w-3.5 h-3.5 transition-transform"
           :class="showFilterControls ? 'i-lucide:chevron-down' : 'i-lucide:chevron-right'"
           aria-hidden="true"
         />
         {{ $t('package.trends.filters') }}
       </button>
-      <div v-if="showFilterControls" class="flex items-end gap-3">
+      <div v-if="showFilterControls" id="download-filter-controls" class="flex items-end gap-3">

1763-1772: Consider UX feedback from PR discussion.

Per the PR comments, graphieros suggested adding an info icon with a tooltip explaining the "Anomalies fixed" checkbox behaviour, and potentially showing this checkbox only when the database contains corrections for the current package. This would improve clarity for users unfamiliar with the feature.


ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c5faf4c and 5fa129e.

📒 Files selected for processing (6)
  • app/components/Package/TrendsChart.vue
  • app/components/Package/WeeklyDownloadStats.vue
  • i18n/locales/en.json
  • i18n/schema.json
  • lunaria/files/en-GB.json
  • lunaria/files/en-US.json
🚧 Files skipped from review as they are similar to previous changes (5)
  • app/components/Package/WeeklyDownloadStats.vue
  • i18n/schema.json
  • lunaria/files/en-GB.json
  • lunaria/files/en-US.json
  • i18n/locales/en.json

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.

🧹 Nitpick comments (1)
lunaria/files/en-GB.json (1)

381-384: Consider more descriptive labels per PR feedback.

The translations are functional, but the PR comments from graphieros suggest more explicit labels that could improve user understanding:

Current Suggested
"Filters" "Data correction controls"
"Average" "Average window"
"Smoothing" "Noise reduction"

These suggestions aim to make the controls' purpose clearer to users unfamiliar with the chart filtering feature. Consider whether adopting some or all of these would benefit the UX.


ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 5fa129e and f597318.

📒 Files selected for processing (4)
  • i18n/locales/en.json
  • i18n/schema.json
  • lunaria/files/en-GB.json
  • lunaria/files/en-US.json
🚧 Files skipped from review as they are similar to previous changes (2)
  • lunaria/files/en-US.json
  • i18n/locales/en.json

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.

clean up npm downloads data for graphs

2 participants