@@ -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+
3554function 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
187269const 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