From e7b3e236c12f6973efbd35a191d71da5db960f0e Mon Sep 17 00:00:00 2001 From: graphieros Date: Mon, 1 Jun 2026 07:04:58 +0200 Subject: [PATCH 01/33] chore: bump vue-data-ui from 3.20.11 to 3.21.0 --- package.json | 2 +- pnpm-lock.yaml | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index dc0094c507..5836fd3901 100644 --- a/package.json +++ b/package.json @@ -111,7 +111,7 @@ "vite-plugin-pwa": "1.3.0", "vite-plus": "0.1.20", "vue": "3.5.34", - "vue-data-ui": "3.20.11", + "vue-data-ui": "3.21.0", "vue-router": "5.0.4" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 70bb836032..f06c0543de 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -253,8 +253,8 @@ importers: specifier: 3.5.34 version: 3.5.34(typescript@6.0.2) vue-data-ui: - specifier: 3.20.11 - version: 3.20.11(vue@3.5.34) + specifier: 3.21.0 + version: 3.21.0(vue@3.5.34) vue-router: specifier: 5.0.4 version: 5.0.4(@vue/compiler-sfc@3.5.34)(vue@3.5.34) @@ -11164,11 +11164,11 @@ packages: vue-component-type-helpers@3.2.8: resolution: {integrity: sha512-9689efAXhN/EV86plgkL/XFiJSXhGtWPG6JDboZ+QnjlUWUUQrQ0ILKQtw4iQsuwIwu5k6Aw+JnehDe7161e7A==} - vue-component-type-helpers@3.3.2: - resolution: {integrity: sha512-l4Z2Y34m7nFMlx8vrslJaVtXxUpzgDMSESC7TakG/c5kwjYT/do+E0NcT2/vWDzaoIhsShg/2OKwX7Q4nbzC0g==} + vue-component-type-helpers@3.3.3: + resolution: {integrity: sha512-x4nsFpy5Pe8fqPzp/5vkTPeTTDBpAx4WVtV47Ejt0+2FQrq4pRRsJs7JmYRqMFzTu/LW+pCWEjQ3YVCkPV7f9g==} - vue-data-ui@3.20.11: - resolution: {integrity: sha512-BvK9amlFqGWF40J00guYmy4IYraDV1qrgMgKC48HGvn6HUCGw4HrAhRBr/7eRrRdMkR3ym6E77/GKLQhr1fQow==} + vue-data-ui@3.21.0: + resolution: {integrity: sha512-jfZhLeHa1c7t3wUbUBuz/HrgpxuvL4MC9DPPV9LlIl+qDEG/weGjXK9HBVGkkkhkArmlSa7yN2j6T+ghNSgc1Q==} peerDependencies: jspdf: '>=3.0.1' vue: '>=3.3.0' @@ -15857,7 +15857,7 @@ snapshots: storybook: 10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4)(react@19.2.4) type-fest: 2.19.0 vue: 3.5.34(typescript@6.0.2) - vue-component-type-helpers: 3.3.2 + vue-component-type-helpers: 3.3.3 '@surma/rollup-plugin-off-main-thread@2.2.3': dependencies: @@ -23687,9 +23687,9 @@ snapshots: vue-component-type-helpers@3.2.8: {} - vue-component-type-helpers@3.3.2: {} + vue-component-type-helpers@3.3.3: {} - vue-data-ui@3.20.11(vue@3.5.34): + vue-data-ui@3.21.0(vue@3.5.34): dependencies: vue: 3.5.34(typescript@6.0.2) From b1c5c931f0215b5632e79c8c2b3640e30e488ff5 Mon Sep 17 00:00:00 2001 From: graphieros Date: Mon, 1 Jun 2026 07:05:16 +0200 Subject: [PATCH 02/33] chore: add translations --- i18n/locales/en.json | 5 +++++ i18n/locales/fr-FR.json | 5 +++++ i18n/schema.json | 15 +++++++++++++++ 3 files changed, 25 insertions(+) diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 8118b181fb..da3da3eeec 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -712,6 +712,11 @@ "general_description": "The Y axis represents the number of downloads. The X axis represents the date range, from {start_date} to {end_date}, with a {granularity} time period.{estimation_notice} {packages_analysis}. {watermark}.", "facet_bar_general_description": "Horizontal bar chart for: {packages}, comparing {facet} ({description}). {facet_analysis} {watermark}.", "facet_bar_analysis": "{package_name} has a value of {value}." + }, + "embedding": { + "chart": "Embed this chart", + "copy_url": "Copy this URL to embed the chart into your website", + "preview": "Preview" } }, "downloads": { diff --git a/i18n/locales/fr-FR.json b/i18n/locales/fr-FR.json index ed341333d9..32c279ebaf 100644 --- a/i18n/locales/fr-FR.json +++ b/i18n/locales/fr-FR.json @@ -697,6 +697,11 @@ "general_description": "L’axe Y représente le nombre de téléchargements. L’axe X représente la plage de dates, de {start_date} à {end_date}, avec une période de temps {granularity}.{estimation_notice} {packages_analysis}. {watermark}.", "facet_bar_general_description": "Graphique en barres horizontales pour : {packages}, comparant {facet} ({description}). {facet_analysis} {watermark}.", "facet_bar_analysis": "{package_name} a une valeur de {value}." + }, + "embedding": { + "chart": "Intégrer ce graphique", + "copy_url": "Copiez cette URL pour intégrer le graphique à votre site web", + "preview": "Aperçu" } }, "downloads": { diff --git a/i18n/schema.json b/i18n/schema.json index 50b6c62d9e..170c06ea77 100644 --- a/i18n/schema.json +++ b/i18n/schema.json @@ -2142,6 +2142,21 @@ } }, "additionalProperties": false + }, + "embedding": { + "type": "object", + "properties": { + "chart": { + "type": "string" + }, + "copy_url": { + "type": "string" + }, + "preview": { + "type": "string" + } + }, + "additionalProperties": false } }, "additionalProperties": false From 5b403c461e61661a5c3e4bb7c0faaf972dcd401f Mon Sep 17 00:00:00 2001 From: graphieros Date: Mon, 1 Jun 2026 07:20:43 +0200 Subject: [PATCH 03/33] feat: add download charts static svg embedding --- app/components/Package/TrendsChart.vue | 657 ++++-------- app/composables/useChartWatermark.ts | 22 +- app/composables/useCharts.ts | 48 +- app/utils/date.ts | 27 + server/api/embed/downloads.svg/index.get.ts | 355 +++++++ server/utils/download-evolution.ts | 176 ++++ shared/utils/download-ranges.ts | 52 + shared/utils/embed-chart-colors.ts | 37 + shared/utils/trends-chart.ts | 687 +++++++++++++ test/unit/app/utils/date.spec.ts | 87 +- .../api/embed/downloads.svg/index.get.spec.ts | 713 +++++++++++++ .../server/utils/download-evolution.spec.ts | 259 +++++ .../unit/shared/utils/download-ranges.spec.ts | 134 +++ test/unit/shared/utils/trends-chart.spec.ts | 973 ++++++++++++++++++ 14 files changed, 3729 insertions(+), 498 deletions(-) create mode 100644 server/api/embed/downloads.svg/index.get.ts create mode 100644 server/utils/download-evolution.ts create mode 100644 shared/utils/download-ranges.ts create mode 100644 shared/utils/embed-chart-colors.ts create mode 100644 shared/utils/trends-chart.ts create mode 100644 test/unit/server/api/embed/downloads.svg/index.get.spec.ts create mode 100644 test/unit/server/utils/download-evolution.spec.ts create mode 100644 test/unit/shared/utils/download-ranges.spec.ts create mode 100644 test/unit/shared/utils/trends-chart.spec.ts diff --git a/app/components/Package/TrendsChart.vue b/app/components/Package/TrendsChart.vue index 9418d8db5e..6c5d289ae4 100644 --- a/app/components/Package/TrendsChart.vue +++ b/app/components/Package/TrendsChart.vue @@ -1,31 +1,28 @@ diff --git a/app/composables/useChartWatermark.ts b/app/composables/useChartWatermark.ts index 66829bf3c8..c823f1ebf1 100644 --- a/app/composables/useChartWatermark.ts +++ b/app/composables/useChartWatermark.ts @@ -1,3 +1,5 @@ +import { generateWatermarkLogo } from '~~/shared/utils/trends-chart' + /** * Shared utilities for chart watermarks and legends in SVG/PNG exports */ @@ -47,26 +49,6 @@ export function drawSvgPrintLegend(svg: Record, colors: WatermarkCo return seriesNames.join('') } -function generateWatermarkLogo({ - x, - y, - width, - height, - fill, -}: { - x: number - y: number - width: number - height: number - fill: string -}) { - return ` - - - - ` -} - /** * Build and return npmx svg logo and tagline, to be injected during PNG & SVG exports * for VueUiXy instances diff --git a/app/composables/useCharts.ts b/app/composables/useCharts.ts index c2b3fa039a..7ee50545ee 100644 --- a/app/composables/useCharts.ts +++ b/app/composables/useCharts.ts @@ -7,7 +7,10 @@ import type { YearlyDataPoint, } from '~/types/chart' import { mapWithConcurrency } from '#shared/utils/async' +import { splitIsoRangeIntoChunksInclusive, mergeDailyPoints } from '#shared/utils/download-ranges' import { fetchNpmDownloadsRange } from '~/utils/npm/api' +import { toValue } from 'vue' +import { addDays, toIsoDate, parseIsoDate } from '~/utils/date' export type PackumentLikeForTime = { time?: Record @@ -21,51 +24,6 @@ function startOfUtcYear(date: Date): Date { return new Date(Date.UTC(date.getUTCFullYear(), 0, 1)) } -function differenceInUtcDaysInclusive(startIso: string, endIso: string): number { - const start = parseIsoDate(startIso) - const end = parseIsoDate(endIso) - return Math.floor((end.getTime() - start.getTime()) / 86400000) + 1 -} - -function splitIsoRangeIntoChunksInclusive( - startIso: string, - endIso: string, - maximumDaysPerRequest: number, -): Array<{ startIso: string; endIso: string }> { - const totalDays = differenceInUtcDaysInclusive(startIso, endIso) - if (totalDays <= maximumDaysPerRequest) return [{ startIso, endIso }] - - const chunks: Array<{ startIso: string; endIso: string }> = [] - let cursorStart = parseIsoDate(startIso) - const finalEnd = parseIsoDate(endIso) - - while (cursorStart.getTime() <= finalEnd.getTime()) { - const cursorEnd = addDays(cursorStart, maximumDaysPerRequest - 1) - const actualEnd = cursorEnd.getTime() < finalEnd.getTime() ? cursorEnd : finalEnd - - chunks.push({ - startIso: toIsoDate(cursorStart), - endIso: toIsoDate(actualEnd), - }) - - cursorStart = addDays(actualEnd, 1) - } - - return chunks -} - -function mergeDailyPoints(points: DailyRawPoint[]): DailyRawPoint[] { - const valuesByDay = new Map() - - for (const point of points) { - valuesByDay.set(point.day, (valuesByDay.get(point.day) ?? 0) + point.value) - } - - return Array.from(valuesByDay.entries()) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([day, value]) => ({ day, value })) -} - const npmDailyRangeCache = import.meta.client ? new Map>() : null const likesEvolutionCache = import.meta.client ? new Map>() : null const contributorsEvolutionCache = import.meta.client diff --git a/app/utils/date.ts b/app/utils/date.ts index c3165feb2e..67a74b2678 100644 --- a/app/utils/date.ts +++ b/app/utils/date.ts @@ -22,3 +22,30 @@ export function daysInMonth(year: number, month: number): number { export function daysInYear(year: number): number { return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 366 : 365 } + +export function getEffectiveEndDateIso(endDate?: string): string { + if (endDate) { + return endDate + } + + const today = new Date() + + return new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() - 1)) + .toISOString() + .slice(0, 10) +} + +export function isLastDayOfMonth(dateIso: string): boolean { + const date = new Date(`${dateIso}T00:00:00.000Z`) + + const lastDayOfMonth = new Date( + Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 0), + ).getUTCDate() + + return date.getUTCDate() === lastDayOfMonth +} + +export function isLastDayOfYear(dateIso: string): boolean { + const date = new Date(`${dateIso}T00:00:00.000Z`) + return date.getUTCMonth() === 11 && date.getUTCDate() === 31 +} diff --git a/server/api/embed/downloads.svg/index.get.ts b/server/api/embed/downloads.svg/index.get.ts new file mode 100644 index 0000000000..584dac05ec --- /dev/null +++ b/server/api/embed/downloads.svg/index.get.ts @@ -0,0 +1,355 @@ +/** + * Embedding of the downloads trends chart (single package via ChartModal, or multiple via compare) + * A static svg is generated from the endpoint. + */ + +import { createStaticVueUiXy } from 'vue-data-ui/ssr' +import { fetchDownloadsEvolution } from '../../../utils/download-evolution' +import { + buildNormalisedTrendsDataset, + buildTrendsChartConfig, + buildTrendsChartData, +} from '#shared/utils/trends-chart' +import { resolveEmbedChartColors } from '#shared/utils/embed-chart-colors' +import { mergeConfigs } from 'vue-data-ui/utils' +import { getEffectiveEndDateIso, isLastDayOfMonth, isLastDayOfYear } from '~/utils/date' +import { generateWatermarkLogo } from '#shared/utils/trends-chart' +import { OKLCH_NEUTRAL_FALLBACK } from '~/utils/colors' + +type FetchGranularity = 'day' | 'week' | 'month' | 'year' +type ChartGranularity = 'daily' | 'weekly' | 'monthly' | 'yearly' +type Metric = 'downloads' | 'likes' | 'contributors' + +export default defineCachedEventHandler( + async event => { + const query = getQuery(event) + + const packageNames = parsePackageNames(query.packages ?? query.package) + + if (!packageNames.length) { + throw createError({ + statusCode: 400, + statusMessage: 'Missing package name. Use ?package=nuxt or ?packages=vite,rolldown', + }) + } + + const fetchGranularity = parseFetchGranularity(query.granularity) + const chartGranularity = toChartGranularity(fetchGranularity) + const metric = parseMetric(query.metric) + const isDarkMode = query.mode === 'dark' + const width = clampNumber(query.width, 360, 1600, 900) + const height = clampNumber(query.height, 240, 900, 420) + + // sanitized + const locale = parseLocale(query.locale) + const accent = parseAccent(query.accent) + const yLabel = parseSafeText(query.yLabel, '', 100) + + const evolutionOptions = { + granularity: fetchGranularity, + weeks: clampNumber(query.weeks, 1, 260, 52), + months: clampNumber(query.months, 1, 120, 12), + startDate: parseDateQuery(query.startDate ?? query.start), + endDate: parseDateQuery(query.endDate ?? query.end), + } + + if (metric !== 'downloads') { + throw createError({ + statusCode: 501, + statusMessage: 'Only the downloads metric is currently supported by the SVG endpoint', + }) + } + + const evolutionsByPackage = Object.fromEntries( + await Promise.all( + packageNames.map(async packageName => [ + packageName, + await fetchDownloadsEvolution(packageName, evolutionOptions), + ]), + ), + ) + + const colors = resolveEmbedChartColors(isDarkMode ? 'dark' : 'light') + const compactNumberFormatter = new Intl.NumberFormat(locale, { + notation: 'compact', + maximumFractionDigits: 1, + }) + + const chartFilter = { + averageWindow: 1, + smoothingTau: 0, + predictionPoints: 0, + } + + const chartData = buildTrendsChartData({ + packageNames, + effectivePackageNamesForMetric: packageNames, + isMultiPackageMode: packageNames.length > 1, + selectedMetric: metric, + selectedMetricLabel: '', + selectedGranularity: chartGranularity, + displayedGranularity: chartGranularity, + singleEvolution: evolutionsByPackage[packageNames[0]!] ?? [], + evolutionsByPackage, + colors, + isDarkMode, + chartFilter, + t: (() => {}) as any, + compactNumberFormatter, + accent, + }) + + const dataset = buildNormalisedTrendsDataset({ + dataset: chartData.dataset, + dates: chartData.dates, + granularity: chartGranularity, + selectedMetric: metric, + chartFilter, + nowMs: Date.now(), + }) + + const baseConfig = buildTrendsChartConfig({ + packageNames, + effectivePackageNamesForMetric: packageNames, + isMultiPackageMode: packageNames.length > 1, + selectedMetric: metric, + selectedMetricLabel: '', + selectedGranularity: chartGranularity, + displayedGranularity: chartGranularity, + singleEvolution: evolutionsByPackage[packageNames[0]!] ?? [], + evolutionsByPackage, + dates: chartData.dates, + colors, + isDarkMode, + isMobile: false, + pending: false, + locale, + chartHeight: height, + chartFilter, + t: (() => {}) as any, + compactNumberFormatter, + tooltipPosition: 'center', + }) + + const config = mergeConfigs({ + defaultConfig: baseConfig, + userConfig: { + line: { + useGradient: false, + area: { + opacity: 12, + }, + }, + chart: { + width, + height, + padding: { + left: 12, + right: 120, + }, + legend: { + show: true, + position: 'top', + fontSize: 24, + color: colors.fgMuted, + }, + grid: { + labels: { + fontSize: 12, + axis: { + fontSize: 16, + yLabel, + }, + yAxis: { + scaleLabelOffsetX: 0, + crosshairSize: 6, + }, + xAxisLabels: { + fontSize: 12, + color: colors.fgMuted, + }, + }, + }, + }, + }, + }) + + if (!chartData.dataset?.length) { + throw createError({ + statusCode: 404, + statusMessage: 'No chart dataset generated', + }) + } + + if (!dataset.length) { + throw createError({ + statusCode: 404, + statusMessage: 'No normalized dataset generated', + }) + } + + const effectiveEndDateIso = getEffectiveEndDateIso(evolutionOptions.endDate) + + const shouldDashLastPoint = + (chartGranularity === 'monthly' && !isLastDayOfMonth(effectiveEndDateIso)) || + (chartGranularity === 'yearly' && !isLastDayOfYear(effectiveEndDateIso)) + + const svg = await createStaticVueUiXy({ + dataset: dataset.map(datapoint => { + const dashIndices = shouldDashLastPoint + ? [...new Set([...(datapoint.dashIndices ?? []), datapoint.series.length - 1])].filter( + index => index >= 0, + ) + : datapoint.dashIndices + + return Object.assign({}, datapoint, { dashIndices }) + }), + config, + additionalSvgContent: ({ series, drawingArea }) => { + const lastPlotValues = series + .map(serie => { + const lastPlot = serie.plots.at(-1) + if (!lastPlot) return '' + return ` + + ${compactNumberFormatter.format(Number(lastPlot.value ?? 0))} + + ` + }) + .join('') + + const logo = generateWatermarkLogo({ + x: 12, + y: drawingArea.bottom + 60, + width: 80, + height: 30, + fill: colors.fgSubtle, + }) + + return ` + ${lastPlotValues} + ${logo} + ` + }, + }) + + setHeader(event, 'Content-Type', 'image/svg+xml; charset=utf-8') + setHeader(event, 'Cache-Control', 'public, max-age=3600, s-maxage=86400') + + return svg + }, + { + maxAge: 3600, + swr: true, + }, +) + +function parseMetric(value: unknown): Metric { + if (value === 'likes') { + return 'likes' + } + + if (value === 'contributors') { + return 'contributors' + } + + return 'downloads' +} + +function toChartGranularity(granularity: FetchGranularity): ChartGranularity { + const mapping: Record = { + day: 'daily', + week: 'weekly', + month: 'monthly', + year: 'yearly', + } + + return mapping[granularity] +} + +function clampNumber(value: unknown, minimum: number, maximum: number, fallback: number): number { + const parsed = Number(value) + + if (!Number.isFinite(parsed)) { + return fallback + } + + return Math.min(maximum, Math.max(minimum, parsed)) +} + +function parseFetchGranularity(value: unknown): FetchGranularity { + if (value === 'daily' || value === 'day') return 'day' + if (value === 'weekly' || value === 'week') return 'week' + if (value === 'monthly' || value === 'month') return 'month' + if (value === 'yearly' || value === 'year') return 'year' + return 'week' +} + +function parseDateQuery(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined + const date = value.trim() + return /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : undefined +} + +function parseSafeText(value: unknown, fallback = '', maximumLength = 100): string { + if (typeof value !== 'string') return fallback + return ( + value + .replace(/[<>&"'`]/g, '') + // eslint-disable-next-line no-control-regex + .replace(/[\u{0000}-\u{001F}\u{007F}]/gu, '') + .trim() + .slice(0, maximumLength) + ) +} + +function parseLocale(value: unknown): string { + if (typeof value !== 'string') return 'en' + try { + return Intl.getCanonicalLocales(value.trim())[0] ?? 'en' + } catch { + return 'en' + } +} + +function parseAccent(value: unknown): string { + if (typeof value !== 'string') { + return OKLCH_NEUTRAL_FALLBACK + } + + const accent = value.trim() + + // HEX + if (/^#(?:[\da-f]{3}|[\da-f]{6})$/i.test(accent)) { + return accent + } + + // OKLCH + if ( + /^oklch\(\s*(?:0|1|0?\.\d+|\d{1,3}(?:\.\d+)?%)\s+(?:0|0?\.\d+|\d+(?:\.\d+)?)\s+(?:\d+(?:\.\d+)?|none)(?:deg|rad|grad|turn)?(?:\s*\/\s*(?:0|1|0?\.\d+|\d{1,3}(?:\.\d+)?%))?\s*\)$/i.test( + accent, + ) + ) { + return accent + } + + return OKLCH_NEUTRAL_FALLBACK +} + +function parsePackageNames(value: unknown): string[] { + return String(value ?? '') + .split(',') + .map(name => name.trim().toLowerCase()) + .filter(name => /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/.test(name)) + .slice(0, 8) +} diff --git a/server/utils/download-evolution.ts b/server/utils/download-evolution.ts new file mode 100644 index 0000000000..ffa2ea15f2 --- /dev/null +++ b/server/utils/download-evolution.ts @@ -0,0 +1,176 @@ +import type { EvolutionOptions } from '~/types/chart' + +import { mapWithConcurrency } from '#shared/utils/async' + +import { + buildDailyEvolution, + buildMonthlyEvolution, + buildWeeklyEvolution, + buildYearlyEvolution, +} from '~/utils/chart-data-buckets' + +import { addDays, parseIsoDate, toIsoDate } from '~/utils/date' + +import { + mergeDailyPoints, + splitIsoRangeIntoChunksInclusive, +} from '../../shared/utils/download-ranges' + +type DailyDownloadPoint = { + day: string + value: number +} + +type NpmDownloadsRangeResponse = { + downloads: Array<{ + day: string + downloads: number + }> +} + +function toDateOnly(value?: string): string | null { + if (!value) { + return null + } + + const dateOnly = value.slice(0, 10) + + return /^\d{4}-\d{2}-\d{2}$/.test(dateOnly) ? dateOnly : null +} + +function startOfUtcMonth(date: Date): Date { + return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1)) +} + +function startOfUtcYear(date: Date): Date { + return new Date(Date.UTC(date.getUTCFullYear(), 0, 1)) +} + +function resolveDateRange(evolutionOptions: EvolutionOptions, packageCreatedIso: string | null) { + const today = new Date() + + const yesterday = new Date( + Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() - 1), + ) + + const endDateOnly = toDateOnly(evolutionOptions.endDate) + + const end = endDateOnly ? parseIsoDate(endDateOnly) : yesterday + + const startDateOnly = toDateOnly(evolutionOptions.startDate) + + if (startDateOnly) { + return { + start: parseIsoDate(startDateOnly), + end, + } + } + + let start: Date + + if (evolutionOptions.granularity === 'year') { + start = packageCreatedIso + ? startOfUtcYear(new Date(packageCreatedIso)) + : addDays(end, -(5 * 365) + 1) + } else if (evolutionOptions.granularity === 'month') { + const monthCount = evolutionOptions.months ?? 12 + const firstOfThisMonth = startOfUtcMonth(end) + + start = new Date( + Date.UTC( + firstOfThisMonth.getUTCFullYear(), + firstOfThisMonth.getUTCMonth() - (monthCount - 1), + 1, + ), + ) + } else if (evolutionOptions.granularity === 'week') { + const weekCount = evolutionOptions.weeks ?? 52 + + start = addDays(end, -(weekCount * 7) + 1) + } else { + start = addDays(end, -29) + } + + return { + start, + end, + } +} + +async function fetchNpmDownloadsRangeServer( + packageName: string, + startIso: string, + endIso: string, +): Promise { + const encodedName = encodeURIComponent(packageName) + + return await $fetch( + `https://api.npmjs.org/downloads/range/${startIso}:${endIso}/${encodedName}`, + ) +} + +async function fetchDailyRange( + packageName: string, + startIso: string, + endIso: string, +): Promise { + const response = await fetchNpmDownloadsRangeServer(packageName, startIso, endIso) + + return response.downloads + .slice() + .sort((a, b) => a.day.localeCompare(b.day)) + .map(download => ({ + day: download.day, + value: download.downloads, + })) +} + +async function fetchDailyRangeChunked( + packageName: string, + startIso: string, + endIso: string, +): Promise { + const maximumDaysPerRequest = 540 + + const ranges = splitIsoRangeIntoChunksInclusive(startIso, endIso, maximumDaysPerRequest) + + if (ranges.length === 1) { + return fetchDailyRange(packageName, startIso, endIso) + } + + const parts = await mapWithConcurrency( + ranges, + range => fetchDailyRange(packageName, range.startIso, range.endIso), + 10, + ) + + return mergeDailyPoints(parts.flat()) +} + +export async function fetchDownloadsEvolution( + packageName: string, + evolutionOptions: EvolutionOptions, + packageCreatedIso: string | null = null, +) { + const { start, end } = resolveDateRange(evolutionOptions, packageCreatedIso) + + const startIso = toIsoDate(start) + const endIso = toIsoDate(end) + + const dailyData = await fetchDailyRangeChunked(packageName, startIso, endIso) + + switch (evolutionOptions.granularity) { + case 'year': + return buildYearlyEvolution(dailyData, startIso, endIso) + + case 'month': + return buildMonthlyEvolution(dailyData, startIso, endIso) + + case 'week': + return buildWeeklyEvolution(dailyData, startIso, endIso) + + case 'day': + default: + return buildDailyEvolution(dailyData) + } +} diff --git a/shared/utils/download-ranges.ts b/shared/utils/download-ranges.ts new file mode 100644 index 0000000000..c555493a9c --- /dev/null +++ b/shared/utils/download-ranges.ts @@ -0,0 +1,52 @@ +import type { DailyRawPoint } from '~/types/chart' +import { addDays, parseIsoDate, toIsoDate } from '~/utils/date' + +export function differenceInUtcDaysInclusive(startIso: string, endIso: string): number { + const start = parseIsoDate(startIso) + const end = parseIsoDate(endIso) + + return Math.floor((end.getTime() - start.getTime()) / 86400000) + 1 +} + +export function splitIsoRangeIntoChunksInclusive( + startIso: string, + endIso: string, + maximumDaysPerRequest: number, +): Array<{ startIso: string; endIso: string }> { + const totalDays = differenceInUtcDaysInclusive(startIso, endIso) + + if (totalDays <= maximumDaysPerRequest) { + return [{ startIso, endIso }] + } + + const chunks: Array<{ startIso: string; endIso: string }> = [] + + let cursorStart = parseIsoDate(startIso) + const finalEnd = parseIsoDate(endIso) + + while (cursorStart.getTime() <= finalEnd.getTime()) { + const cursorEnd = addDays(cursorStart, maximumDaysPerRequest - 1) + const actualEnd = cursorEnd.getTime() < finalEnd.getTime() ? cursorEnd : finalEnd + + chunks.push({ + startIso: toIsoDate(cursorStart), + endIso: toIsoDate(actualEnd), + }) + + cursorStart = addDays(actualEnd, 1) + } + + return chunks +} + +export function mergeDailyPoints(points: DailyRawPoint[]): DailyRawPoint[] { + const valuesByDay = new Map() + + for (const point of points) { + valuesByDay.set(point.day, (valuesByDay.get(point.day) ?? 0) + point.value) + } + + return Array.from(valuesByDay.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([day, value]) => ({ day, value })) +} diff --git a/shared/utils/embed-chart-colors.ts b/shared/utils/embed-chart-colors.ts new file mode 100644 index 0000000000..990c617da6 --- /dev/null +++ b/shared/utils/embed-chart-colors.ts @@ -0,0 +1,37 @@ +type EmbedTheme = 'light' | 'dark' + +export function resolveEmbedChartColors(theme: EmbedTheme = 'light') { + // duplicated from main.css + const palettes = { + dark: { + default: { + bg: 'oklch(0.171 0 0)', + bgSubtle: 'oklch(0.198 0 0)', + bgMuted: 'oklch(0.236 0 0)', + bgElevated: 'oklch(0.266 0 0)', + fg: 'oklch(0.982 0 0)', + fgMuted: 'oklch(0.749 0 0)', + fgSubtle: 'oklch(0.673 0 0)', + border: 'oklch(0.269 0 0)', + accent: 'oklch(0.787 0.128 230.318)', + }, + }, + light: { + default: { + bg: 'oklch(1 0 0)', + bgSubtle: 'oklch(0.979 0.001 286.375)', + bgMuted: 'oklch(0.955 0.001 286.76)', + bgElevated: 'oklch(0.94 0.002 287.29)', + fg: 'oklch(0.146 0 0)', + fgMuted: 'oklch(0.398 0 0)', + fgSubtle: 'oklch(0.48 0 0)', + border: 'oklch(0.8514 0 0)', + accent: 'oklch(0.5 0.16 247.27)', + }, + }, + } as const + + return { + ...palettes[theme].default, + } +} diff --git a/shared/utils/trends-chart.ts b/shared/utils/trends-chart.ts new file mode 100644 index 0000000000..626f5119e5 --- /dev/null +++ b/shared/utils/trends-chart.ts @@ -0,0 +1,687 @@ +// Shared utilies for client & embed versions of the downloads trend chart + +import type { TextAlign, Theme as VueDataUiTheme } from 'vue-data-ui' +import type { VueUiXyConfig, VueUiXyDatasetItem } from 'vue-data-ui/vue-ui-xy' +import { OKLCH_NEUTRAL_FALLBACK, lightenOklch } from '~/utils/colors' +import { getFrameworkColor, isListedFramework } from '~/utils/frameworks' +import { applyEllipsis } from '~/utils/charts' +import { applyDataPipeline, DEFAULT_PREDICTION_POINTS } from '~/utils/chart-data-prediction' +import type { + ChartTimeGranularity, + DailyDataPoint, + EvolutionData, + MonthlyDataPoint, + WeeklyDataPoint, + YearlyDataPoint, +} from '~/types/chart' + +type TrendMetricId = 'downloads' | 'likes' | 'contributors' + +type TrendColors = Record + +type TrendFormatter = { + format: (value: number) => string +} + +type TranslateFn = (key: string, params?: Record) => string + +type TrendChartBaseOptions = { + packageNames: string[] + isMultiPackageMode: boolean + selectedMetric: TrendMetricId + selectedMetricLabel: string + selectedGranularity: ChartTimeGranularity + displayedGranularity: ChartTimeGranularity + singleEvolution: EvolutionData + evolutionsByPackage?: Record + effectivePackageNamesForMetric?: string[] + colors: TrendColors + accent?: string + isDarkMode: boolean + chartFilter: { + averageWindow: number + smoothingTau: number + predictionPoints?: number + } + useAnomalyCorrection?: boolean + applyAnomalyCorrection?: (params: { + data: EvolutionData + packageName: string + granularity: ChartTimeGranularity + }) => EvolutionData + t: TranslateFn + compactNumberFormatter: Intl.NumberFormat +} + +type TrendChartDataOptions = TrendChartBaseOptions + +type TrendChartConfigOptions = TrendChartBaseOptions & { + dates: number[] + isMobile: boolean + pending: boolean + locale: string + chartHeight: number + inModal?: boolean + tooltipPosition?: string +} +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +export function isWeeklyDataset(data: unknown): data is WeeklyDataPoint[] { + return ( + Array.isArray(data) && + data.length > 0 && + isRecord(data[0]) && + 'weekStart' in data[0] && + 'weekEnd' in data[0] && + 'value' in data[0] + ) +} + +export function isDailyDataset(data: unknown): data is DailyDataPoint[] { + return ( + Array.isArray(data) && + data.length > 0 && + isRecord(data[0]) && + 'day' in data[0] && + 'value' in data[0] + ) +} + +export function isMonthlyDataset(data: unknown): data is MonthlyDataPoint[] { + return ( + Array.isArray(data) && + data.length > 0 && + isRecord(data[0]) && + 'month' in data[0] && + 'value' in data[0] + ) +} + +export function isYearlyDataset(data: unknown): data is YearlyDataPoint[] { + return ( + Array.isArray(data) && + data.length > 0 && + isRecord(data[0]) && + 'year' in data[0] && + 'value' in data[0] + ) +} + +function extractSeriesPoints( + granularity: ChartTimeGranularity, + dataset: EvolutionData, +): Array<{ timestamp: number; value: number; hasAnomaly: boolean }> { + if (granularity === 'weekly' && isWeeklyDataset(dataset)) { + return dataset.map(item => ({ + timestamp: item.timestampEnd, + value: item.value, + hasAnomaly: !!item.hasAnomaly, + })) + } + + if ( + (granularity === 'daily' && isDailyDataset(dataset)) || + (granularity === 'monthly' && isMonthlyDataset(dataset)) || + (granularity === 'yearly' && isYearlyDataset(dataset)) + ) { + return (dataset as Array<{ timestamp: number; value: number; hasAnomaly?: boolean }>).map( + item => ({ + timestamp: item.timestamp, + value: item.value, + hasAnomaly: !!item.hasAnomaly, + }), + ) + } + + return [] +} + +function formatSingleXyDataset(options: { + granularity: ChartTimeGranularity + dataset: EvolutionData + seriesName: string + accent: string + isDarkMode: boolean +}): { dataset: VueUiXyDatasetItem[] | null; dates: number[] } { + const lightColor = options.isDarkMode ? lightenOklch(options.accent, 0.618) : undefined + const temperatureColors = lightColor ? [lightColor, options.accent] : undefined + + const datasetItem: VueUiXyDatasetItem = { + name: applyEllipsis(options.seriesName, 32), + type: 'line', + series: options.dataset.map(item => item.value), + color: options.accent, + temperatureColors, + useArea: true, + dashIndices: options.dataset + .map((item, index) => (item.hasAnomaly ? index : -1)) + .filter(index => index !== -1), + } + + if (options.granularity === 'weekly' && isWeeklyDataset(options.dataset)) { + return { + dataset: [datasetItem], + dates: options.dataset.map(item => item.timestampEnd), + } + } + + if (options.granularity === 'daily' && isDailyDataset(options.dataset)) { + return { + dataset: [datasetItem], + dates: options.dataset.map(item => item.timestamp), + } + } + + if (options.granularity === 'monthly' && isMonthlyDataset(options.dataset)) { + return { + dataset: [datasetItem], + dates: options.dataset.map(item => item.timestamp), + } + } + + if (options.granularity === 'yearly' && isYearlyDataset(options.dataset)) { + return { + dataset: [datasetItem], + dates: options.dataset.map(item => item.timestamp), + } + } + + return { dataset: null, dates: [] } +} + +export function buildTrendsChartData(options: TrendChartDataOptions): { + dataset: VueUiXyDatasetItem[] | null + dates: number[] +} { + const accent = options.accent ?? options.colors.fgSubtle ?? OKLCH_NEUTRAL_FALLBACK + + if (!options.isMultiPackageMode) { + const packageName = options.packageNames[0] ?? '' + return formatSingleXyDataset({ + granularity: options.displayedGranularity, + dataset: options.singleEvolution, + seriesName: packageName, + accent, + isDarkMode: options.isDarkMode, + }) + } + + const names = options.effectivePackageNamesForMetric ?? options.packageNames + const timestampSet = new Set() + const pointsByPackage = new Map< + string, + Array<{ timestamp: number; value: number; hasAnomaly?: boolean }> + >() + + for (const packageName of names) { + let data = options.evolutionsByPackage?.[packageName] ?? [] + + if ( + options.selectedMetric === 'downloads' && + options.useAnomalyCorrection && + options.applyAnomalyCorrection + ) { + data = options.applyAnomalyCorrection({ + data, + packageName, + granularity: options.displayedGranularity, + }) + } + + const points = extractSeriesPoints(options.displayedGranularity, data) + pointsByPackage.set(packageName, points) + + for (const point of points) { + timestampSet.add(point.timestamp) + } + } + + const dates = Array.from(timestampSet).sort((a, b) => a - b) + + if (!dates.length) { + return { dataset: null, dates: [] } + } + + const dataset = names.map(packageName => { + const points = pointsByPackage.get(packageName) ?? [] + const valueByTimestamp = new Map() + const anomalyTimestamps = new Set() + + for (const point of points) { + valueByTimestamp.set(point.timestamp, point.value) + + if (point.hasAnomaly) { + anomalyTimestamps.add(point.timestamp) + } + } + + const series = dates.map(timestamp => valueByTimestamp.get(timestamp) ?? 0) + const dashIndices = dates + .map((timestamp, index) => (anomalyTimestamps.has(timestamp) ? index : -1)) + .filter(index => index !== -1) + + const item: VueUiXyDatasetItem = { + name: applyEllipsis(packageName, 32), + type: 'line', + series, + dashIndices, + } + + if (isListedFramework(packageName)) { + item.color = getFrameworkColor(packageName) + } + + return item + }) + + return { dataset, dates } +} + +type TrendsNormalisedDatasetItem = VueUiXyDatasetItem & { + color?: string + series: number[] + dashIndices?: number[] +} + +export function buildNormalisedTrendsDataset(options: { + dataset: VueUiXyDatasetItem[] | null + dates: number[] + granularity: ChartTimeGranularity + selectedMetric: TrendMetricId + chartFilter: { + averageWindow: number + smoothingTau: number + predictionPoints?: number + } + endDateMs?: number | null + nowMs?: number +}): TrendsNormalisedDatasetItem[] { + const referenceMs = options.endDateMs ?? options.nowMs ?? Date.now() + const lastDateMs = options.dates.at(-1) ?? 0 + const isAbsoluteMetric = options.selectedMetric === 'contributors' + + return (options.dataset ?? []).map(item => { + const sourceSeries = item.series.map(value => { + if (typeof value === 'number') { + return value + } + + if (value && typeof value === 'object' && typeof value.y === 'number') { + return value.y + } + + return 0 + }) + + const series = applyDataPipeline( + sourceSeries, + { + averageWindow: options.chartFilter.averageWindow, + smoothingTau: options.chartFilter.smoothingTau, + predictionPoints: + options.granularity === 'weekly' + ? 0 + : (options.chartFilter.predictionPoints ?? DEFAULT_PREDICTION_POINTS), + }, + { + granularity: options.granularity, + lastDateMs, + referenceMs, + isAbsoluteMetric, + }, + ) + + return Object.assign({}, item, { + series, + dashIndices: item.dashIndices ?? [], + }) + }) +} +export function getTrendsDatetimeFormatterOptions(granularity: ChartTimeGranularity) { + return { + daily: { year: 'yyyy-MM-dd', month: 'yyyy-MM-dd', day: 'yyyy-MM-dd' }, + weekly: { year: 'yyyy-MM-dd', month: 'yyyy-MM-dd', day: 'yyyy-MM-dd' }, + monthly: { year: 'MMM yyyy', month: 'MMM yyyy', day: 'MMM yyyy' }, + yearly: { year: 'yyyy', month: 'yyyy', day: 'yyyy' }, + }[granularity] +} + +export function buildTrendsChartConfig( + options: TrendChartConfigOptions & { + dates: number[] + }, +): VueUiXyConfig { + return { + theme: options.isDarkMode ? 'dark' : ('' as VueDataUiTheme), + downsample: { + threshold: 5000, + }, + a11y: { + translations: { + keyboardNavigation: options.t( + 'package.trends.chart_assistive_text.keyboard_navigation_horizontal', + ), + tableAvailable: options.t('package.trends.chart_assistive_text.table_available'), + tableCaption: options.t('package.trends.chart_assistive_text.table_caption'), + }, + }, + chart: { + height: options.chartHeight, + backgroundColor: options.colors.bg, + padding: { + bottom: options.displayedGranularity === 'yearly' ? 84 : 64, + right: 145, + }, + userOptions: { + buttons: { + pdf: false, + labels: false, + fullscreen: false, + table: false, + tooltip: false, + altCopy: true, + }, + buttonTitles: { + csv: options.t('package.trends.download_file', { fileType: 'CSV' }), + img: options.t('package.trends.download_file', { fileType: 'PNG' }), + svg: options.t('package.trends.download_file', { fileType: 'SVG' }), + annotator: options.t('package.trends.toggle_annotator'), + stack: options.t('package.trends.toggle_stack_mode'), + altCopy: options.t('package.trends.copy_alt.button_label'), + open: options.t('package.trends.open_options'), + close: options.t('package.trends.close_options'), + }, + useCursorPointer: true, + }, + grid: { + position: 'start', + stroke: options.colors.border, + showHorizontalLines: true, + labels: { + fontSize: options.isMobile ? 24 : 16, + color: options.pending ? options.colors.border : options.colors.fgSubtle, + axis: { + yLabel: options.t('package.trends.y_axis_label', { + granularity: options.t(`package.trends.granularity_${options.selectedGranularity}`), + facet: options.selectedMetricLabel, + }), + yLabelOffsetX: 12, + fontSize: options.isMobile ? 32 : 24, + }, + xAxisLabels: { + show: true, + showOnlyAtModulo: true, + modulo: 12, + values: options.dates, + datetimeFormatter: { + enable: true, + locale: options.locale, + useUTC: true, + options: getTrendsDatetimeFormatterOptions(options.selectedGranularity), + }, + }, + yAxis: { + formatter: ({ value }: { value: number }) => { + return options.compactNumberFormatter.format(Number.isFinite(value) ? value : 0) + }, + useNiceScale: true, + gap: 24, + }, + }, + }, + timeTag: { + show: true, + backgroundColor: options.colors.bgElevated ?? options.colors.bg, + color: options.colors.fg, + fontSize: 16, + circleMarker: { + radius: 3, + color: options.colors.border, + }, + useDefaultFormat: true, + timeFormat: 'yyyy-MM-dd HH:mm:ss', + }, + highlighter: { + useLine: true, + }, + legend: { + show: false, + position: 'top', + }, + tooltip: { + teleportTo: options.inModal ? '#chart-modal' : undefined, + position: (options.tooltipPosition ?? 'center') as TextAlign, + offsetX: 24, + offsetY: options.isMultiPackageMode ? undefined : -24, + borderColor: 'transparent', + backdropFilter: false, + backgroundColor: 'transparent', + }, + }, + line: { + radius: 4, + useGradient: true, + dot: { + useSerieColor: true, + }, + labels: { + show: false, + }, + area: { + useGradient: true, + opacity: 12, + }, + }, + } +} + +export function drawTrendsEstimationLine(options: { + svg: Record + colors: TrendColors + shouldRender: boolean +}): string { + if (!options.shouldRender) { + return '' + } + + const data = Array.isArray(options.svg?.data) + ? options.svg.data + : Array.isArray(options.svg?.series) + ? options.svg.series + : [] + + if (!data.length) { + return '' + } + + const lines: string[] = [] + + for (const serie of data) { + const plots = serie?.plots + + if (!Array.isArray(plots) || plots.length < 2) { + continue + } + + const previousPoint = plots.at(-2) + const lastPoint = plots.at(-1) + + if (!previousPoint || !lastPoint) { + continue + } + + const stroke = String(serie?.color ?? options.colors.fg) + + lines.push(` + + + + `) + } + + return lines.join('\n') +} + +export function drawTrendsLastDatapointLabel(options: { + svg: Record + colors: TrendColors + compactNumberFormatter: TrendFormatter +}): string { + const data = Array.isArray(options.svg?.data) + ? options.svg.data + : Array.isArray(options.svg?.series) + ? options.svg.series + : [] + + if (!data.length) { + return '' + } + + const labels: string[] = [] + + for (const serie of data) { + const lastPlot = serie?.plots?.at(-1) + + if (!lastPlot) { + continue + } + + labels.push(` + + ${options.compactNumberFormatter.format(Number.isFinite(lastPlot.value) ? lastPlot.value : 0)} + + `) + } + + return labels.join('\n') +} + +export function drawTrendsSvgPrintLegend(options: { + svg: Record + colors: TrendColors + showEstimationLegend: boolean + estimationLabel: string +}): string { + const data = Array.isArray(options.svg?.data) + ? options.svg.data + : Array.isArray(options.svg?.series) + ? options.svg.series + : [] + + if (!data.length) { + return '' + } + + const output: string[] = [] + + data.forEach((serie, index) => { + output.push(` + + + ${serie.name} + + `) + }) + + if (options.showEstimationLegend) { + output.push(` + + + ${options.estimationLabel} + + `) + } + + return output.join('\n') +} + +export function generateWatermarkLogo({ + x, + y, + width, + height, + fill, +}: { + x: number + y: number + width: number + height: number + fill: string +}) { + return ` + + + + ` +} diff --git a/test/unit/app/utils/date.spec.ts b/test/unit/app/utils/date.spec.ts index 34517e6aa3..ee17fb377f 100644 --- a/test/unit/app/utils/date.spec.ts +++ b/test/unit/app/utils/date.spec.ts @@ -1,5 +1,15 @@ -import { describe, expect, it } from 'vitest' -import { addDays, DAY_MS, daysInMonth, daysInYear, parseIsoDate, toIsoDate } from '~/utils/date' +import { describe, expect, it, vi } from 'vitest' +import { + addDays, + DAY_MS, + daysInMonth, + daysInYear, + parseIsoDate, + toIsoDate, + getEffectiveEndDateIso, + isLastDayOfMonth, + isLastDayOfYear, +} from '~/utils/date' describe('DAY_MS', () => { it('equals 86 400 000', () => { @@ -89,3 +99,76 @@ describe('daysInYear', () => { expect(daysInYear(2000)).toBe(366) }) }) + +describe('getEffectiveEndDateIso', () => { + it('returns the provided end date when present', () => { + expect(getEffectiveEndDateIso('2026-05-31')).toBe('2026-05-31') + }) + + it('returns yesterday in UTC when no end date is provided', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-06-01T12:00:00.000Z')) + + expect(getEffectiveEndDateIso()).toBe('2026-05-31') + + vi.useRealTimers() + }) + + it('handles UTC month boundaries', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-03-01T00:30:00.000Z')) + + expect(getEffectiveEndDateIso()).toBe('2026-02-28') + + vi.useRealTimers() + }) + + it('handles UTC year boundaries', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-01-01T00:30:00.000Z')) + + expect(getEffectiveEndDateIso()).toBe('2025-12-31') + + vi.useRealTimers() + }) +}) + +describe('isLastDayOfMonth', () => { + it('returns true for the last day of a 31-day month', () => { + expect(isLastDayOfMonth('2026-01-31')).toBe(true) + }) + + it('returns true for the last day of a 30-day month', () => { + expect(isLastDayOfMonth('2026-04-30')).toBe(true) + }) + + it('returns true for February 29 in a leap year', () => { + expect(isLastDayOfMonth('2024-02-29')).toBe(true) + }) + + it('returns true for February 28 in a non-leap year', () => { + expect(isLastDayOfMonth('2023-02-28')).toBe(true) + }) + + it('returns false when the date is not the last day of the month', () => { + expect(isLastDayOfMonth('2026-05-30')).toBe(false) + }) +}) + +describe('isLastDayOfYear', () => { + it('returns true for December 31', () => { + expect(isLastDayOfYear('2026-12-31')).toBe(true) + }) + + it('returns false for any other day in December', () => { + expect(isLastDayOfYear('2026-12-30')).toBe(false) + }) + + it('returns false for the last day of a month that is not December', () => { + expect(isLastDayOfYear('2026-11-30')).toBe(false) + }) + + it('returns true for leap years on December 31', () => { + expect(isLastDayOfYear('2024-12-31')).toBe(true) + }) +}) diff --git a/test/unit/server/api/embed/downloads.svg/index.get.spec.ts b/test/unit/server/api/embed/downloads.svg/index.get.spec.ts new file mode 100644 index 0000000000..41b5cffbd1 --- /dev/null +++ b/test/unit/server/api/embed/downloads.svg/index.get.spec.ts @@ -0,0 +1,713 @@ +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { createError, type H3Event } from 'h3' + +const fetchDownloadsEvolutionMock = vi.fn() +const buildTrendsChartDataMock = vi.fn() +const buildNormalisedTrendsDatasetMock = vi.fn() +const buildTrendsChartConfigMock = vi.fn() +const resolveEmbedChartColorsMock = vi.fn() +const mergeConfigsMock = vi.fn() +const createStaticVueUiXyMock = vi.fn() +const generateWatermarkLogoMock = vi.fn() +const isLastDayOfMonthMock = vi.fn() +const getEffectiveEndDateIsoMock = vi.fn() + +vi.mock('#server/utils/download-evolution', () => ({ + fetchDownloadsEvolution: fetchDownloadsEvolutionMock, +})) + +vi.mock('#shared/utils/trends-chart', () => ({ + buildTrendsChartData: buildTrendsChartDataMock, + buildNormalisedTrendsDataset: buildNormalisedTrendsDatasetMock, + buildTrendsChartConfig: buildTrendsChartConfigMock, + generateWatermarkLogo: generateWatermarkLogoMock, +})) + +vi.mock('#shared/utils/embed-chart-colors', () => ({ + resolveEmbedChartColors: resolveEmbedChartColorsMock, +})) + +vi.mock('vue-data-ui/utils', () => ({ + mergeConfigs: mergeConfigsMock, +})) + +vi.mock('vue-data-ui/ssr', () => ({ + createStaticVueUiXy: createStaticVueUiXyMock, +})) + +vi.mock('~/utils/date', () => ({ + getEffectiveEndDateIso: getEffectiveEndDateIsoMock, + isLastDayOfMonth: isLastDayOfMonthMock, +})) + +vi.mock('~/utils/colors', () => ({ + OKLCH_NEUTRAL_FALLBACK: 'oklch-neutral-fallback', +})) + +vi.stubGlobal('defineCachedEventHandler', (handler: Function) => handler) +vi.stubGlobal('createError', createError) + +let queryParams: Record = {} +const setHeaderMock = vi.fn() + +vi.stubGlobal('getQuery', () => queryParams) +vi.stubGlobal('setHeader', setHeaderMock) + +const handler = (await import('#server/api/embed/downloads.svg/index.get')).default +const event = {} as H3Event + +function createEvolution(packageName: string) { + return [ + { + period: '2026-05-01', + downloads: packageName.length * 100, + }, + ] +} + +function createDataset(overrides: Record = {}) { + return [ + { + name: 'vue', + series: [10, 20], + dashIndices: undefined, + ...overrides, + }, + ] +} + +beforeEach(() => { + vi.clearAllMocks() + + queryParams = { + package: 'vue', + } + + fetchDownloadsEvolutionMock.mockImplementation(async (packageName: string) => + createEvolution(packageName), + ) + + resolveEmbedChartColorsMock.mockReturnValue({ + fg: '#111111', + bg: '#ffffff', + fgMuted: '#666666', + fgSubtle: '#999999', + }) + + buildTrendsChartDataMock.mockReturnValue({ + dates: ['2026-05-01', '2026-05-02'], + dataset: createDataset(), + }) + + buildNormalisedTrendsDatasetMock.mockReturnValue(createDataset()) + + buildTrendsChartConfigMock.mockReturnValue({ + chart: { + base: true, + }, + }) + + mergeConfigsMock.mockImplementation(({ defaultConfig, userConfig }) => ({ + defaultConfig, + userConfig, + })) + + generateWatermarkLogoMock.mockReturnValue('') + getEffectiveEndDateIsoMock.mockReturnValue('2026-05-31') + isLastDayOfMonthMock.mockReturnValue(true) + + createStaticVueUiXyMock.mockImplementation(async options => { + options.additionalSvgContent({ + drawingArea: { + bottom: 300, + }, + series: [ + { + plots: [ + { + x: 100, + y: 50, + value: 1200, + }, + ], + }, + { + plots: [], + }, + ], + }) + + return '' + }) +}) + +afterAll(() => { + vi.unstubAllGlobals() +}) + +describe('downloads SVG embed API', () => { + it('throws 400 when no valid package name is provided', async () => { + queryParams = {} + + await expect(handler(event)).rejects.toMatchObject({ + statusCode: 400, + statusMessage: 'Missing package name. Use ?package=nuxt or ?packages=vite,rolldown', + }) + }) + + it('throws 501 for likes metric', async () => { + queryParams = { + package: 'vue', + metric: 'likes', + } + + await expect(handler(event)).rejects.toMatchObject({ + statusCode: 501, + }) + }) + + it('throws 501 for contributors metric', async () => { + queryParams = { + package: 'vue', + metric: 'contributors', + } + + await expect(handler(event)).rejects.toMatchObject({ + statusCode: 501, + }) + }) + + it('renders an SVG response for a single package', async () => { + const result = await handler(event) + + expect(result).toBe('') + expect(fetchDownloadsEvolutionMock).toHaveBeenCalledWith('vue', { + granularity: 'week', + weeks: 52, + months: 12, + startDate: undefined, + endDate: undefined, + }) + expect(setHeaderMock).toHaveBeenCalledWith( + event, + 'Content-Type', + 'image/svg+xml; charset=utf-8', + ) + expect(setHeaderMock).toHaveBeenCalledWith( + event, + 'Cache-Control', + 'public, max-age=3600, s-maxage=86400', + ) + }) + + it('supports multiple packages from the packages query', async () => { + queryParams = { + packages: 'Vue, @Nuxt/Kit, invalid package, React', + } + + await handler(event) + + expect(fetchDownloadsEvolutionMock).toHaveBeenCalledTimes(3) + expect(fetchDownloadsEvolutionMock).toHaveBeenNthCalledWith(1, 'vue', expect.any(Object)) + expect(fetchDownloadsEvolutionMock).toHaveBeenNthCalledWith(2, '@nuxt/kit', expect.any(Object)) + expect(fetchDownloadsEvolutionMock).toHaveBeenNthCalledWith(3, 'react', expect.any(Object)) + + expect(buildTrendsChartDataMock).toHaveBeenCalledWith( + expect.objectContaining({ + packageNames: ['vue', '@nuxt/kit', 'react'], + isMultiPackageMode: true, + }), + ) + }) + + it('limits package names to 8 entries', async () => { + queryParams = { + packages: 'a,b,c,d,e,f,g,h,i,j', + } + + await handler(event) + + expect(fetchDownloadsEvolutionMock).toHaveBeenCalledTimes(8) + }) + + it.each([ + ['daily', 'day', 'daily'], + ['day', 'day', 'daily'], + ['weekly', 'week', 'weekly'], + ['week', 'week', 'weekly'], + ['monthly', 'month', 'monthly'], + ['month', 'month', 'monthly'], + ['yearly', 'year', 'yearly'], + ['year', 'year', 'yearly'], + ])('parses granularity %s', async (queryGranularity, fetchGranularity, chartGranularity) => { + queryParams = { + package: 'vue', + granularity: queryGranularity, + } + + await handler(event) + + expect(fetchDownloadsEvolutionMock).toHaveBeenCalledWith( + 'vue', + expect.objectContaining({ + granularity: fetchGranularity, + }), + ) + expect(buildTrendsChartDataMock).toHaveBeenCalledWith( + expect.objectContaining({ + selectedGranularity: chartGranularity, + displayedGranularity: chartGranularity, + }), + ) + }) + + it('clamps width, height, weeks, and months', async () => { + queryParams = { + package: 'vue', + width: 99999, + height: 1, + weeks: 99999, + months: 0, + } + + await handler(event) + + expect(fetchDownloadsEvolutionMock).toHaveBeenCalledWith( + 'vue', + expect.objectContaining({ + weeks: 260, + months: 1, + }), + ) + + expect(mergeConfigsMock).toHaveBeenCalledWith( + expect.objectContaining({ + userConfig: expect.objectContaining({ + chart: expect.objectContaining({ + width: 1600, + height: 240, + }), + }), + }), + ) + }) + + it('uses fallback dimensions and periods for invalid numeric query values', async () => { + queryParams = { + package: 'vue', + width: 'nope', + height: 'nope', + weeks: 'nope', + months: 'nope', + } + + await handler(event) + + expect(fetchDownloadsEvolutionMock).toHaveBeenCalledWith( + 'vue', + expect.objectContaining({ + weeks: 52, + months: 12, + }), + ) + + expect(mergeConfigsMock).toHaveBeenCalledWith( + expect.objectContaining({ + userConfig: expect.objectContaining({ + chart: expect.objectContaining({ + width: 900, + height: 420, + }), + }), + }), + ) + }) + + it('parses valid dates and ignores invalid dates', async () => { + queryParams = { + package: 'vue', + start: 'invalid', + endDate: '2026-05-31', + } + + await handler(event) + + expect(fetchDownloadsEvolutionMock).toHaveBeenCalledWith( + 'vue', + expect.objectContaining({ + startDate: undefined, + endDate: '2026-05-31', + }), + ) + }) + + it('uses startDate and end aliases', async () => { + queryParams = { + package: 'vue', + startDate: '2026-01-01', + end: '2026-05-31', + } + + await handler(event) + + expect(fetchDownloadsEvolutionMock).toHaveBeenCalledWith( + 'vue', + expect.objectContaining({ + startDate: '2026-01-01', + endDate: '2026-05-31', + }), + ) + }) + + it('uses dark colors when mode is dark', async () => { + queryParams = { + package: 'vue', + mode: 'dark', + } + + await handler(event) + + expect(resolveEmbedChartColorsMock).toHaveBeenCalledWith('dark') + }) + + it('uses light colors by default', async () => { + await handler(event) + + expect(resolveEmbedChartColorsMock).toHaveBeenCalledWith('light') + }) + + it('uses a valid locale', async () => { + queryParams = { + package: 'vue', + locale: 'fr-FR', + } + + await handler(event) + + const chartDataOptions = buildTrendsChartDataMock.mock.calls[0]![0] + expect(chartDataOptions.compactNumberFormatter.resolvedOptions().locale).toBe('fr-FR') + }) + + it('falls back to en for invalid locale', async () => { + queryParams = { + package: 'vue', + locale: 'not a locale', + } + + await handler(event) + + const chartDataOptions = buildTrendsChartDataMock.mock.calls[0]![0] + expect(chartDataOptions.compactNumberFormatter.resolvedOptions().locale).toBe('en') + }) + + it('sanitizes yLabel', async () => { + queryParams = { + package: 'vue', + yLabel: '&"`\u0000', + } + + await handler(event) + + const userConfig = mergeConfigsMock.mock.calls[0]![0].userConfig + expect(userConfig.chart.grid.labels.axis.yLabel).toBe('Downloads') + }) + + it('uses fallback yLabel for non-string values', async () => { + queryParams = { + package: 'vue', + yLabel: 123, + } + + await handler(event) + + const userConfig = mergeConfigsMock.mock.calls[0]![0].userConfig + expect(userConfig.chart.grid.labels.axis.yLabel).toBe('') + }) + + it('accepts hex accent colors', async () => { + queryParams = { + package: 'vue', + accent: '#abc', + } + + await handler(event) + + expect(buildTrendsChartDataMock).toHaveBeenCalledWith( + expect.objectContaining({ + accent: '#abc', + }), + ) + }) + + it('accepts oklch accent colors', async () => { + queryParams = { + package: 'vue', + accent: 'oklch(0.787 0.128 230.318)', + } + + await handler(event) + + expect(buildTrendsChartDataMock).toHaveBeenCalledWith( + expect.objectContaining({ + accent: 'oklch(0.787 0.128 230.318)', + }), + ) + }) + + it('falls back for invalid accent colors', async () => { + queryParams = { + package: 'vue', + accent: 'red', + } + + await handler(event) + + expect(buildTrendsChartDataMock).toHaveBeenCalledWith( + expect.objectContaining({ + accent: 'oklch-neutral-fallback', + }), + ) + }) + + it('falls back for non-string accent colors', async () => { + queryParams = { + package: 'vue', + accent: 42, + } + + await handler(event) + + expect(buildTrendsChartDataMock).toHaveBeenCalledWith( + expect.objectContaining({ + accent: 'oklch-neutral-fallback', + }), + ) + }) + + it('throws 404 when chart dataset is empty', async () => { + buildTrendsChartDataMock.mockReturnValue({ + dates: [], + dataset: [], + }) + + await expect(handler(event)).rejects.toMatchObject({ + statusCode: 404, + statusMessage: 'No chart dataset generated', + }) + }) + + it('throws 404 when normalized dataset is empty', async () => { + buildNormalisedTrendsDatasetMock.mockReturnValue([]) + + await expect(handler(event)).rejects.toMatchObject({ + statusCode: 404, + statusMessage: 'No normalized dataset generated', + }) + }) + + it('adds a dash index to the last monthly point when the effective end date is not the last day of month', async () => { + queryParams = { + package: 'vue', + granularity: 'month', + endDate: '2026-05-12', + } + + isLastDayOfMonthMock.mockReturnValue(false) + getEffectiveEndDateIsoMock.mockReturnValue('2026-05-12') + buildNormalisedTrendsDatasetMock.mockReturnValue([ + { + name: 'vue', + series: [10, 20, 30], + dashIndices: [0, 2], + }, + ]) + + await handler(event) + + expect(createStaticVueUiXyMock).toHaveBeenCalledWith( + expect.objectContaining({ + dataset: [ + expect.objectContaining({ + dashIndices: [0, 2], + }), + ], + }), + ) + }) + + it('filters negative dash index for empty monthly series', async () => { + queryParams = { + package: 'vue', + granularity: 'month', + } + + isLastDayOfMonthMock.mockReturnValue(false) + buildNormalisedTrendsDatasetMock.mockReturnValue([ + { + name: 'vue', + series: [], + }, + ]) + + await handler(event) + + expect(createStaticVueUiXyMock).toHaveBeenCalledWith( + expect.objectContaining({ + dataset: [ + expect.objectContaining({ + dashIndices: [], + }), + ], + }), + ) + }) + + it('keeps dash indices unchanged outside incomplete monthly data', async () => { + buildNormalisedTrendsDatasetMock.mockReturnValue([ + { + name: 'vue', + series: [10, 20], + dashIndices: [1], + }, + ]) + + await handler(event) + + expect(createStaticVueUiXyMock).toHaveBeenCalledWith( + expect.objectContaining({ + dataset: [ + expect.objectContaining({ + dashIndices: [1], + }), + ], + }), + ) + }) + + it('generates extra SVG labels and watermark content', async () => { + await handler(event) + + const options = createStaticVueUiXyMock.mock.calls[0]![0] + const content = options.additionalSvgContent({ + drawingArea: { + bottom: 300, + }, + series: [ + { + plots: [ + { + x: 100, + y: 50, + value: 1200, + }, + ], + }, + { + plots: [], + }, + ], + }) + + expect(content).toContain('') + expect(generateWatermarkLogoMock).toHaveBeenCalledWith({ + x: 12, + y: 360, + width: 80, + height: 30, + fill: '#999999', + }) + }) + + it('falls back to an empty singleEvolution when the first package has no evolution', async () => { + fetchDownloadsEvolutionMock.mockImplementation(async (packageName: string) => { + if (packageName === 'vue') { + return undefined + } + + return createEvolution(packageName) + }) + + await handler(event) + + expect(buildTrendsChartDataMock).toHaveBeenCalledWith( + expect.objectContaining({ + singleEvolution: [], + }), + ) + + expect(buildTrendsChartConfigMock).toHaveBeenCalledWith( + expect.objectContaining({ + singleEvolution: [], + }), + ) + }) + + it('formats the last plot value in additionalSvgContent', async () => { + await handler(event) + + const options = createStaticVueUiXyMock.mock.calls[0]![0] + + const content = options.additionalSvgContent({ + drawingArea: { + bottom: 300, + }, + series: [ + { + plots: [ + { + x: 10, + y: 20, + value: 1234, + }, + ], + }, + ], + }) + + expect(content).toContain('1.2K') + }) + + it('falls back to 0 when the last plot value is missing', async () => { + await handler(event) + + const options = createStaticVueUiXyMock.mock.calls[0]![0] + + const content = options.additionalSvgContent({ + drawingArea: { + bottom: 300, + }, + series: [ + { + plots: [ + { + x: 10, + y: 20, + value: undefined, + }, + ], + }, + ], + }) + + expect(content).toContain('0') + }) + + it('falls back to en when canonical locales returns an empty array', async () => { + const spy = vi.spyOn(Intl, 'getCanonicalLocales').mockReturnValue([]) + + queryParams = { + package: 'vue', + locale: 'fr', + } + + await handler(event) + + const chartDataOptions = buildTrendsChartDataMock.mock.calls[0]![0] + + expect(chartDataOptions.compactNumberFormatter.resolvedOptions().locale).toBe('en') + + spy.mockRestore() + }) +}) diff --git a/test/unit/server/utils/download-evolution.spec.ts b/test/unit/server/utils/download-evolution.spec.ts new file mode 100644 index 0000000000..ac72236605 --- /dev/null +++ b/test/unit/server/utils/download-evolution.spec.ts @@ -0,0 +1,259 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { EvolutionOptions } from '~/types/chart' +import { + buildDailyEvolution, + buildMonthlyEvolution, + buildWeeklyEvolution, + buildYearlyEvolution, +} from '~/utils/chart-data-buckets' +import { fetchDownloadsEvolution } from '#server/utils/download-evolution' + +vi.mock('~/utils/chart-data-buckets', () => ({ + buildDailyEvolution: vi.fn(), + buildMonthlyEvolution: vi.fn(), + buildWeeklyEvolution: vi.fn(), + buildYearlyEvolution: vi.fn(), +})) + +const buildDailyEvolutionMock = vi.mocked(buildDailyEvolution) +const buildMonthlyEvolutionMock = vi.mocked(buildMonthlyEvolution) +const buildWeeklyEvolutionMock = vi.mocked(buildWeeklyEvolution) +const buildYearlyEvolutionMock = vi.mocked(buildYearlyEvolution) + +beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-06-01T12:00:00.000Z')) + + vi.stubGlobal( + '$fetch', + vi.fn(async () => ({ + downloads: [ + { day: '2026-05-30', downloads: 30 }, + { day: '2026-05-29', downloads: 20 }, + { day: '2026-05-31', downloads: 40 }, + ], + })), + ) + + buildDailyEvolutionMock.mockReturnValue('daily-result' as never) + buildWeeklyEvolutionMock.mockReturnValue('weekly-result' as never) + buildMonthlyEvolutionMock.mockReturnValue('monthly-result' as never) + buildYearlyEvolutionMock.mockReturnValue('yearly-result' as never) +}) + +describe('fetchDownloadsEvolution', () => { + it('fetches the last 30 days by default and builds daily evolution', async () => { + const result = await fetchDownloadsEvolution('vue', { + granularity: 'day', + } as EvolutionOptions) + + expect(result).toBe('daily-result') + expect($fetch).toHaveBeenCalledWith( + 'https://api.npmjs.org/downloads/range/2026-05-02:2026-05-31/vue', + ) + expect(buildDailyEvolutionMock).toHaveBeenCalledWith([ + { day: '2026-05-29', value: 20 }, + { day: '2026-05-30', value: 30 }, + { day: '2026-05-31', value: 40 }, + ]) + }) + + it('uses explicit startDate and endDate when provided', async () => { + const result = await fetchDownloadsEvolution('@scope/pkg', { + granularity: 'week', + startDate: '2026-01-01T10:30:00.000Z', + endDate: '2026-01-31T18:45:00.000Z', + } as EvolutionOptions) + + expect(result).toBe('weekly-result') + expect($fetch).toHaveBeenCalledWith( + 'https://api.npmjs.org/downloads/range/2026-01-01:2026-01-31/%40scope%2Fpkg', + ) + expect(buildWeeklyEvolutionMock).toHaveBeenCalledWith( + [ + { day: '2026-05-29', value: 20 }, + { day: '2026-05-30', value: 30 }, + { day: '2026-05-31', value: 40 }, + ], + '2026-01-01', + '2026-01-31', + ) + }) + + it('builds monthly evolution from the configured month window', async () => { + const result = await fetchDownloadsEvolution('nuxt', { + granularity: 'month', + months: 3, + } as EvolutionOptions) + + expect(result).toBe('monthly-result') + expect($fetch).toHaveBeenCalledWith( + 'https://api.npmjs.org/downloads/range/2026-03-01:2026-05-31/nuxt', + ) + expect(buildMonthlyEvolutionMock).toHaveBeenCalledWith( + expect.any(Array), + '2026-03-01', + '2026-05-31', + ) + }) + + it('builds yearly evolution from the package creation year', async () => { + const result = await fetchDownloadsEvolution( + 'react', + { + granularity: 'year', + } as EvolutionOptions, + '2013-05-29T00:00:00.000Z', + ) + + expect(result).toBe('yearly-result') + expect($fetch).toHaveBeenCalledTimes(10) + expect($fetch).toHaveBeenNthCalledWith( + 1, + 'https://api.npmjs.org/downloads/range/2013-01-01:2014-06-24/react', + ) + expect($fetch).toHaveBeenNthCalledWith( + 10, + 'https://api.npmjs.org/downloads/range/2026-04-23:2026-05-31/react', + ) + expect(buildYearlyEvolutionMock).toHaveBeenCalledWith( + expect.any(Array), + '2013-01-01', + '2026-05-31', + ) + }) + + it('splits large ranges into npm-compatible chunks and merges the results', async () => { + await fetchDownloadsEvolution('vue', { + granularity: 'year', + startDate: '2024-01-01', + endDate: '2026-05-31', + } as EvolutionOptions) + + expect($fetch).toHaveBeenCalledTimes(2) + expect($fetch).toHaveBeenNthCalledWith( + 1, + 'https://api.npmjs.org/downloads/range/2024-01-01:2025-06-23/vue', + ) + expect($fetch).toHaveBeenNthCalledWith( + 2, + 'https://api.npmjs.org/downloads/range/2025-06-24:2026-05-31/vue', + ) + }) + + it('accepts ISO datetime strings for startDate and endDate', async () => { + await fetchDownloadsEvolution('vue', { + granularity: 'day', + startDate: '2026-05-01T12:34:56.000Z', + endDate: '2026-05-31T23:59:59.999Z', + } as EvolutionOptions) + + expect($fetch).toHaveBeenCalledWith( + 'https://api.npmjs.org/downloads/range/2026-05-01:2026-05-31/vue', + ) + }) + + it('falls back to the default 5-year window for yearly ranges without packageCreatedIso', async () => { + await fetchDownloadsEvolution('react', { + granularity: 'year', + } as EvolutionOptions) + + expect($fetch).toHaveBeenCalledTimes(4) + + expect($fetch).toHaveBeenNthCalledWith( + 1, + 'https://api.npmjs.org/downloads/range/2021-06-02:2022-11-23/react', + ) + + expect($fetch).toHaveBeenNthCalledWith( + 4, + 'https://api.npmjs.org/downloads/range/2025-11-08:2026-05-31/react', + ) + + expect(buildYearlyEvolutionMock).toHaveBeenCalledWith( + expect.any(Array), + '2021-06-02', + '2026-05-31', + ) + }) + + it('uses a 12 month window by default for monthly evolution', async () => { + await fetchDownloadsEvolution('nuxt', { + granularity: 'month', + } as EvolutionOptions) + + expect($fetch).toHaveBeenCalledWith( + 'https://api.npmjs.org/downloads/range/2025-06-01:2026-05-31/nuxt', + ) + + expect(buildMonthlyEvolutionMock).toHaveBeenCalledWith( + expect.any(Array), + '2025-06-01', + '2026-05-31', + ) + }) + + it('uses a 52 week window by default for weekly evolution', async () => { + await fetchDownloadsEvolution('vue', { + granularity: 'week', + } as EvolutionOptions) + + expect(buildWeeklyEvolutionMock).toHaveBeenCalledWith( + expect.any(Array), + '2025-06-02', + '2026-05-31', + ) + }) + + it('uses packageCreatedIso when resolving a yearly range without startDate', async () => { + await fetchDownloadsEvolution( + 'react', + { + granularity: 'year', + endDate: '2013-12-31', + } as EvolutionOptions, + '2013-05-29T00:00:00.000Z', + ) + + expect($fetch).toHaveBeenCalledWith( + 'https://api.npmjs.org/downloads/range/2013-01-01:2013-12-31/react', + ) + + expect(buildYearlyEvolutionMock).toHaveBeenCalledWith( + expect.any(Array), + '2013-01-01', + '2013-12-31', + ) + }) + + it('ignores invalid date formats', async () => { + await fetchDownloadsEvolution('vue', { + granularity: 'day', + startDate: 'not-a-date', + } as EvolutionOptions) + + expect($fetch).toHaveBeenCalledWith( + 'https://api.npmjs.org/downloads/range/2026-05-02:2026-05-31/vue', + ) + }) + + it('uses the configured week window when granularity is week', async () => { + const result = await fetchDownloadsEvolution('vue', { + granularity: 'week', + weeks: 4, + } as EvolutionOptions) + + expect(result).toBe('weekly-result') + + expect($fetch).toHaveBeenCalledWith( + 'https://api.npmjs.org/downloads/range/2026-05-04:2026-05-31/vue', + ) + + expect(buildWeeklyEvolutionMock).toHaveBeenCalledWith( + expect.any(Array), + '2026-05-04', + '2026-05-31', + ) + }) +}) diff --git a/test/unit/shared/utils/download-ranges.spec.ts b/test/unit/shared/utils/download-ranges.spec.ts new file mode 100644 index 0000000000..dbcb330f56 --- /dev/null +++ b/test/unit/shared/utils/download-ranges.spec.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from 'vitest' +import { + differenceInUtcDaysInclusive, + mergeDailyPoints, + splitIsoRangeIntoChunksInclusive, +} from '#shared/utils/download-ranges' + +describe('differenceInUtcDaysInclusive', () => { + it('returns 1 for the same start and end date', () => { + expect(differenceInUtcDaysInclusive('2026-05-31', '2026-05-31')).toBe(1) + }) + + it('counts both start and end dates inclusively', () => { + expect(differenceInUtcDaysInclusive('2026-05-01', '2026-05-31')).toBe(31) + }) + + it('handles ranges across month and year boundaries', () => { + expect(differenceInUtcDaysInclusive('2025-12-31', '2026-01-02')).toBe(3) + }) +}) + +describe('splitIsoRangeIntoChunksInclusive', () => { + it('returns a single chunk when total days is below the maximum', () => { + expect(splitIsoRangeIntoChunksInclusive('2026-05-01', '2026-05-10', 20)).toEqual([ + { + startIso: '2026-05-01', + endIso: '2026-05-10', + }, + ]) + }) + + it('returns a single chunk when total days equals the maximum', () => { + expect(splitIsoRangeIntoChunksInclusive('2026-05-01', '2026-05-10', 10)).toEqual([ + { + startIso: '2026-05-01', + endIso: '2026-05-10', + }, + ]) + }) + + it('splits a range into inclusive chunks', () => { + expect(splitIsoRangeIntoChunksInclusive('2026-05-01', '2026-05-10', 4)).toEqual([ + { + startIso: '2026-05-01', + endIso: '2026-05-04', + }, + { + startIso: '2026-05-05', + endIso: '2026-05-08', + }, + { + startIso: '2026-05-09', + endIso: '2026-05-10', + }, + ]) + }) + + it('splits ranges across month boundaries', () => { + expect(splitIsoRangeIntoChunksInclusive('2026-01-30', '2026-02-03', 2)).toEqual([ + { + startIso: '2026-01-30', + endIso: '2026-01-31', + }, + { + startIso: '2026-02-01', + endIso: '2026-02-02', + }, + { + startIso: '2026-02-03', + endIso: '2026-02-03', + }, + ]) + }) + + it('splits ranges across year boundaries', () => { + expect(splitIsoRangeIntoChunksInclusive('2025-12-30', '2026-01-02', 2)).toEqual([ + { + startIso: '2025-12-30', + endIso: '2025-12-31', + }, + { + startIso: '2026-01-01', + endIso: '2026-01-02', + }, + ]) + }) +}) + +describe('mergeDailyPoints', () => { + it('returns an empty array when there are no points', () => { + expect(mergeDailyPoints([])).toEqual([]) + }) + + it('sorts points by day', () => { + expect( + mergeDailyPoints([ + { day: '2026-05-03', value: 30 }, + { day: '2026-05-01', value: 10 }, + { day: '2026-05-02', value: 20 }, + ]), + ).toEqual([ + { day: '2026-05-01', value: 10 }, + { day: '2026-05-02', value: 20 }, + { day: '2026-05-03', value: 30 }, + ]) + }) + + it('merges duplicate days by summing their values', () => { + expect( + mergeDailyPoints([ + { day: '2026-05-02', value: 20 }, + { day: '2026-05-01', value: 10 }, + { day: '2026-05-02', value: 5 }, + { day: '2026-05-01', value: 15 }, + ]), + ).toEqual([ + { day: '2026-05-01', value: 25 }, + { day: '2026-05-02', value: 25 }, + ]) + }) + + it('preserves negative and zero values when merging', () => { + expect( + mergeDailyPoints([ + { day: '2026-05-01', value: 10 }, + { day: '2026-05-01', value: -5 }, + { day: '2026-05-02', value: 0 }, + ]), + ).toEqual([ + { day: '2026-05-01', value: 5 }, + { day: '2026-05-02', value: 0 }, + ]) + }) +}) diff --git a/test/unit/shared/utils/trends-chart.spec.ts b/test/unit/shared/utils/trends-chart.spec.ts new file mode 100644 index 0000000000..72d4e69394 --- /dev/null +++ b/test/unit/shared/utils/trends-chart.spec.ts @@ -0,0 +1,973 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + buildNormalisedTrendsDataset, + buildTrendsChartConfig, + buildTrendsChartData, + drawTrendsEstimationLine, + drawTrendsLastDatapointLabel, + drawTrendsSvgPrintLegend, + generateWatermarkLogo, + getTrendsDatetimeFormatterOptions, + isDailyDataset, + isMonthlyDataset, + isWeeklyDataset, + isYearlyDataset, +} from '#shared/utils/trends-chart' + +const { + lightenOklchMock, + getFrameworkColorMock, + isListedFrameworkMock, + applyEllipsisMock, + applyDataPipelineMock, +} = vi.hoisted(() => ({ + lightenOklchMock: vi.fn(), + getFrameworkColorMock: vi.fn(), + isListedFrameworkMock: vi.fn(), + applyEllipsisMock: vi.fn(), + applyDataPipelineMock: vi.fn(), +})) + +vi.mock('~/utils/colors', () => ({ + OKLCH_NEUTRAL_FALLBACK: 'oklch-fallback', + lightenOklch: lightenOklchMock, +})) + +vi.mock('~/utils/frameworks', () => ({ + getFrameworkColor: getFrameworkColorMock, + isListedFramework: isListedFrameworkMock, +})) + +vi.mock('~/utils/charts', () => ({ + applyEllipsis: applyEllipsisMock, +})) + +vi.mock('~/utils/chart-data-prediction', () => ({ + DEFAULT_PREDICTION_POINTS: 12, + applyDataPipeline: applyDataPipelineMock, +})) + +const colors = { + bg: '#ffffff', + bgElevated: '#f8f8f8', + fg: '#111111', + fgMuted: '#666666', + fgSubtle: '#999999', + border: '#dddddd', +} + +const translate = (key: string, params?: Record) => { + if (!params) { + return key + } + + return `${key}:${JSON.stringify(params)}` +} + +const compactNumberFormatter = new Intl.NumberFormat('en', { + notation: 'compact', + maximumFractionDigits: 1, +}) + +function baseChartDataOptions() { + return { + packageNames: ['vue'], + isMultiPackageMode: false, + selectedMetric: 'downloads' as const, + selectedMetricLabel: 'Downloads', + selectedGranularity: 'daily' as const, + displayedGranularity: 'daily' as const, + singleEvolution: [], + colors, + isDarkMode: false, + chartFilter: { + averageWindow: 1, + smoothingTau: 0, + }, + t: translate, + compactNumberFormatter, + } +} + +function baseConfigOptions() { + return { + packageNames: ['vue'], + isMultiPackageMode: false, + selectedMetric: 'downloads' as const, + selectedMetricLabel: 'Downloads', + selectedGranularity: 'daily' as const, + displayedGranularity: 'daily' as const, + singleEvolution: [], + colors, + isDarkMode: false, + chartFilter: { + averageWindow: 1, + smoothingTau: 0, + }, + t: translate, + compactNumberFormatter, + dates: [1, 2], + isMobile: false, + pending: false, + locale: 'en', + chartHeight: 400, + } +} + +beforeEach(() => { + vi.clearAllMocks() + + lightenOklchMock.mockReturnValue('lightened-accent') + getFrameworkColorMock.mockReturnValue('#42b883') + isListedFrameworkMock.mockImplementation((packageName: string) => packageName === 'vue') + applyEllipsisMock.mockImplementation((value: string) => value) + applyDataPipelineMock.mockImplementation((series: number[]) => series) +}) + +describe('dataset guards', () => { + it('detects weekly datasets', () => { + expect( + isWeeklyDataset([ + { + weekKey: '2026-W01', + weekStart: '2026-01-01', + weekEnd: '2026-01-07', + timestampStart: 1, + timestampEnd: 2, + value: 10, + }, + ]), + ).toBe(true) + + expect(isWeeklyDataset([])).toBe(false) + expect(isWeeklyDataset(null)).toBe(false) + expect(isWeeklyDataset([{ value: 10 }])).toBe(false) + }) + + it('detects daily datasets', () => { + expect(isDailyDataset([{ day: '2026-01-01', timestamp: 1, value: 10 }])).toBe(true) + expect(isDailyDataset([])).toBe(false) + expect(isDailyDataset(null)).toBe(false) + expect(isDailyDataset([{ value: 10 }])).toBe(false) + }) + + it('detects monthly datasets', () => { + expect(isMonthlyDataset([{ month: '2026-01', timestamp: 1, value: 10 }])).toBe(true) + expect(isMonthlyDataset([])).toBe(false) + expect(isMonthlyDataset(null)).toBe(false) + expect(isMonthlyDataset([{ value: 10 }])).toBe(false) + }) + + it('detects yearly datasets', () => { + expect(isYearlyDataset([{ year: '2026', timestamp: 1, value: 10 }])).toBe(true) + expect(isYearlyDataset([])).toBe(false) + expect(isYearlyDataset(null)).toBe(false) + expect(isYearlyDataset([{ value: 10 }])).toBe(false) + }) +}) + +describe('buildTrendsChartData', () => { + it('formats a single daily dataset', () => { + const result = buildTrendsChartData({ + ...baseChartDataOptions(), + singleEvolution: [ + { day: '2026-01-01', timestamp: 1, value: 10, hasAnomaly: true }, + { day: '2026-01-02', timestamp: 2, value: 20 }, + ], + accent: '#abcdef', + }) + + expect(result).toEqual({ + dataset: [ + expect.objectContaining({ + name: 'vue', + type: 'line', + series: [10, 20], + color: '#abcdef', + temperatureColors: undefined, + useArea: true, + dashIndices: [0], + }), + ], + dates: [1, 2], + }) + }) + + it('uses an empty series name when packageNames is empty in single-package mode', () => { + const result = buildTrendsChartData({ + ...baseChartDataOptions(), + packageNames: [], + singleEvolution: [{ day: '2026-01-01', timestamp: 1, value: 10 }], + }) + + expect(result.dataset?.[0]?.name).toBe('') + expect(applyEllipsisMock).toHaveBeenCalledWith('', 32) + }) + + it('formats weekly, monthly, and yearly single datasets', () => { + expect( + buildTrendsChartData({ + ...baseChartDataOptions(), + selectedGranularity: 'weekly', + displayedGranularity: 'weekly', + singleEvolution: [ + { + weekKey: '2026-W01', + weekStart: '2026-01-01', + weekEnd: '2026-01-07', + timestampStart: 1, + timestampEnd: 2, + value: 10, + }, + ], + }).dates, + ).toEqual([2]) + + expect( + buildTrendsChartData({ + ...baseChartDataOptions(), + selectedGranularity: 'monthly', + displayedGranularity: 'monthly', + singleEvolution: [{ month: '2026-01', timestamp: 3, value: 10 }], + }).dates, + ).toEqual([3]) + + expect( + buildTrendsChartData({ + ...baseChartDataOptions(), + selectedGranularity: 'yearly', + displayedGranularity: 'yearly', + singleEvolution: [{ year: '2026', timestamp: 4, value: 10 }], + }).dates, + ).toEqual([4]) + }) + + it('returns null for an invalid single dataset', () => { + const result = buildTrendsChartData({ + ...baseChartDataOptions(), + singleEvolution: [], + }) + + expect(result).toEqual({ + dataset: null, + dates: [], + }) + }) + + it('uses fallback accent and dark mode temperature colors', () => { + const result = buildTrendsChartData({ + ...baseChartDataOptions(), + singleEvolution: [{ day: '2026-01-01', timestamp: 1, value: 10 }], + colors: { ...colors, fgSubtle: undefined as never }, + isDarkMode: true, + }) + + expect(lightenOklchMock).toHaveBeenCalledWith('oklch-fallback', 0.618) + expect(result.dataset?.[0]).toMatchObject({ + color: 'oklch-fallback', + temperatureColors: ['lightened-accent', 'oklch-fallback'], + }) + }) + + it('extracts daily points in multi-package mode', () => { + const result = buildTrendsChartData({ + ...baseChartDataOptions(), + packageNames: ['vue', 'react'], + effectivePackageNamesForMetric: ['vue', 'react'], + isMultiPackageMode: true, + evolutionsByPackage: { + vue: [{ day: '2026-01-01', timestamp: 100, value: 10, hasAnomaly: true }], + react: [{ day: '2026-01-02', timestamp: 200, value: 20 }], + }, + }) + + expect(result.dates).toEqual([100, 200]) + expect(result.dataset).toEqual([ + expect.objectContaining({ + name: 'vue', + series: [10, 0], + dashIndices: [0], + color: '#42b883', + }), + expect.objectContaining({ + name: 'react', + series: [0, 20], + dashIndices: [], + }), + ]) + }) + + it('extracts monthly points in multi-package mode', () => { + const result = buildTrendsChartData({ + ...baseChartDataOptions(), + packageNames: ['vue', 'react'], + effectivePackageNamesForMetric: ['vue', 'react'], + isMultiPackageMode: true, + selectedGranularity: 'monthly', + displayedGranularity: 'monthly', + evolutionsByPackage: { + vue: [{ month: '2026-01', timestamp: 100, value: 10, hasAnomaly: true }], + react: [{ month: '2026-02', timestamp: 200, value: 20 }], + }, + }) + + expect(result.dates).toEqual([100, 200]) + expect(result.dataset).toEqual([ + expect.objectContaining({ + name: 'vue', + series: [10, 0], + dashIndices: [0], + }), + expect.objectContaining({ + name: 'react', + series: [0, 20], + dashIndices: [], + }), + ]) + }) + + it('extracts yearly points in multi-package mode', () => { + const result = buildTrendsChartData({ + ...baseChartDataOptions(), + packageNames: ['vue', 'react'], + effectivePackageNamesForMetric: ['vue', 'react'], + isMultiPackageMode: true, + selectedGranularity: 'yearly', + displayedGranularity: 'yearly', + evolutionsByPackage: { + vue: [{ year: '2025', timestamp: 100, value: 10, hasAnomaly: true }], + react: [{ year: '2026', timestamp: 200, value: 20 }], + }, + }) + + expect(result.dates).toEqual([100, 200]) + expect(result.dataset).toEqual([ + expect.objectContaining({ + name: 'vue', + series: [10, 0], + dashIndices: [0], + }), + expect.objectContaining({ + name: 'react', + series: [0, 20], + dashIndices: [], + }), + ]) + }) + + it('extracts weekly points in multi-package mode', () => { + const result = buildTrendsChartData({ + ...baseChartDataOptions(), + packageNames: ['vue', 'react'], + effectivePackageNamesForMetric: ['vue', 'react'], + isMultiPackageMode: true, + selectedGranularity: 'weekly', + displayedGranularity: 'weekly', + evolutionsByPackage: { + vue: [ + { + weekKey: '2026-W01', + weekStart: '2026-01-01', + weekEnd: '2026-01-07', + timestampStart: 100, + timestampEnd: 200, + value: 10, + hasAnomaly: true, + }, + ], + react: [ + { + weekKey: '2026-W02', + weekStart: '2026-01-08', + weekEnd: '2026-01-14', + timestampStart: 201, + timestampEnd: 300, + value: 20, + }, + ], + }, + }) + + expect(result.dates).toEqual([200, 300]) + expect(result.dataset).toEqual([ + expect.objectContaining({ + name: 'vue', + series: [10, 0], + dashIndices: [0], + }), + expect.objectContaining({ + name: 'react', + series: [0, 20], + dashIndices: [], + }), + ]) + }) + + it('falls back to packageNames when effectivePackageNamesForMetric is not provided', () => { + const result = buildTrendsChartData({ + ...baseChartDataOptions(), + packageNames: ['vue'], + isMultiPackageMode: true, + evolutionsByPackage: { + vue: [{ day: '2026-01-01', timestamp: 1, value: 10 }], + }, + }) + + expect(result.dataset?.[0]?.name).toBe('vue') + }) + + it('falls back to an empty point list when a mapped package was not collected', () => { + const effectivePackageNamesForMetric = ['vue'] as unknown as string[] + + effectivePackageNamesForMetric.map = ((callback: (packageName: string) => unknown) => + ['vue', 'react'].map(callback)) as typeof effectivePackageNamesForMetric.map + + const result = buildTrendsChartData({ + ...baseChartDataOptions(), + packageNames: ['vue', 'react'], + effectivePackageNamesForMetric, + isMultiPackageMode: true, + evolutionsByPackage: { + vue: [{ day: '2026-01-01', timestamp: 1, value: 10 }], + }, + }) + + expect(result.dataset).toEqual([ + expect.objectContaining({ + name: 'vue', + series: [10], + }), + expect.objectContaining({ + name: 'react', + series: [0], + }), + ]) + }) + + it('returns null for multi-package mode when no dates are available', () => { + const result = buildTrendsChartData({ + ...baseChartDataOptions(), + isMultiPackageMode: true, + evolutionsByPackage: {}, + }) + + expect(result).toEqual({ + dataset: null, + dates: [], + }) + }) + + it('returns null for multi-package mode when extracted points are invalid', () => { + const result = buildTrendsChartData({ + ...baseChartDataOptions(), + packageNames: ['vue'], + isMultiPackageMode: true, + selectedGranularity: 'daily', + displayedGranularity: 'daily', + evolutionsByPackage: { + vue: [{ month: '2026-01', timestamp: 1, value: 10 }] as never, + }, + }) + + expect(result).toEqual({ + dataset: null, + dates: [], + }) + }) + + it('applies anomaly correction for downloads when enabled', () => { + const applyAnomalyCorrection = vi.fn(() => [{ day: '2026-01-01', timestamp: 1, value: 99 }]) + + const result = buildTrendsChartData({ + ...baseChartDataOptions(), + packageNames: ['vue'], + isMultiPackageMode: true, + evolutionsByPackage: { + vue: [{ day: '2026-01-01', timestamp: 1, value: 10 }], + }, + useAnomalyCorrection: true, + applyAnomalyCorrection, + }) + + expect(applyAnomalyCorrection).toHaveBeenCalledWith({ + data: [{ day: '2026-01-01', timestamp: 1, value: 10 }], + packageName: 'vue', + granularity: 'daily', + }) + expect(result.dataset?.[0]?.series).toEqual([99]) + }) + + it('does not apply anomaly correction for non-download metrics', () => { + const applyAnomalyCorrection = vi.fn() + + buildTrendsChartData({ + ...baseChartDataOptions(), + packageNames: ['vue'], + isMultiPackageMode: true, + selectedMetric: 'likes', + selectedMetricLabel: 'Likes', + evolutionsByPackage: { + vue: [{ day: '2026-01-01', timestamp: 1, value: 10 }], + }, + useAnomalyCorrection: true, + applyAnomalyCorrection, + }) + + expect(applyAnomalyCorrection).not.toHaveBeenCalled() + }) +}) + +describe('buildNormalisedTrendsDataset', () => { + it('returns an empty array for null dataset', () => { + expect( + buildNormalisedTrendsDataset({ + dataset: null, + dates: [], + granularity: 'daily', + selectedMetric: 'downloads', + chartFilter: { averageWindow: 1, smoothingTau: 0 }, + nowMs: 100, + }), + ).toEqual([]) + }) + + it('normalizes number, object, and invalid values before applying the pipeline', () => { + applyDataPipelineMock.mockReturnValue([1, 2, 0]) + + const result = buildNormalisedTrendsDataset({ + dataset: [ + { + name: 'vue', + type: 'line', + series: [1, { x: 1, y: 2 }, 'bad'] as never, + }, + ], + dates: [10], + granularity: 'daily', + selectedMetric: 'downloads', + chartFilter: { averageWindow: 2, smoothingTau: 3 }, + endDateMs: 500, + nowMs: 100, + }) + + expect(applyDataPipelineMock).toHaveBeenCalledWith( + [1, 2, 0], + { + averageWindow: 2, + smoothingTau: 3, + predictionPoints: 12, + }, + { + granularity: 'daily', + lastDateMs: 10, + referenceMs: 500, + isAbsoluteMetric: false, + }, + ) + + expect(result).toEqual([ + expect.objectContaining({ + series: [1, 2, 0], + dashIndices: [], + }), + ]) + }) + + it('uses nowMs when endDateMs is null', () => { + buildNormalisedTrendsDataset({ + dataset: [{ name: 'vue', type: 'line', series: [1] }], + dates: [10], + granularity: 'daily', + selectedMetric: 'downloads', + chartFilter: { averageWindow: 1, smoothingTau: 0 }, + endDateMs: null, + nowMs: 123, + }) + + expect(applyDataPipelineMock).toHaveBeenCalledWith( + [1], + expect.any(Object), + expect.objectContaining({ + referenceMs: 123, + }), + ) + }) + + it('uses Date.now when endDateMs and nowMs are missing', () => { + const dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(999) + + buildNormalisedTrendsDataset({ + dataset: [{ name: 'vue', type: 'line', series: [1] }], + dates: [], + granularity: 'daily', + selectedMetric: 'downloads', + chartFilter: { averageWindow: 1, smoothingTau: 0, predictionPoints: 4 }, + }) + + expect(applyDataPipelineMock).toHaveBeenCalledWith( + [1], + expect.objectContaining({ + predictionPoints: 4, + }), + expect.objectContaining({ + lastDateMs: 0, + referenceMs: 999, + }), + ) + + dateNowSpy.mockRestore() + }) + + it('disables prediction points for weekly granularity', () => { + buildNormalisedTrendsDataset({ + dataset: [{ name: 'vue', type: 'line', series: [1], dashIndices: [0] }], + dates: [10], + granularity: 'weekly', + selectedMetric: 'contributors', + chartFilter: { averageWindow: 1, smoothingTau: 0, predictionPoints: 5 }, + nowMs: 100, + }) + + expect(applyDataPipelineMock).toHaveBeenCalledWith( + [1], + expect.objectContaining({ + predictionPoints: 0, + }), + expect.objectContaining({ + isAbsoluteMetric: true, + referenceMs: 100, + }), + ) + }) +}) + +describe('getTrendsDatetimeFormatterOptions', () => { + it('returns formatter options for each granularity', () => { + expect(getTrendsDatetimeFormatterOptions('daily')).toEqual({ + year: 'yyyy-MM-dd', + month: 'yyyy-MM-dd', + day: 'yyyy-MM-dd', + }) + + expect(getTrendsDatetimeFormatterOptions('weekly')).toEqual({ + year: 'yyyy-MM-dd', + month: 'yyyy-MM-dd', + day: 'yyyy-MM-dd', + }) + + expect(getTrendsDatetimeFormatterOptions('monthly')).toEqual({ + year: 'MMM yyyy', + month: 'MMM yyyy', + day: 'MMM yyyy', + }) + + expect(getTrendsDatetimeFormatterOptions('yearly')).toEqual({ + year: 'yyyy', + month: 'yyyy', + day: 'yyyy', + }) + }) +}) + +describe('buildTrendsChartConfig', () => { + it('builds a light desktop config with default tooltip behavior', () => { + const config = buildTrendsChartConfig(baseConfigOptions()) + + expect(config.theme).toBe('') + expect(config.chart?.height).toBe(400) + expect(config.chart?.padding?.bottom).toBe(64) + expect(config.chart?.grid?.labels?.fontSize).toBe(16) + expect(config.chart?.tooltip?.teleportTo).toBeUndefined() + expect(config.chart?.tooltip?.position).toBe('center') + expect(config.chart?.tooltip?.offsetY).toBe(-24) + expect(config.chart?.timeTag?.backgroundColor).toBe('#f8f8f8') + }) + + it('falls back to bg when bgElevated is not provided', () => { + const config = buildTrendsChartConfig({ + ...baseConfigOptions(), + colors: { + ...colors, + bgElevated: undefined as never, + }, + }) + + expect(config.chart?.timeTag?.backgroundColor).toBe(colors.bg) + }) + + it('builds a dark mobile yearly multi-package config', () => { + const config = buildTrendsChartConfig({ + ...baseConfigOptions(), + packageNames: ['vue', 'react'], + isMultiPackageMode: true, + selectedGranularity: 'yearly', + displayedGranularity: 'yearly', + isDarkMode: true, + dates: [1, 2], + isMobile: true, + pending: true, + locale: 'fr-FR', + chartHeight: 500, + inModal: true, + tooltipPosition: 'left', + }) + + expect(config.theme).toBe('dark') + expect(config.chart?.padding?.bottom).toBe(84) + expect(config.chart?.grid?.labels?.fontSize).toBe(24) + expect(config.chart?.grid?.labels?.color).toBe(colors.border) + expect(config.chart?.grid?.labels?.axis?.fontSize).toBe(32) + expect(config.chart?.tooltip?.teleportTo).toBe('#chart-modal') + expect(config.chart?.tooltip?.position).toBe('left') + expect(config.chart?.tooltip?.offsetY).toBeUndefined() + }) + + it('formats finite and non-finite y-axis values', () => { + const config = buildTrendsChartConfig(baseConfigOptions()) + + const formatter = config.chart?.grid?.labels?.yAxis?.formatter as (payload: { + value: number + }) => string + + expect(formatter({ value: 1200 })).toBe('1.2K') + expect(formatter({ value: Number.NaN })).toBe('0') + }) +}) + +describe('drawTrendsEstimationLine', () => { + it('returns an empty string when rendering is disabled or no data exists', () => { + expect( + drawTrendsEstimationLine({ + svg: {}, + colors, + shouldRender: false, + }), + ).toBe('') + + expect( + drawTrendsEstimationLine({ + svg: {}, + colors, + shouldRender: true, + }), + ).toBe('') + }) + + it('draws estimation lines from svg data', () => { + const result = drawTrendsEstimationLine({ + svg: { + data: [ + { + color: '#ff0000', + plots: [ + { x: 1, y: 2 }, + { x: 3, y: 4 }, + ], + }, + ], + }, + colors, + shouldRender: true, + }) + + expect(result).toContain('x1="1"') + expect(result).toContain('x2="3"') + expect(result).toContain('stroke="#ff0000"') + expect(result).toContain(' { + const result = drawTrendsEstimationLine({ + svg: { + series: [ + { + plots: [ + { x: 1, y: 2 }, + { x: 3, y: 4 }, + ], + }, + { + plots: [{ x: 1, y: 2 }], + }, + { + plots: null, + }, + ], + }, + colors, + shouldRender: true, + }) + + expect(result).toContain(`stroke="${colors.fg}"`) + }) + + it('skips estimation lines when previous or last plot is missing', () => { + const result = drawTrendsEstimationLine({ + svg: { + data: [ + { + color: '#ff0000', + plots: [{ x: 1, y: 2 }, undefined], + }, + { + color: '#00ff00', + plots: [null, { x: 3, y: 4 }], + }, + ], + }, + colors, + shouldRender: true, + }) + + expect(result).toBe('') + }) +}) + +describe('drawTrendsLastDatapointLabel', () => { + it('returns an empty string when no data exists', () => { + expect( + drawTrendsLastDatapointLabel({ + svg: {}, + colors, + compactNumberFormatter, + }), + ).toBe('') + }) + + it('draws last datapoint labels from svg data', () => { + const result = drawTrendsLastDatapointLabel({ + svg: { + data: [ + { + plots: [{ x: 1, y: 2, value: 1200 }], + }, + ], + }, + colors, + compactNumberFormatter, + }) + + expect(result).toContain('1.2K') + expect(result).toContain('x="13"') + }) + + it('draws last datapoint labels and falls back to zero for non-finite values', () => { + const result = drawTrendsLastDatapointLabel({ + svg: { + series: [ + { + plots: [{ x: 1, y: 2, value: 1200 }], + }, + { + plots: [{ x: 3, y: 4, value: Number.NaN }], + }, + { + plots: [], + }, + ], + }, + colors, + compactNumberFormatter, + }) + + expect(result).toContain('1.2K') + expect(result).toContain('0') + expect(result).toContain('x="13"') + }) +}) + +describe('drawTrendsSvgPrintLegend', () => { + it('returns an empty string when no data exists', () => { + expect( + drawTrendsSvgPrintLegend({ + svg: {}, + colors, + showEstimationLegend: false, + estimationLabel: 'Estimated', + }), + ).toBe('') + + expect( + drawTrendsSvgPrintLegend({ + svg: { + data: [], + series: [ + { + name: 'ignored', + color: '#000000', + }, + ], + }, + colors, + showEstimationLegend: false, + estimationLabel: 'Estimated', + }), + ).toBe('') + }) + + it('draws the print legend without estimation legend from data', () => { + const result = drawTrendsSvgPrintLegend({ + svg: { + drawingArea: { + left: 10, + top: 20, + }, + data: [ + { + name: 'vue', + color: '#42b883', + }, + ], + }, + colors, + showEstimationLegend: false, + estimationLabel: 'Estimated', + }) + + expect(result).toContain('vue') + expect(result).toContain('fill="#42b883"') + expect(result).not.toContain('Estimated') + }) + + it('draws the print legend with estimation legend using series fallback', () => { + const result = drawTrendsSvgPrintLegend({ + svg: { + drawingArea: { + left: 10, + top: 20, + }, + series: [ + { + name: 'vue', + color: '#42b883', + }, + ], + }, + colors, + showEstimationLegend: true, + estimationLabel: 'Estimated', + }) + + expect(result).toContain('vue') + expect(result).toContain('Estimated') + expect(result).toContain('stroke-dasharray="4"') + }) +}) + +describe('generateWatermarkLogo', () => { + it('generates an SVG watermark logo', () => { + const result = generateWatermarkLogo({ + x: 1, + y: 2, + width: 3, + height: 4, + fill: '#123456', + }) + + expect(result).toContain('x="1"') + expect(result).toContain('y="2"') + expect(result).toContain('width="3"') + expect(result).toContain('height="4"') + expect(result).toContain('fill="#123456"') + }) +}) From a5ff26b6a4776780825412f021ce61ad95ced6ab Mon Sep 17 00:00:00 2001 From: graphieros Date: Mon, 1 Jun 2026 07:39:07 +0200 Subject: [PATCH 04/33] fix: show embed for downloads only --- app/components/Package/TrendsChart.vue | 112 +++++++++++++------------ 1 file changed, 57 insertions(+), 55 deletions(-) diff --git a/app/components/Package/TrendsChart.vue b/app/components/Package/TrendsChart.vue index 6c5d289ae4..fa835f7e1f 100644 --- a/app/components/Package/TrendsChart.vue +++ b/app/components/Package/TrendsChart.vue @@ -1953,63 +1953,65 @@ const copyEmbedUrl = () => copyEmbed(embedUrl.value) -
- -
-