Skip to content

Commit ba9df70

Browse files
committed
from end
1 parent d3cfce5 commit ba9df70

3 files changed

Lines changed: 229 additions & 48 deletions

File tree

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
)
@@ -1771,15 +1776,14 @@ watch(selectedMetric, value => {
17711776
</span>
17721777
<label
17731778
class="flex items-center gap-1.5 text-2xs font-mono text-fg-subtle cursor-pointer h-4"
1774-
:class="{ 'opacity-50 pointer-events-none': !hasAnomalies }"
1779+
:class="{ 'opacity-50': !hasAnomalies }"
17751780
>
17761781
<input
1777-
:checked="settings.chartFilter.anomaliesFixed && hasAnomalies"
1782+
:checked="settings.chartFilter.anomaliesFixed"
17781783
@change="
17791784
settings.chartFilter.anomaliesFixed = ($event.target as HTMLInputElement).checked
17801785
"
17811786
type="checkbox"
1782-
:disabled="!hasAnomalies"
17831787
class="accent-[var(--accent-color,var(--fg-subtle))]"
17841788
/>
17851789
{{ $t('package.trends.apply_correction') }}

app/composables/useCharts.ts

Lines changed: 117 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,25 @@ function startOfUtcMonth(date: Date): Date {
3232
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1))
3333
}
3434

35+
function daysInMonth(year: number, month: number): number {
36+
return new Date(Date.UTC(year, month + 1, 0)).getUTCDate()
37+
}
38+
39+
function daysInYear(year: number): number {
40+
return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 366 : 365
41+
}
42+
43+
/**
44+
* Scale up a partial bucket value proportionally.
45+
* @param value - the raw sum for the partial bucket
46+
* @param actualDays - number of days with data in the bucket
47+
* @param totalDays - expected full bucket size in days
48+
*/
49+
export function fillPartialBucket(value: number, actualDays: number, totalDays: number): number {
50+
if (actualDays <= 0 || actualDays >= totalDays) return value
51+
return Math.round((value * totalDays) / actualDays)
52+
}
53+
3554
function startOfUtcYear(date: Date): Date {
3655
return new Date(Date.UTC(date.getUTCFullYear(), 0, 1))
3756
}
@@ -107,35 +126,48 @@ export function buildRollingWeeklyEvolutionFromDaily(
107126
rangeEndIso: string,
108127
): WeeklyDataPoint[] {
109128
const sorted = daily.slice().sort((a, b) => a.day.localeCompare(b.day))
110-
const rangeStartDate = parseIsoDateOnly(rangeStartIso)
111-
const rangeEndDate = parseIsoDateOnly(rangeEndIso)
129+
if (sorted.length === 0) return []
112130

131+
const rangeStartDate = parseIsoDateOnly(rangeStartIso)
132+
// Align from last day with actual data (npm has 1-2 day delay, today is incomplete)
133+
const lastNonZero = sorted.findLast(d => d.value > 0)
134+
const effectiveEnd = lastNonZero
135+
? parseIsoDateOnly(lastNonZero.day)
136+
: parseIsoDateOnly(rangeEndIso)
137+
const pickerEnd = parseIsoDateOnly(rangeEndIso)
138+
const rangeEndDate = effectiveEnd.getTime() < pickerEnd.getTime() ? effectiveEnd : pickerEnd
139+
140+
// Build 7-day buckets from END backwards
113141
const groupedByIndex = new Map<number, number>()
114142

115143
for (const item of sorted) {
116144
const itemDate = parseIsoDateOnly(item.day)
117-
const dayOffset = Math.floor((itemDate.getTime() - rangeStartDate.getTime()) / 86400000)
118-
if (dayOffset < 0) continue
145+
const dayOffsetFromEnd = Math.floor((rangeEndDate.getTime() - itemDate.getTime()) / 86400000)
146+
if (dayOffsetFromEnd < 0) continue
119147

120-
const weekIndex = Math.floor(dayOffset / 7)
148+
const weekIndex = Math.floor(dayOffsetFromEnd / 7)
121149
groupedByIndex.set(weekIndex, (groupedByIndex.get(weekIndex) ?? 0) + item.value)
122150
}
123151

124152
return Array.from(groupedByIndex.entries())
125-
.sort(([a], [b]) => a - b)
153+
.sort(([a], [b]) => b - a) // reverse: highest index = oldest week
126154
.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
155+
const weekEndDate = addDays(rangeEndDate, -(weekIndex * 7))
156+
let weekStartDate = addDays(weekEndDate, -6)
157+
158+
// First bucket may be partial — scale up proportionally
159+
if (weekStartDate.getTime() < rangeStartDate.getTime()) {
160+
weekStartDate = rangeStartDate
161+
const actualDays =
162+
Math.floor((weekEndDate.getTime() - rangeStartDate.getTime()) / 86400000) + 1
163+
value = fillPartialBucket(value, actualDays, 7)
164+
}
133165

134166
const weekStartIso = toIsoDateString(weekStartDate)
135-
const weekEndIso = toIsoDateString(clampedWeekEndDate)
167+
const weekEndIso = toIsoDateString(weekEndDate)
136168

137169
const timestampStart = weekStartDate.getTime()
138-
const timestampEnd = clampedWeekEndDate.getTime()
170+
const timestampEnd = weekEndDate.getTime()
139171

140172
return {
141173
value,
@@ -148,7 +180,11 @@ export function buildRollingWeeklyEvolutionFromDaily(
148180
})
149181
}
150182

151-
export function buildMonthlyEvolutionFromDaily(daily: DailyRawPoint[]): MonthlyDataPoint[] {
183+
export function buildMonthlyEvolutionFromDaily(
184+
daily: DailyRawPoint[],
185+
rangeStartIso?: string,
186+
rangeEndIso?: string,
187+
): MonthlyDataPoint[] {
152188
const sorted = daily.slice().sort((a, b) => a.day.localeCompare(b.day))
153189
const valuesByMonth = new Map<string, number>()
154190

@@ -157,16 +193,39 @@ export function buildMonthlyEvolutionFromDaily(daily: DailyRawPoint[]): MonthlyD
157193
valuesByMonth.set(month, (valuesByMonth.get(month) ?? 0) + item.value)
158194
}
159195

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-
})
196+
const entries = Array.from(valuesByMonth.entries()).sort(([a], [b]) => a.localeCompare(b))
197+
198+
return entries.map(([month, value], index) => {
199+
const monthStartDate = parseIsoDateOnly(`${month}-01`)
200+
const [y, m] = month.split('-').map(Number) as [number, number]
201+
const totalDays = daysInMonth(y, m - 1)
202+
203+
// Scale up partial first bucket
204+
if (index === 0 && rangeStartIso) {
205+
const rangeStartDay = Number(rangeStartIso.split('-')[2])
206+
if (rangeStartDay > 1) {
207+
value = fillPartialBucket(value, totalDays - rangeStartDay + 1, totalDays)
208+
}
209+
}
210+
211+
// Scale up partial last bucket
212+
if (index === entries.length - 1 && rangeEndIso) {
213+
const rangeEndDay = Number(rangeEndIso.split('-')[2])
214+
if (rangeEndDay < totalDays) {
215+
value = fillPartialBucket(value, rangeEndDay, totalDays)
216+
}
217+
}
218+
219+
const timestamp = monthStartDate.getTime()
220+
return { month, value, timestamp }
221+
})
167222
}
168223

169-
export function buildYearlyEvolutionFromDaily(daily: DailyRawPoint[]): YearlyDataPoint[] {
224+
export function buildYearlyEvolutionFromDaily(
225+
daily: DailyRawPoint[],
226+
rangeStartIso?: string,
227+
rangeEndIso?: string,
228+
): YearlyDataPoint[] {
170229
const sorted = daily.slice().sort((a, b) => a.day.localeCompare(b.day))
171230
const valuesByYear = new Map<string, number>()
172231

@@ -175,13 +234,36 @@ export function buildYearlyEvolutionFromDaily(daily: DailyRawPoint[]): YearlyDat
175234
valuesByYear.set(year, (valuesByYear.get(year) ?? 0) + item.value)
176235
}
177236

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-
})
237+
const entries = Array.from(valuesByYear.entries()).sort(([a], [b]) => a.localeCompare(b))
238+
239+
return entries.map(([year, value], index) => {
240+
const y = Number(year)
241+
const totalDays = daysInYear(y)
242+
243+
// Scale up partial first bucket
244+
if (index === 0 && rangeStartIso) {
245+
const rangeStart = parseIsoDateOnly(rangeStartIso)
246+
const yearStart = parseIsoDateOnly(`${year}-01-01`)
247+
const dayOfYear = Math.floor((rangeStart.getTime() - yearStart.getTime()) / 86400000)
248+
if (dayOfYear > 0) {
249+
value = fillPartialBucket(value, totalDays - dayOfYear, totalDays)
250+
}
251+
}
252+
253+
// Scale up partial last bucket
254+
if (index === entries.length - 1 && rangeEndIso) {
255+
const rangeEnd = parseIsoDateOnly(rangeEndIso)
256+
const yearStart = parseIsoDateOnly(`${year}-01-01`)
257+
const actualDays = Math.floor((rangeEnd.getTime() - yearStart.getTime()) / 86400000) + 1
258+
if (actualDays < totalDays) {
259+
value = fillPartialBucket(value, actualDays, totalDays)
260+
}
261+
}
262+
263+
const yearStartDate = parseIsoDateOnly(`${year}-01-01`)
264+
const timestamp = yearStartDate.getTime()
265+
return { year, value, timestamp }
266+
})
185267
}
186268

187269
const npmDailyRangeCache = import.meta.client ? new Map<string, Promise<DailyRawPoint[]>>() : null
@@ -473,8 +555,9 @@ export function useCharts() {
473555
if (resolvedOptions.granularity === 'day') return buildDailyEvolutionFromDaily(sortedDaily)
474556
if (resolvedOptions.granularity === 'week')
475557
return buildRollingWeeklyEvolutionFromDaily(sortedDaily, startIso, endIso)
476-
if (resolvedOptions.granularity === 'month') return buildMonthlyEvolutionFromDaily(sortedDaily)
477-
return buildYearlyEvolutionFromDaily(sortedDaily)
558+
if (resolvedOptions.granularity === 'month')
559+
return buildMonthlyEvolutionFromDaily(sortedDaily, startIso, endIso)
560+
return buildYearlyEvolutionFromDaily(sortedDaily, startIso, endIso)
478561
}
479562

480563
async function fetchPackageLikesEvolution(
@@ -517,8 +600,8 @@ export function useCharts() {
517600
if (resolvedOptions.granularity === 'week')
518601
return buildRollingWeeklyEvolutionFromDaily(filteredDaily, startIso, endIso)
519602
if (resolvedOptions.granularity === 'month')
520-
return buildMonthlyEvolutionFromDaily(filteredDaily)
521-
return buildYearlyEvolutionFromDaily(filteredDaily)
603+
return buildMonthlyEvolutionFromDaily(filteredDaily, startIso, endIso)
604+
return buildYearlyEvolutionFromDaily(filteredDaily, startIso, endIso)
522605
}
523606

524607
async function fetchRepoContributorsEvolution(

0 commit comments

Comments
 (0)