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
14 changes: 9 additions & 5 deletions app/components/Package/TrendsChart.vue
Original file line number Diff line number Diff line change
Expand Up @@ -402,7 +402,9 @@ const isEndDateOnPeriodEnd = computed(() => {
})

const supportsEstimation = computed(
() => displayedGranularity.value !== 'daily' && selectedMetric.value !== 'contributors',
() =>
!['daily', 'weekly'].includes(displayedGranularity.value) &&
selectedMetric.value !== 'contributors',
)

const hasDownloadAnomalies = computed(() =>
Expand Down Expand Up @@ -1081,7 +1083,10 @@ const normalisedDataset = computed(() => {
{
averageWindow: settings.value.chartFilter.averageWindow,
smoothingTau: settings.value.chartFilter.smoothingTau,
predictionPoints: settings.value.chartFilter.predictionPoints ?? DEFAULT_PREDICTION_POINTS,
predictionPoints:
granularity === 'weekly'
? 0 // weekly buckets are end-aligned → always complete, no prediction needed
: (settings.value.chartFilter.predictionPoints ?? DEFAULT_PREDICTION_POINTS),
},
{ granularity, lastDateMs, referenceMs, isAbsoluteMetric },
)
Expand Down Expand Up @@ -1763,15 +1768,14 @@ watch(selectedMetric, value => {
</span>
<label
class="flex items-center gap-1.5 text-2xs font-mono text-fg-subtle cursor-pointer h-4"
:class="{ 'opacity-50 pointer-events-none': !hasAnomalies }"
:class="{ 'opacity-50': !hasAnomalies }"
>
<input
:checked="settings.chartFilter.anomaliesFixed && hasAnomalies"
:checked="settings.chartFilter.anomaliesFixed"
@change="
settings.chartFilter.anomaliesFixed = ($event.target as HTMLInputElement).checked
"
type="checkbox"
:disabled="!hasAnomalies"
class="accent-[var(--accent-color,var(--fg-subtle))]"
/>
{{ $t('package.trends.apply_correction') }}
Expand Down
165 changes: 30 additions & 135 deletions app/composables/useCharts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,18 @@ 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'
import { parseIsoDate, toIsoDate, addDays } from '~/utils/date'
import {
buildDailyEvolution,
buildWeeklyEvolution,
buildMonthlyEvolution,
buildYearlyEvolution,
} from '~/utils/chart-data-buckets'

export type PackumentLikeForTime = {
time?: Record<string, string>
}

function toIsoDateString(date: Date): string {
return date.toISOString().slice(0, 10)
}

function addDays(date: Date, days: number): Date {
const updatedDate = new Date(date)
updatedDate.setUTCDate(updatedDate.getUTCDate() + days)
return updatedDate
}

function startOfUtcMonth(date: Date): Date {
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1))
}
Expand All @@ -36,17 +33,9 @@ function startOfUtcYear(date: Date): Date {
return new Date(Date.UTC(date.getUTCFullYear(), 0, 1))
}

function parseIsoDateOnly(value: string): Date {
return new Date(`${value}T00:00:00.000Z`)
}

function formatIsoDateOnly(date: Date): string {
return date.toISOString().slice(0, 10)
}

function differenceInUtcDaysInclusive(startIso: string, endIso: string): number {
const start = parseIsoDateOnly(startIso)
const end = parseIsoDateOnly(endIso)
const start = parseIsoDate(startIso)
const end = parseIsoDate(endIso)
return Math.floor((end.getTime() - start.getTime()) / 86400000) + 1
}

Expand All @@ -59,16 +48,16 @@ function splitIsoRangeIntoChunksInclusive(
if (totalDays <= maximumDaysPerRequest) return [{ startIso, endIso }]

const chunks: Array<{ startIso: string; endIso: string }> = []
let cursorStart = parseIsoDateOnly(startIso)
const finalEnd = parseIsoDateOnly(endIso)
let cursorStart = parseIsoDate(startIso)
const finalEnd = parseIsoDate(endIso)

while (cursorStart.getTime() <= finalEnd.getTime()) {
const cursorEnd = addDays(cursorStart, maximumDaysPerRequest - 1)
const actualEnd = cursorEnd.getTime() < finalEnd.getTime() ? cursorEnd : finalEnd

chunks.push({
startIso: formatIsoDateOnly(cursorStart),
endIso: formatIsoDateOnly(actualEnd),
startIso: toIsoDate(cursorStart),
endIso: toIsoDate(actualEnd),
})

cursorStart = addDays(actualEnd, 1)
Expand All @@ -89,101 +78,6 @@ function mergeDailyPoints(points: DailyRawPoint[]): DailyRawPoint[] {
.map(([day, value]) => ({ day, value }))
}

export function buildDailyEvolutionFromDaily(daily: DailyRawPoint[]): DailyDataPoint[] {
return daily
.slice()
.sort((a, b) => a.day.localeCompare(b.day))
.map(item => {
const dayDate = parseIsoDateOnly(item.day)
const timestamp = dayDate.getTime()

return { day: item.day, value: item.value, timestamp }
})
}

export function buildRollingWeeklyEvolutionFromDaily(
daily: DailyRawPoint[],
rangeStartIso: string,
rangeEndIso: string,
): WeeklyDataPoint[] {
const sorted = daily.slice().sort((a, b) => a.day.localeCompare(b.day))
const rangeStartDate = parseIsoDateOnly(rangeStartIso)
const rangeEndDate = parseIsoDateOnly(rangeEndIso)

const groupedByIndex = new Map<number, number>()

for (const item of sorted) {
const itemDate = parseIsoDateOnly(item.day)
const dayOffset = Math.floor((itemDate.getTime() - rangeStartDate.getTime()) / 86400000)
if (dayOffset < 0) continue

const weekIndex = Math.floor(dayOffset / 7)
groupedByIndex.set(weekIndex, (groupedByIndex.get(weekIndex) ?? 0) + item.value)
}

return Array.from(groupedByIndex.entries())
.sort(([a], [b]) => a - b)
.map(([weekIndex, value]) => {
const weekStartDate = addDays(rangeStartDate, weekIndex * 7)
const weekEndDate = addDays(weekStartDate, 6)

// Clamp weekEnd to the actual data range end date
const clampedWeekEndDate =
weekEndDate.getTime() > rangeEndDate.getTime() ? rangeEndDate : weekEndDate

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

const timestampStart = weekStartDate.getTime()
const timestampEnd = clampedWeekEndDate.getTime()

return {
value,
weekKey: `${weekStartIso}_${weekEndIso}`,
weekStart: weekStartIso,
weekEnd: weekEndIso,
timestampStart,
timestampEnd,
}
})
}

export function buildMonthlyEvolutionFromDaily(daily: DailyRawPoint[]): MonthlyDataPoint[] {
const sorted = daily.slice().sort((a, b) => a.day.localeCompare(b.day))
const valuesByMonth = new Map<string, number>()

for (const item of sorted) {
const month = item.day.slice(0, 7)
valuesByMonth.set(month, (valuesByMonth.get(month) ?? 0) + item.value)
}

return Array.from(valuesByMonth.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([month, value]) => {
const monthStartDate = parseIsoDateOnly(`${month}-01`)
const timestamp = monthStartDate.getTime()
return { month, value, timestamp }
})
}

export function buildYearlyEvolutionFromDaily(daily: DailyRawPoint[]): YearlyDataPoint[] {
const sorted = daily.slice().sort((a, b) => a.day.localeCompare(b.day))
const valuesByYear = new Map<string, number>()

for (const item of sorted) {
const year = item.day.slice(0, 4)
valuesByYear.set(year, (valuesByYear.get(year) ?? 0) + item.value)
}

return Array.from(valuesByYear.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([year, value]) => {
const yearStartDate = parseIsoDateOnly(`${year}-01-01`)
const timestamp = yearStartDate.getTime()
return { year, value, timestamp }
})
}

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
Expand Down Expand Up @@ -238,8 +132,8 @@ function buildWeeklyEvolutionFromContributorCounts(

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

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

return {
value,
Expand Down Expand Up @@ -415,11 +309,11 @@ export function useCharts() {
)

const endDateOnly = toDateOnly(evolutionOptions.endDate)
const end = endDateOnly ? parseIsoDateOnly(endDateOnly) : yesterday
const end = endDateOnly ? parseIsoDate(endDateOnly) : yesterday

const startDateOnly = toDateOnly(evolutionOptions.startDate)
if (startDateOnly) {
const start = parseIsoDateOnly(startDateOnly)
const start = parseIsoDate(startDateOnly)
return { start, end }
Comment on lines 311 to 317
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 | 🟡 Minor

Handle invalid calendar dates before using parsed values.

toDateOnly checks format only. A value like 2026-02-31 still passes, then parseIsoDate can produce an invalid date and propagate NaN behaviour. Please guard parsed dates and fall back to defaults.

Proposed fix
   const endDateOnly = toDateOnly(evolutionOptions.endDate)
-  const end = endDateOnly ? parseIsoDate(endDateOnly) : yesterday
+  const parsedEnd = endDateOnly ? parseIsoDate(endDateOnly) : null
+  const end = parsedEnd && !Number.isNaN(parsedEnd.getTime()) ? parsedEnd : yesterday

   const startDateOnly = toDateOnly(evolutionOptions.startDate)
   if (startDateOnly) {
-    const start = parseIsoDate(startDateOnly)
-    return { start, end }
+    const parsedStart = parseIsoDate(startDateOnly)
+    if (!Number.isNaN(parsedStart.getTime())) {
+      return { start: parsedStart, end }
+    }
   }

As per coding guidelines "Use error handling patterns consistently".

📝 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
const endDateOnly = toDateOnly(evolutionOptions.endDate)
const end = endDateOnly ? parseIsoDateOnly(endDateOnly) : yesterday
const end = endDateOnly ? parseIsoDate(endDateOnly) : yesterday
const startDateOnly = toDateOnly(evolutionOptions.startDate)
if (startDateOnly) {
const start = parseIsoDateOnly(startDateOnly)
const start = parseIsoDate(startDateOnly)
return { start, end }
const endDateOnly = toDateOnly(evolutionOptions.endDate)
const parsedEnd = endDateOnly ? parseIsoDate(endDateOnly) : null
const end = parsedEnd && !Number.isNaN(parsedEnd.getTime()) ? parsedEnd : yesterday
const startDateOnly = toDateOnly(evolutionOptions.startDate)
if (startDateOnly) {
const parsedStart = parseIsoDate(startDateOnly)
if (!Number.isNaN(parsedStart.getTime())) {
return { start: parsedStart, end }
}
}

}

Expand Down Expand Up @@ -465,16 +359,17 @@ export function useCharts() {

const { start, end } = resolveDateRange(resolvedOptions, resolvedCreatedIso)

const startIso = toIsoDateString(start)
const endIso = toIsoDateString(end)
const startIso = toIsoDate(start)
const endIso = toIsoDate(end)

const sortedDaily = await fetchDailyRangeChunked(resolvedPackageName, startIso, endIso)

if (resolvedOptions.granularity === 'day') return buildDailyEvolutionFromDaily(sortedDaily)
if (resolvedOptions.granularity === 'day') return buildDailyEvolution(sortedDaily)
if (resolvedOptions.granularity === 'week')
return buildRollingWeeklyEvolutionFromDaily(sortedDaily, startIso, endIso)
if (resolvedOptions.granularity === 'month') return buildMonthlyEvolutionFromDaily(sortedDaily)
return buildYearlyEvolutionFromDaily(sortedDaily)
return buildWeeklyEvolution(sortedDaily, startIso, endIso)
if (resolvedOptions.granularity === 'month')
return buildMonthlyEvolution(sortedDaily, startIso, endIso)
return buildYearlyEvolution(sortedDaily, startIso, endIso)
}

async function fetchPackageLikesEvolution(
Expand Down Expand Up @@ -508,17 +403,17 @@ export function useCharts() {
const sortedDaily = await dailyLikesPromise

const { start, end } = resolveDateRange(resolvedOptions, null)
const startIso = toIsoDateString(start)
const endIso = toIsoDateString(end)
const startIso = toIsoDate(start)
const endIso = toIsoDate(end)

const filteredDaily = sortedDaily.filter(d => d.day >= startIso && d.day <= endIso)

if (resolvedOptions.granularity === 'day') return buildDailyEvolutionFromDaily(filteredDaily)
if (resolvedOptions.granularity === 'day') return buildDailyEvolution(filteredDaily)
if (resolvedOptions.granularity === 'week')
return buildRollingWeeklyEvolutionFromDaily(filteredDaily, startIso, endIso)
return buildWeeklyEvolution(filteredDaily, startIso, endIso)
if (resolvedOptions.granularity === 'month')
return buildMonthlyEvolutionFromDaily(filteredDaily)
return buildYearlyEvolutionFromDaily(filteredDaily)
return buildMonthlyEvolution(filteredDaily, startIso, endIso)
return buildYearlyEvolution(filteredDaily, startIso, endIso)
}

async function fetchRepoContributorsEvolution(
Expand Down
Loading
Loading