Skip to content

Commit 8976969

Browse files
authored
fix: align weekly chart buckets from end to match npm downloads (#2052)
1 parent 0d0707f commit 8976969

File tree

8 files changed

+537
-260
lines changed

8 files changed

+537
-260
lines changed

app/components/Package/TrendsChart.vue

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -402,7 +402,9 @@ const isEndDateOnPeriodEnd = computed(() => {
402402
})
403403
404404
const supportsEstimation = computed(
405-
() => displayedGranularity.value !== 'daily' && selectedMetric.value !== 'contributors',
405+
() =>
406+
!['daily', 'weekly'].includes(displayedGranularity.value) &&
407+
selectedMetric.value !== 'contributors',
406408
)
407409
408410
const hasDownloadAnomalies = computed(() =>
@@ -1081,7 +1083,10 @@ const normalisedDataset = computed(() => {
10811083
{
10821084
averageWindow: settings.value.chartFilter.averageWindow,
10831085
smoothingTau: settings.value.chartFilter.smoothingTau,
1084-
predictionPoints: settings.value.chartFilter.predictionPoints ?? DEFAULT_PREDICTION_POINTS,
1086+
predictionPoints:
1087+
granularity === 'weekly'
1088+
? 0 // weekly buckets are end-aligned → always complete, no prediction needed
1089+
: (settings.value.chartFilter.predictionPoints ?? DEFAULT_PREDICTION_POINTS),
10851090
},
10861091
{ granularity, lastDateMs, referenceMs, isAbsoluteMetric },
10871092
)
@@ -1763,15 +1768,14 @@ watch(selectedMetric, value => {
17631768
</span>
17641769
<label
17651770
class="flex items-center gap-1.5 text-2xs font-mono text-fg-subtle cursor-pointer h-4"
1766-
:class="{ 'opacity-50 pointer-events-none': !hasAnomalies }"
1771+
:class="{ 'opacity-50': !hasAnomalies }"
17671772
>
17681773
<input
1769-
:checked="settings.chartFilter.anomaliesFixed && hasAnomalies"
1774+
:checked="settings.chartFilter.anomaliesFixed"
17701775
@change="
17711776
settings.chartFilter.anomaliesFixed = ($event.target as HTMLInputElement).checked
17721777
"
17731778
type="checkbox"
1774-
:disabled="!hasAnomalies"
17751779
class="accent-[var(--accent-color,var(--fg-subtle))]"
17761780
/>
17771781
{{ $t('package.trends.apply_correction') }}

app/composables/useCharts.ts

Lines changed: 30 additions & 135 deletions
Original file line numberDiff line numberDiff line change
@@ -13,21 +13,18 @@ import { parseRepoUrl } from '#shared/utils/git-providers'
1313
import type { PackageMetaResponse } from '#shared/types'
1414
import { encodePackageName } from '#shared/utils/npm'
1515
import { fetchNpmDownloadsRange } from '~/utils/npm/api'
16+
import { parseIsoDate, toIsoDate, addDays } from '~/utils/date'
17+
import {
18+
buildDailyEvolution,
19+
buildWeeklyEvolution,
20+
buildMonthlyEvolution,
21+
buildYearlyEvolution,
22+
} from '~/utils/chart-data-buckets'
1623

1724
export type PackumentLikeForTime = {
1825
time?: Record<string, string>
1926
}
2027

21-
function toIsoDateString(date: Date): string {
22-
return date.toISOString().slice(0, 10)
23-
}
24-
25-
function addDays(date: Date, days: number): Date {
26-
const updatedDate = new Date(date)
27-
updatedDate.setUTCDate(updatedDate.getUTCDate() + days)
28-
return updatedDate
29-
}
30-
3128
function startOfUtcMonth(date: Date): Date {
3229
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1))
3330
}
@@ -36,17 +33,9 @@ function startOfUtcYear(date: Date): Date {
3633
return new Date(Date.UTC(date.getUTCFullYear(), 0, 1))
3734
}
3835

39-
function parseIsoDateOnly(value: string): Date {
40-
return new Date(`${value}T00:00:00.000Z`)
41-
}
42-
43-
function formatIsoDateOnly(date: Date): string {
44-
return date.toISOString().slice(0, 10)
45-
}
46-
4736
function differenceInUtcDaysInclusive(startIso: string, endIso: string): number {
48-
const start = parseIsoDateOnly(startIso)
49-
const end = parseIsoDateOnly(endIso)
37+
const start = parseIsoDate(startIso)
38+
const end = parseIsoDate(endIso)
5039
return Math.floor((end.getTime() - start.getTime()) / 86400000) + 1
5140
}
5241

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

6150
const chunks: Array<{ startIso: string; endIso: string }> = []
62-
let cursorStart = parseIsoDateOnly(startIso)
63-
const finalEnd = parseIsoDateOnly(endIso)
51+
let cursorStart = parseIsoDate(startIso)
52+
const finalEnd = parseIsoDate(endIso)
6453

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

6958
chunks.push({
70-
startIso: formatIsoDateOnly(cursorStart),
71-
endIso: formatIsoDateOnly(actualEnd),
59+
startIso: toIsoDate(cursorStart),
60+
endIso: toIsoDate(actualEnd),
7261
})
7362

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

92-
export function buildDailyEvolutionFromDaily(daily: DailyRawPoint[]): DailyDataPoint[] {
93-
return daily
94-
.slice()
95-
.sort((a, b) => a.day.localeCompare(b.day))
96-
.map(item => {
97-
const dayDate = parseIsoDateOnly(item.day)
98-
const timestamp = dayDate.getTime()
99-
100-
return { day: item.day, value: item.value, timestamp }
101-
})
102-
}
103-
104-
export function buildRollingWeeklyEvolutionFromDaily(
105-
daily: DailyRawPoint[],
106-
rangeStartIso: string,
107-
rangeEndIso: string,
108-
): WeeklyDataPoint[] {
109-
const sorted = daily.slice().sort((a, b) => a.day.localeCompare(b.day))
110-
const rangeStartDate = parseIsoDateOnly(rangeStartIso)
111-
const rangeEndDate = parseIsoDateOnly(rangeEndIso)
112-
113-
const groupedByIndex = new Map<number, number>()
114-
115-
for (const item of sorted) {
116-
const itemDate = parseIsoDateOnly(item.day)
117-
const dayOffset = Math.floor((itemDate.getTime() - rangeStartDate.getTime()) / 86400000)
118-
if (dayOffset < 0) continue
119-
120-
const weekIndex = Math.floor(dayOffset / 7)
121-
groupedByIndex.set(weekIndex, (groupedByIndex.get(weekIndex) ?? 0) + item.value)
122-
}
123-
124-
return Array.from(groupedByIndex.entries())
125-
.sort(([a], [b]) => a - b)
126-
.map(([weekIndex, value]) => {
127-
const weekStartDate = addDays(rangeStartDate, weekIndex * 7)
128-
const weekEndDate = addDays(weekStartDate, 6)
129-
130-
// Clamp weekEnd to the actual data range end date
131-
const clampedWeekEndDate =
132-
weekEndDate.getTime() > rangeEndDate.getTime() ? rangeEndDate : weekEndDate
133-
134-
const weekStartIso = toIsoDateString(weekStartDate)
135-
const weekEndIso = toIsoDateString(clampedWeekEndDate)
136-
137-
const timestampStart = weekStartDate.getTime()
138-
const timestampEnd = clampedWeekEndDate.getTime()
139-
140-
return {
141-
value,
142-
weekKey: `${weekStartIso}_${weekEndIso}`,
143-
weekStart: weekStartIso,
144-
weekEnd: weekEndIso,
145-
timestampStart,
146-
timestampEnd,
147-
}
148-
})
149-
}
150-
151-
export function buildMonthlyEvolutionFromDaily(daily: DailyRawPoint[]): MonthlyDataPoint[] {
152-
const sorted = daily.slice().sort((a, b) => a.day.localeCompare(b.day))
153-
const valuesByMonth = new Map<string, number>()
154-
155-
for (const item of sorted) {
156-
const month = item.day.slice(0, 7)
157-
valuesByMonth.set(month, (valuesByMonth.get(month) ?? 0) + item.value)
158-
}
159-
160-
return Array.from(valuesByMonth.entries())
161-
.sort(([a], [b]) => a.localeCompare(b))
162-
.map(([month, value]) => {
163-
const monthStartDate = parseIsoDateOnly(`${month}-01`)
164-
const timestamp = monthStartDate.getTime()
165-
return { month, value, timestamp }
166-
})
167-
}
168-
169-
export function buildYearlyEvolutionFromDaily(daily: DailyRawPoint[]): YearlyDataPoint[] {
170-
const sorted = daily.slice().sort((a, b) => a.day.localeCompare(b.day))
171-
const valuesByYear = new Map<string, number>()
172-
173-
for (const item of sorted) {
174-
const year = item.day.slice(0, 4)
175-
valuesByYear.set(year, (valuesByYear.get(year) ?? 0) + item.value)
176-
}
177-
178-
return Array.from(valuesByYear.entries())
179-
.sort(([a], [b]) => a.localeCompare(b))
180-
.map(([year, value]) => {
181-
const yearStartDate = parseIsoDateOnly(`${year}-01-01`)
182-
const timestamp = yearStartDate.getTime()
183-
return { year, value, timestamp }
184-
})
185-
}
186-
18781
const npmDailyRangeCache = import.meta.client ? new Map<string, Promise<DailyRawPoint[]>>() : null
18882
const likesEvolutionCache = import.meta.client ? new Map<string, Promise<DailyRawPoint[]>>() : null
18983
const contributorsEvolutionCache = import.meta.client
@@ -238,8 +132,8 @@ function buildWeeklyEvolutionFromContributorCounts(
238132

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

241-
const weekStartIso = toIsoDateString(weekStartDate)
242-
const weekEndIso = toIsoDateString(clampedWeekEndDate)
135+
const weekStartIso = toIsoDate(weekStartDate)
136+
const weekEndIso = toIsoDate(clampedWeekEndDate)
243137

244138
return {
245139
value,
@@ -415,11 +309,11 @@ export function useCharts() {
415309
)
416310

417311
const endDateOnly = toDateOnly(evolutionOptions.endDate)
418-
const end = endDateOnly ? parseIsoDateOnly(endDateOnly) : yesterday
312+
const end = endDateOnly ? parseIsoDate(endDateOnly) : yesterday
419313

420314
const startDateOnly = toDateOnly(evolutionOptions.startDate)
421315
if (startDateOnly) {
422-
const start = parseIsoDateOnly(startDateOnly)
316+
const start = parseIsoDate(startDateOnly)
423317
return { start, end }
424318
}
425319

@@ -465,16 +359,17 @@ export function useCharts() {
465359

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

468-
const startIso = toIsoDateString(start)
469-
const endIso = toIsoDateString(end)
362+
const startIso = toIsoDate(start)
363+
const endIso = toIsoDate(end)
470364

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

473-
if (resolvedOptions.granularity === 'day') return buildDailyEvolutionFromDaily(sortedDaily)
367+
if (resolvedOptions.granularity === 'day') return buildDailyEvolution(sortedDaily)
474368
if (resolvedOptions.granularity === 'week')
475-
return buildRollingWeeklyEvolutionFromDaily(sortedDaily, startIso, endIso)
476-
if (resolvedOptions.granularity === 'month') return buildMonthlyEvolutionFromDaily(sortedDaily)
477-
return buildYearlyEvolutionFromDaily(sortedDaily)
369+
return buildWeeklyEvolution(sortedDaily, startIso, endIso)
370+
if (resolvedOptions.granularity === 'month')
371+
return buildMonthlyEvolution(sortedDaily, startIso, endIso)
372+
return buildYearlyEvolution(sortedDaily, startIso, endIso)
478373
}
479374

480375
async function fetchPackageLikesEvolution(
@@ -508,17 +403,17 @@ export function useCharts() {
508403
const sortedDaily = await dailyLikesPromise
509404

510405
const { start, end } = resolveDateRange(resolvedOptions, null)
511-
const startIso = toIsoDateString(start)
512-
const endIso = toIsoDateString(end)
406+
const startIso = toIsoDate(start)
407+
const endIso = toIsoDate(end)
513408

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

516-
if (resolvedOptions.granularity === 'day') return buildDailyEvolutionFromDaily(filteredDaily)
411+
if (resolvedOptions.granularity === 'day') return buildDailyEvolution(filteredDaily)
517412
if (resolvedOptions.granularity === 'week')
518-
return buildRollingWeeklyEvolutionFromDaily(filteredDaily, startIso, endIso)
413+
return buildWeeklyEvolution(filteredDaily, startIso, endIso)
519414
if (resolvedOptions.granularity === 'month')
520-
return buildMonthlyEvolutionFromDaily(filteredDaily)
521-
return buildYearlyEvolutionFromDaily(filteredDaily)
415+
return buildMonthlyEvolution(filteredDaily, startIso, endIso)
416+
return buildYearlyEvolution(filteredDaily, startIso, endIso)
522417
}
523418

524419
async function fetchRepoContributorsEvolution(

0 commit comments

Comments
 (0)