From 626d2339f8cf5e3fff06a824ebf25dafb419912e Mon Sep 17 00:00:00 2001 From: jycouet Date: Mon, 9 Mar 2026 23:39:19 +0100 Subject: [PATCH 1/5] fix: weekly chart prediction and data pipeline extraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - extend estimation support to weekly (and daily) granularity - add linear regression prediction based on recent data points - extract prediction logic into chart-data-prediction utility - add configurable prediction points slider (0–30, default 4) - apply prediction before smoothing so partial periods don't skew curves --- app/components/Package/TrendsChart.vue | 209 ++++++------------------- app/composables/useSettings.ts | 2 + app/utils/chart-data-prediction.ts | 157 +++++++++++++++++++ i18n/locales/en.json | 1 + i18n/schema.json | 3 + 5 files changed, 212 insertions(+), 160 deletions(-) create mode 100644 app/utils/chart-data-prediction.ts diff --git a/app/components/Package/TrendsChart.vue b/app/components/Package/TrendsChart.vue index 7be37d03e..762e38458 100644 --- a/app/components/Package/TrendsChart.vue +++ b/app/components/Package/TrendsChart.vue @@ -18,7 +18,11 @@ import type { YearlyDataPoint, } from '~/types/chart' import { DATE_INPUT_MAX } from '~/utils/input' -import { applyDataCorrection } from '~/utils/chart-data-correction' +import { + applyDataPipeline, + endDateOnlyToUtcMs, + DEFAULT_PREDICTION_POINTS, +} from '~/utils/chart-data-prediction' import { applyBlocklistCorrection, getAnomaliesForPackages } from '~/utils/download-anomalies' import { copyAltTextForTrendLineChart, sanitise, loadFile, applyEllipsis } from '~/utils/charts' @@ -368,7 +372,6 @@ const displayedGranularity = shallowRef(DEFAULT_GRANULARIT const isEndDateOnPeriodEnd = computed(() => { const g = selectedGranularity.value - if (g !== 'monthly' && g !== 'yearly') return false const iso = String(endDate.value ?? '').slice(0, 10) if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) return false @@ -376,6 +379,18 @@ const isEndDateOnPeriodEnd = computed(() => { const [year, month, day] = iso.split('-').map(Number) if (!year || !month || !day) return false + if (g === 'daily') return true // every day is a complete period + + if (g === 'weekly') { + // The last week bucket is complete when the range length is divisible by 7 + const startIso = String(startDate.value ?? '').slice(0, 10) + if (!/^\d{4}-\d{2}-\d{2}$/.test(startIso)) return false + const startMs = Date.UTC(...(startIso.split('-').map(Number) as [number, number, number])) + const endMs = Date.UTC(year, month - 1, day) + const totalDays = Math.floor((endMs - startMs) / 86400000) + 1 + return totalDays % 7 === 0 + } + // Monthly: endDate is the last day of its month (UTC) if (g === 'monthly') { const lastDayOfMonth = new Date(Date.UTC(year, month, 0)).getUTCDate() @@ -386,11 +401,8 @@ const isEndDateOnPeriodEnd = computed(() => { return month === 12 && day === 31 }) -const isEstimationGranularity = computed( - () => displayedGranularity.value === 'monthly' || displayedGranularity.value === 'yearly', -) const supportsEstimation = computed( - () => isEstimationGranularity.value && selectedMetric.value !== 'contributors', + () => displayedGranularity.value !== 'daily' && selectedMetric.value !== 'contributors', ) const hasDownloadAnomalies = computed(() => @@ -972,11 +984,6 @@ const effectiveDataSingle = computed(() => { granularity: displayedGranularity.value, }) } - - return applyDataCorrection( - data as Array<{ value: number }>, - settings.value.chartFilter, - ) as EvolutionData } return data @@ -1021,10 +1028,6 @@ const chartData = computed<{ if (settings.value.chartFilter.anomaliesFixed) { data = applyBlocklistCorrection({ data, packageName: pkg, granularity }) } - data = applyDataCorrection( - data as Array<{ value: number }>, - settings.value.chartFilter, - ) as EvolutionData } const points = extractSeriesPoints(granularity, data) @@ -1066,16 +1069,26 @@ const chartData = computed<{ }) const normalisedDataset = computed(() => { - return chartData.value.dataset?.map(d => { - const lastValue = d.series.at(-1) ?? 0 + const granularity = displayedGranularity.value + const endDateMs = endDate.value ? endDateOnlyToUtcMs(endDate.value) : null + const referenceMs = endDateMs ?? Date.now() + const lastDateMs = chartData.value.dates.at(-1) ?? 0 + const isAbsoluteMetric = selectedMetric.value === 'contributors' - // Contributors is an absolute metric: keep the partial period value as-is. - const projectedLastValue = - selectedMetric.value === 'contributors' ? lastValue : extrapolateLastValue(lastValue) + return chartData.value.dataset?.map(d => { + const series = applyDataPipeline( + d.series.map(v => v ?? 0), + { + averageWindow: settings.value.chartFilter.averageWindow, + smoothingTau: settings.value.chartFilter.smoothingTau, + predictionPoints: settings.value.chartFilter.predictionPoints ?? DEFAULT_PREDICTION_POINTS, + }, + { granularity, lastDateMs, referenceMs, isAbsoluteMetric }, + ) return { ...d, - series: [...d.series.slice(0, -1), projectedLastValue], + series, dashIndices: d.dashIndices ?? [], } }) @@ -1137,144 +1150,6 @@ const granularityItems = computed(() => })), ) -function clampRatio(value: number): number { - if (value < 0) return 0 - if (value > 1) return 1 - return value -} - -/** - * Convert a `YYYY-MM-DD` date to UTC timestamp representing the end of that day. - * The returned timestamp corresponds to `23:59:59.999` in UTC - * - * @param endDateOnly - ISO-like date string (`YYYY-MM-DD`) - * @returns The UTC timestamp in milliseconds for the end of the given day, - * or `null` if the input is invalid. - */ -function endDateOnlyToUtcMs(endDateOnly: string): number | null { - if (!/^\d{4}-\d{2}-\d{2}$/.test(endDateOnly)) return null - const [y, m, d] = endDateOnly.split('-').map(Number) - if (!y || !m || !d) return null - return Date.UTC(y, m - 1, d, 23, 59, 59, 999) -} - -/** - * Computes the UTC timestamp corresponding to the start of the time bucket - * that contains the given timestamp. - * - * This function is used to derive period boundaries when computing completion - * ratios or extrapolating values for partially completed periods. - * - * Bucket boundaries are defined in UTC: - * - **monthly** : first day of the month at `00:00:00.000` UTC - * - **yearly** : January 1st of the year at `00:00:00.000` UTC - * - * @param timestampMs - Reference timestamp in milliseconds - * @param granularity - Bucket granularity (`monthly` or `yearly`) - * @returns The UTC timestamp representing the start of the corresponding - * time bucket. - */ -function getBucketStartUtc(timestampMs: number, granularity: 'monthly' | 'yearly'): number { - const date = new Date(timestampMs) - if (granularity === 'yearly') return Date.UTC(date.getUTCFullYear(), 0, 1, 0, 0, 0, 0) - return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1, 0, 0, 0, 0) -} - -/** - * Computes the UTC timestamp corresponding to the end of the time - * bucket that contains the given timestamp. This end timestamp is paired with `getBucketStartUtc` to define - * a half-open interval `[start, end)` when computing elapsed time or completion - * ratios within a period. - * - * Bucket boundaries are defined in UTC and are **exclusive**: - * - **monthly** : first day of the following month at `00:00:00.000` UTC - * - **yearly** : January 1st of the following year at `00:00:00.000` UTC - * - * @param timestampMs - Reference timestamp in milliseconds - * @param granularity - Bucket granularity (`monthly` or `yearly`) - * @returns The UTC timestamp (in milliseconds) representing the exclusive end - * of the corresponding time bucket. - */ -function getBucketEndUtc(timestampMs: number, granularity: 'monthly' | 'yearly'): number { - const date = new Date(timestampMs) - if (granularity === 'yearly') return Date.UTC(date.getUTCFullYear() + 1, 0, 1, 0, 0, 0, 0) - return Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 1, 0, 0, 0, 0) -} - -/** - * Computes the completion ratio of a time bucket relative to a reference time. - * - * The ratio represents how much of the bucket’s duration has elapsed at - * `referenceMs`, expressed as a normalized value in the range `[0, 1]`. - * - * The bucket is defined by the calendar period (monthly or yearly) that - * contains `bucketTimestampMs`, using UTC boundaries: - * - start: `getBucketStartUtc(...)` - * - end: `getBucketEndUtc(...)` - * - * The returned value is clamped to `[0, 1]`: - * - `0`: reference time is at or before the start of the bucket - * - `1`: reference time is at or after the end of the bucket - * - * This function is used to detect partially completed periods and to - * extrapolate full period values from partial data. - * - * @param params.bucketTimestampMs - Timestamp belonging to the bucket - * @param params.granularity - Bucket granularity (`monthly` or `yearly`) - * @param params.referenceMs - Reference timestamp used to measure progress - * @returns A normalized completion ratio in the range `[0, 1]`. - */ -function getCompletionRatioForBucket(params: { - bucketTimestampMs: number - granularity: 'monthly' | 'yearly' - referenceMs: number -}): number { - const start = getBucketStartUtc(params.bucketTimestampMs, params.granularity) - const end = getBucketEndUtc(params.bucketTimestampMs, params.granularity) - const total = end - start - if (total <= 0) return 1 - return clampRatio((params.referenceMs - start) / total) -} - -/** - * Extrapolate the last observed value of a time series when the last bucket - * (month or year) is only partially complete. - * - * This is used to replace the final value in each `VueUiXy` series - * before rendering, so the chart can display an estimated full-period value - * for the current month or year. - * - * Notes: - * - This function assumes `lastValue` is the value corresponding to the last - * date in `chartData.value.dates` - * - * @param lastValue - The last observed numeric value for a series. - * @returns The extrapolated value for partially completed monthly or yearly granularities, - * or the original `lastValue` when no extrapolation should be applied. - */ -function extrapolateLastValue(lastValue: number) { - if (selectedMetric.value === 'contributors') return lastValue - - if (displayedGranularity.value !== 'monthly' && displayedGranularity.value !== 'yearly') - return lastValue - - const endDateMs = endDate.value ? endDateOnlyToUtcMs(endDate.value) : null - const referenceMs = endDateMs ?? Date.now() - - const completionRatio = getCompletionRatioForBucket({ - bucketTimestampMs: chartData.value.dates.at(-1) ?? 0, - granularity: displayedGranularity.value, - referenceMs, - }) - - if (!(completionRatio > 0 && completionRatio < 1)) return lastValue - - const extrapolatedValue = lastValue / completionRatio - if (!Number.isFinite(extrapolatedValue)) return lastValue - - return extrapolatedValue -} - /** * Build and return svg markup for estimation overlays on the chart. * @@ -1826,6 +1701,20 @@ watch(selectedMetric, value => { class="accent-[var(--accent-color,var(--fg-subtle))]" /> +
{