Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
217 changes: 54 additions & 163 deletions app/components/Package/TrendsChart.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -368,14 +372,25 @@ const displayedGranularity = shallowRef<ChartTimeGranularity>(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

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()
Expand All @@ -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(() =>
Expand Down Expand Up @@ -972,11 +984,6 @@ const effectiveDataSingle = computed<EvolutionData>(() => {
granularity: displayedGranularity.value,
})
}

return applyDataCorrection(
data as Array<{ value: number }>,
settings.value.chartFilter,
) as EvolutionData
}

return data
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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 ?? [],
}
})
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -1709,14 +1584,15 @@ watch(selectedMetric, value => {
:aria-busy="activeMetricState.pending ? 'true' : 'false'"
>
<div class="w-full mb-4 flex flex-col gap-3">
<div class="flex flex-col sm:flex-row gap-3 sm:gap-2 sm:items-end">
<div class="grid grid-cols-2 sm:flex sm:flex-row gap-3 sm:gap-2 sm:items-end">
<SelectField
v-if="showFacetSelector"
id="trends-metric-select"
v-model="selectedMetric"
:disabled="activeMetricState.pending"
:items="METRICS.map(m => ({ label: m.label, value: m.id }))"
:label="$t('package.trends.facet')"
block
/>

<SelectField
Expand All @@ -1725,9 +1601,10 @@ watch(selectedMetric, value => {
v-model="selectedGranularity"
:disabled="activeMetricState.pending"
:items="granularityItems"
block
/>

<div class="grid grid-cols-2 gap-2 flex-1">
<div class="col-span-2 sm:col-span-1 grid grid-cols-2 gap-2 flex-1">
<div class="flex flex-col gap-1">
<label
for="startDate"
Expand Down Expand Up @@ -1797,7 +1674,7 @@ watch(selectedMetric, value => {
/>
{{ $t('package.trends.data_correction') }}
</button>
<div v-if="showCorrectionControls" class="flex items-end gap-3">
<div v-if="showCorrectionControls" class="grid grid-cols-2 sm:flex items-end gap-3">
<label class="flex flex-col gap-1 flex-1">
<span class="text-2xs font-mono text-fg-subtle tracking-wide uppercase">
{{ $t('package.trends.average_window') }}
Expand Down Expand Up @@ -1826,6 +1703,20 @@ watch(selectedMetric, value => {
class="accent-[var(--accent-color,var(--fg-subtle))]"
/>
</label>
<label class="flex flex-col gap-1 flex-1">
<span class="text-2xs font-mono text-fg-subtle tracking-wide uppercase">
{{ $t('package.trends.prediction') }}
<span class="text-fg-muted">({{ settings.chartFilter.predictionPoints }})</span>
</span>
<input
v-model.number="settings.chartFilter.predictionPoints"
type="range"
min="0"
max="30"
step="1"
class="accent-[var(--accent-color,var(--fg-subtle))]"
/>
</label>
<div class="flex flex-col gap-1 shrink-0">
<span
class="text-2xs font-mono text-fg-subtle tracking-wide uppercase flex items-center justify-between"
Expand Down Expand Up @@ -1879,7 +1770,7 @@ watch(selectedMetric, value => {
</TooltipApp>
</span>
<label
class="flex items-center gap-1.5 text-2xs font-mono text-fg-subtle cursor-pointer"
class="flex items-center gap-1.5 text-2xs font-mono text-fg-subtle cursor-pointer h-4"
:class="{ 'opacity-50 pointer-events-none': !hasAnomalies }"
>
<input
Expand Down
2 changes: 2 additions & 0 deletions app/composables/useSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export interface AppSettings {
averageWindow: number
smoothingTau: number
anomaliesFixed: boolean
predictionPoints: number
}
}

Expand All @@ -68,6 +69,7 @@ const DEFAULT_SETTINGS: AppSettings = {
averageWindow: 0,
smoothingTau: 1,
anomaliesFixed: true,
predictionPoints: 4,
},
}

Expand Down
Loading
Loading