@@ -18,7 +18,11 @@ import type {
1818 YearlyDataPoint ,
1919} from ' ~/types/chart'
2020import { DATE_INPUT_MAX } from ' ~/utils/input'
21- import { applyDataCorrection } from ' ~/utils/chart-data-correction'
21+ import {
22+ applyDataPipeline ,
23+ endDateOnlyToUtcMs ,
24+ DEFAULT_PREDICTION_POINTS ,
25+ } from ' ~/utils/chart-data-prediction'
2226import { applyBlocklistCorrection , getAnomaliesForPackages } from ' ~/utils/download-anomalies'
2327import { copyAltTextForTrendLineChart , sanitise , loadFile , applyEllipsis } from ' ~/utils/charts'
2428
@@ -368,14 +372,25 @@ const displayedGranularity = shallowRef<ChartTimeGranularity>(DEFAULT_GRANULARIT
368372
369373const isEndDateOnPeriodEnd = computed (() => {
370374 const g = selectedGranularity .value
371- if (g !== ' monthly' && g !== ' yearly' ) return false
372375
373376 const iso = String (endDate .value ?? ' ' ).slice (0 , 10 )
374377 if (! / ^ \d {4} -\d {2} -\d {2} $ / .test (iso )) return false
375378
376379 const [year, month, day] = iso .split (' -' ).map (Number )
377380 if (! year || ! month || ! day ) return false
378381
382+ if (g === ' daily' ) return true // every day is a complete period
383+
384+ if (g === ' weekly' ) {
385+ // The last week bucket is complete when the range length is divisible by 7
386+ const startIso = String (startDate .value ?? ' ' ).slice (0 , 10 )
387+ if (! / ^ \d {4} -\d {2} -\d {2} $ / .test (startIso )) return false
388+ const startMs = Date .UTC (... (startIso .split (' -' ).map (Number ) as [number , number , number ]))
389+ const endMs = Date .UTC (year , month - 1 , day )
390+ const totalDays = Math .floor ((endMs - startMs ) / 86400000 ) + 1
391+ return totalDays % 7 === 0
392+ }
393+
379394 // Monthly: endDate is the last day of its month (UTC)
380395 if (g === ' monthly' ) {
381396 const lastDayOfMonth = new Date (Date .UTC (year , month , 0 )).getUTCDate ()
@@ -386,11 +401,8 @@ const isEndDateOnPeriodEnd = computed(() => {
386401 return month === 12 && day === 31
387402})
388403
389- const isEstimationGranularity = computed (
390- () => displayedGranularity .value === ' monthly' || displayedGranularity .value === ' yearly' ,
391- )
392404const supportsEstimation = computed (
393- () => isEstimationGranularity .value && selectedMetric .value !== ' contributors' ,
405+ () => displayedGranularity .value !== ' daily ' && selectedMetric .value !== ' contributors' ,
394406)
395407
396408const hasDownloadAnomalies = computed (() =>
@@ -972,11 +984,6 @@ const effectiveDataSingle = computed<EvolutionData>(() => {
972984 granularity: displayedGranularity .value ,
973985 })
974986 }
975-
976- return applyDataCorrection (
977- data as Array <{ value: number }>,
978- settings .value .chartFilter ,
979- ) as EvolutionData
980987 }
981988
982989 return data
@@ -1021,10 +1028,6 @@ const chartData = computed<{
10211028 if (settings .value .chartFilter .anomaliesFixed ) {
10221029 data = applyBlocklistCorrection ({ data , packageName: pkg , granularity })
10231030 }
1024- data = applyDataCorrection (
1025- data as Array <{ value: number }>,
1026- settings .value .chartFilter ,
1027- ) as EvolutionData
10281031 }
10291032 const points = extractSeriesPoints (granularity , data )
10301033
@@ -1066,16 +1069,26 @@ const chartData = computed<{
10661069})
10671070
10681071const normalisedDataset = computed (() => {
1069- return chartData .value .dataset ?.map (d => {
1070- const lastValue = d .series .at (- 1 ) ?? 0
1072+ const granularity = displayedGranularity .value
1073+ const endDateMs = endDate .value ? endDateOnlyToUtcMs (endDate .value ) : null
1074+ const referenceMs = endDateMs ?? Date .now ()
1075+ const lastDateMs = chartData .value .dates .at (- 1 ) ?? 0
1076+ const isAbsoluteMetric = selectedMetric .value === ' contributors'
10711077
1072- // Contributors is an absolute metric: keep the partial period value as-is.
1073- const projectedLastValue =
1074- selectedMetric .value === ' contributors' ? lastValue : extrapolateLastValue (lastValue )
1078+ return chartData .value .dataset ?.map (d => {
1079+ const series = applyDataPipeline (
1080+ d .series .map (v => v ?? 0 ),
1081+ {
1082+ averageWindow: settings .value .chartFilter .averageWindow ,
1083+ smoothingTau: settings .value .chartFilter .smoothingTau ,
1084+ predictionPoints: settings .value .chartFilter .predictionPoints ?? DEFAULT_PREDICTION_POINTS ,
1085+ },
1086+ { granularity , lastDateMs , referenceMs , isAbsoluteMetric },
1087+ )
10751088
10761089 return {
10771090 ... d ,
1078- series: [ ... d . series . slice ( 0 , - 1 ), projectedLastValue ] ,
1091+ series ,
10791092 dashIndices: d .dashIndices ?? [],
10801093 }
10811094 })
@@ -1137,144 +1150,6 @@ const granularityItems = computed(() =>
11371150 })),
11381151)
11391152
1140- function clampRatio(value : number ): number {
1141- if (value < 0 ) return 0
1142- if (value > 1 ) return 1
1143- return value
1144- }
1145-
1146- /**
1147- * Convert a `YYYY-MM-DD` date to UTC timestamp representing the end of that day.
1148- * The returned timestamp corresponds to `23:59:59.999` in UTC
1149- *
1150- * @param endDateOnly - ISO-like date string (`YYYY-MM-DD`)
1151- * @returns The UTC timestamp in milliseconds for the end of the given day,
1152- * or `null` if the input is invalid.
1153- */
1154- function endDateOnlyToUtcMs(endDateOnly : string ): number | null {
1155- if (! / ^ \d {4} -\d {2} -\d {2} $ / .test (endDateOnly )) return null
1156- const [y, m, d] = endDateOnly .split (' -' ).map (Number )
1157- if (! y || ! m || ! d ) return null
1158- return Date .UTC (y , m - 1 , d , 23 , 59 , 59 , 999 )
1159- }
1160-
1161- /**
1162- * Computes the UTC timestamp corresponding to the start of the time bucket
1163- * that contains the given timestamp.
1164- *
1165- * This function is used to derive period boundaries when computing completion
1166- * ratios or extrapolating values for partially completed periods.
1167- *
1168- * Bucket boundaries are defined in UTC:
1169- * - **monthly** : first day of the month at `00:00:00.000` UTC
1170- * - **yearly** : January 1st of the year at `00:00:00.000` UTC
1171- *
1172- * @param timestampMs - Reference timestamp in milliseconds
1173- * @param granularity - Bucket granularity (`monthly` or `yearly`)
1174- * @returns The UTC timestamp representing the start of the corresponding
1175- * time bucket.
1176- */
1177- function getBucketStartUtc(timestampMs : number , granularity : ' monthly' | ' yearly' ): number {
1178- const date = new Date (timestampMs )
1179- if (granularity === ' yearly' ) return Date .UTC (date .getUTCFullYear (), 0 , 1 , 0 , 0 , 0 , 0 )
1180- return Date .UTC (date .getUTCFullYear (), date .getUTCMonth (), 1 , 0 , 0 , 0 , 0 )
1181- }
1182-
1183- /**
1184- * Computes the UTC timestamp corresponding to the end of the time
1185- * bucket that contains the given timestamp. This end timestamp is paired with `getBucketStartUtc` to define
1186- * a half-open interval `[start, end)` when computing elapsed time or completion
1187- * ratios within a period.
1188- *
1189- * Bucket boundaries are defined in UTC and are **exclusive**:
1190- * - **monthly** : first day of the following month at `00:00:00.000` UTC
1191- * - **yearly** : January 1st of the following year at `00:00:00.000` UTC
1192- *
1193- * @param timestampMs - Reference timestamp in milliseconds
1194- * @param granularity - Bucket granularity (`monthly` or `yearly`)
1195- * @returns The UTC timestamp (in milliseconds) representing the exclusive end
1196- * of the corresponding time bucket.
1197- */
1198- function getBucketEndUtc(timestampMs : number , granularity : ' monthly' | ' yearly' ): number {
1199- const date = new Date (timestampMs )
1200- if (granularity === ' yearly' ) return Date .UTC (date .getUTCFullYear () + 1 , 0 , 1 , 0 , 0 , 0 , 0 )
1201- return Date .UTC (date .getUTCFullYear (), date .getUTCMonth () + 1 , 1 , 0 , 0 , 0 , 0 )
1202- }
1203-
1204- /**
1205- * Computes the completion ratio of a time bucket relative to a reference time.
1206- *
1207- * The ratio represents how much of the bucket’s duration has elapsed at
1208- * `referenceMs`, expressed as a normalized value in the range `[0, 1]`.
1209- *
1210- * The bucket is defined by the calendar period (monthly or yearly) that
1211- * contains `bucketTimestampMs`, using UTC boundaries:
1212- * - start: `getBucketStartUtc(...)`
1213- * - end: `getBucketEndUtc(...)`
1214- *
1215- * The returned value is clamped to `[0, 1]`:
1216- * - `0`: reference time is at or before the start of the bucket
1217- * - `1`: reference time is at or after the end of the bucket
1218- *
1219- * This function is used to detect partially completed periods and to
1220- * extrapolate full period values from partial data.
1221- *
1222- * @param params.bucketTimestampMs - Timestamp belonging to the bucket
1223- * @param params.granularity - Bucket granularity (`monthly` or `yearly`)
1224- * @param params.referenceMs - Reference timestamp used to measure progress
1225- * @returns A normalized completion ratio in the range `[0, 1]`.
1226- */
1227- function getCompletionRatioForBucket(params : {
1228- bucketTimestampMs: number
1229- granularity: ' monthly' | ' yearly'
1230- referenceMs: number
1231- }): number {
1232- const start = getBucketStartUtc (params .bucketTimestampMs , params .granularity )
1233- const end = getBucketEndUtc (params .bucketTimestampMs , params .granularity )
1234- const total = end - start
1235- if (total <= 0 ) return 1
1236- return clampRatio ((params .referenceMs - start ) / total )
1237- }
1238-
1239- /**
1240- * Extrapolate the last observed value of a time series when the last bucket
1241- * (month or year) is only partially complete.
1242- *
1243- * This is used to replace the final value in each `VueUiXy` series
1244- * before rendering, so the chart can display an estimated full-period value
1245- * for the current month or year.
1246- *
1247- * Notes:
1248- * - This function assumes `lastValue` is the value corresponding to the last
1249- * date in `chartData.value.dates`
1250- *
1251- * @param lastValue - The last observed numeric value for a series.
1252- * @returns The extrapolated value for partially completed monthly or yearly granularities,
1253- * or the original `lastValue` when no extrapolation should be applied.
1254- */
1255- function extrapolateLastValue(lastValue : number ) {
1256- if (selectedMetric .value === ' contributors' ) return lastValue
1257-
1258- if (displayedGranularity .value !== ' monthly' && displayedGranularity .value !== ' yearly' )
1259- return lastValue
1260-
1261- const endDateMs = endDate .value ? endDateOnlyToUtcMs (endDate .value ) : null
1262- const referenceMs = endDateMs ?? Date .now ()
1263-
1264- const completionRatio = getCompletionRatioForBucket ({
1265- bucketTimestampMs: chartData .value .dates .at (- 1 ) ?? 0 ,
1266- granularity: displayedGranularity .value ,
1267- referenceMs ,
1268- })
1269-
1270- if (! (completionRatio > 0 && completionRatio < 1 )) return lastValue
1271-
1272- const extrapolatedValue = lastValue / completionRatio
1273- if (! Number .isFinite (extrapolatedValue )) return lastValue
1274-
1275- return extrapolatedValue
1276- }
1277-
12781153/**
12791154 * Build and return svg markup for estimation overlays on the chart.
12801155 *
@@ -1709,14 +1584,15 @@ watch(selectedMetric, value => {
17091584 :aria-busy =" activeMetricState.pending ? 'true' : 'false'"
17101585 >
17111586 <div class =" w-full mb-4 flex flex-col gap-3" >
1712- <div class =" flex flex-col sm:flex-row gap-3 sm:gap-2 sm:items-end" >
1587+ <div class =" grid grid-cols-2 sm:flex sm:flex-row gap-3 sm:gap-2 sm:items-end" >
17131588 <SelectField
17141589 v-if =" showFacetSelector"
17151590 id =" trends-metric-select"
17161591 v-model =" selectedMetric"
17171592 :disabled =" activeMetricState.pending"
17181593 :items =" METRICS.map(m => ({ label: m.label, value: m.id }))"
17191594 :label =" $t('package.trends.facet')"
1595+ block
17201596 />
17211597
17221598 <SelectField
@@ -1725,9 +1601,10 @@ watch(selectedMetric, value => {
17251601 v-model =" selectedGranularity"
17261602 :disabled =" activeMetricState.pending"
17271603 :items =" granularityItems"
1604+ block
17281605 />
17291606
1730- <div class =" grid grid-cols-2 gap-2 flex-1" >
1607+ <div class =" col-span-2 sm:col-span-1 grid grid-cols-2 gap-2 flex-1" >
17311608 <div class =" flex flex-col gap-1" >
17321609 <label
17331610 for =" startDate"
@@ -1797,7 +1674,7 @@ watch(selectedMetric, value => {
17971674 />
17981675 {{ $t('package.trends.data_correction') }}
17991676 </button >
1800- <div v-if =" showCorrectionControls" class =" flex items-end gap-3" >
1677+ <div v-if =" showCorrectionControls" class =" grid grid-cols-2 sm: flex items-end gap-3" >
18011678 <label class =" flex flex-col gap-1 flex-1" >
18021679 <span class =" text-2xs font-mono text-fg-subtle tracking-wide uppercase" >
18031680 {{ $t('package.trends.average_window') }}
@@ -1826,6 +1703,20 @@ watch(selectedMetric, value => {
18261703 class =" accent-[var(--accent-color,var(--fg-subtle))]"
18271704 />
18281705 </label >
1706+ <label class =" flex flex-col gap-1 flex-1" >
1707+ <span class =" text-2xs font-mono text-fg-subtle tracking-wide uppercase" >
1708+ {{ $t('package.trends.prediction') }}
1709+ <span class =" text-fg-muted" >({{ settings.chartFilter.predictionPoints }})</span >
1710+ </span >
1711+ <input
1712+ v-model.number =" settings.chartFilter.predictionPoints"
1713+ type =" range"
1714+ min =" 0"
1715+ max =" 30"
1716+ step =" 1"
1717+ class =" accent-[var(--accent-color,var(--fg-subtle))]"
1718+ />
1719+ </label >
18291720 <div class =" flex flex-col gap-1 shrink-0" >
18301721 <span
18311722 class =" text-2xs font-mono text-fg-subtle tracking-wide uppercase flex items-center justify-between"
@@ -1879,7 +1770,7 @@ watch(selectedMetric, value => {
18791770 </TooltipApp >
18801771 </span >
18811772 <label
1882- class =" flex items-center gap-1.5 text-2xs font-mono text-fg-subtle cursor-pointer"
1773+ class =" flex items-center gap-1.5 text-2xs font-mono text-fg-subtle cursor-pointer h-4 "
18831774 :class =" { 'opacity-50 pointer-events-none': !hasAnomalies }"
18841775 >
18851776 <input
0 commit comments