Skip to content

Commit 87f3aaf

Browse files
authored
fix: weekly chart prediction and data pipeline extraction (#2014)
1 parent 99c9533 commit 87f3aaf

File tree

6 files changed

+431
-163
lines changed

6 files changed

+431
-163
lines changed

app/components/Package/TrendsChart.vue

Lines changed: 54 additions & 163 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,11 @@ import type {
1818
YearlyDataPoint,
1919
} from '~/types/chart'
2020
import { 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'
2226
import { applyBlocklistCorrection, getAnomaliesForPackages } from '~/utils/download-anomalies'
2327
import { copyAltTextForTrendLineChart, sanitise, loadFile, applyEllipsis } from '~/utils/charts'
2428
@@ -368,14 +372,25 @@ const displayedGranularity = shallowRef<ChartTimeGranularity>(DEFAULT_GRANULARIT
368372
369373
const 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-
)
392404
const supportsEstimation = computed(
393-
() => isEstimationGranularity.value && selectedMetric.value !== 'contributors',
405+
() => displayedGranularity.value !== 'daily' && selectedMetric.value !== 'contributors',
394406
)
395407
396408
const 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
10681071
const 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

app/composables/useSettings.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export interface AppSettings {
4545
averageWindow: number
4646
smoothingTau: number
4747
anomaliesFixed: boolean
48+
predictionPoints: number
4849
}
4950
}
5051

@@ -68,6 +69,7 @@ const DEFAULT_SETTINGS: AppSettings = {
6869
averageWindow: 0,
6970
smoothingTau: 1,
7071
anomaliesFixed: true,
72+
predictionPoints: 4,
7173
},
7274
}
7375

0 commit comments

Comments
 (0)