From 41b0ecf511cb3778e151613509fa8363a61fc83a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20G=C3=B3mez?= Date: Fri, 31 Jul 2026 13:34:16 +0200 Subject: [PATCH 1/3] feat: download analytics on extension details page (cherry picked from commit c1e7648161d24e796783eeebe013be4285528a99) --- webui/src/extension-registry-service.ts | 24 +++++ webui/src/extension-registry-types.ts | 13 +++ .../extension-detail-overview.tsx | 2 + .../use-extension-download-series.ts | 53 ++++++++++ .../extension-detail/weekly-downloads.tsx | 99 +++++++++++++++++++ .../unit/components/weekly-downloads.spec.tsx | 76 ++++++++++++++ 6 files changed, 267 insertions(+) create mode 100644 webui/src/pages/extension-detail/use-extension-download-series.ts create mode 100644 webui/src/pages/extension-detail/weekly-downloads.tsx create mode 100644 webui/test/unit/components/weekly-downloads.spec.tsx diff --git a/webui/src/extension-registry-service.ts b/webui/src/extension-registry-service.ts index 5c8edd5d5..a67be0fa9 100644 --- a/webui/src/extension-registry-service.ts +++ b/webui/src/extension-registry-service.ts @@ -28,6 +28,8 @@ import { NamespaceMembershipList, PublisherInfo, RegistryVersion, + DownloadSeries, + DownloadSeriesInterval, SearchEntry, LoginProviders, ScanResultJson, @@ -94,6 +96,28 @@ export class ExtensionRegistryService { return createAbsoluteURL(arr); } + /** + * Fetches the download time series for an extension from the analytics endpoint. The endpoint + * only exists when download analytics are enabled server-side (otherwise it responds 404), so + * callers should gate on {@link RegistryVersion.analyticsEnabled}. `from`/`to` are UTC dates + * (yyyy-MM-dd); `from` is inclusive and `to` is exclusive. + */ + async getExtensionDownloadSeries( + abortController: AbortController, + params: { namespace: string; name: string; from?: string; to?: string; interval?: DownloadSeriesInterval } + ): Promise> { + const endpoint = createAbsoluteURL( + [this.serverUrl, 'api', params.namespace, params.name, 'analytics', 'downloads'], + [ + { key: 'from', value: params.from }, + { key: 'to', value: params.to }, + { key: 'interval', value: params.interval } + ] + ); + // Non-retriable: retries are owned by the TanStack query that calls this. + return sendNonRetriableRequest({ abortController, endpoint }); + } + async getNamespaceDetails(abortController: AbortController, name: string): Promise> { const endpoint = createAbsoluteURL([this.serverUrl, 'api', name, 'details']); return sendStrictRequest({ abortController, endpoint }); diff --git a/webui/src/extension-registry-types.ts b/webui/src/extension-registry-types.ts index 5b4edfe2a..d39f5d888 100644 --- a/webui/src/extension-registry-types.ts +++ b/webui/src/extension-registry-types.ts @@ -340,8 +340,21 @@ export interface TargetPlatformVersion { export interface RegistryVersion { version: string; maxExtensionSize?: number; + analyticsEnabled?: boolean; } +/** One bucket of the download time series: `t` is the UTC bucket start (yyyy-MM-dd). */ +export interface DownloadSeriesPoint { + t: string; + count: number; +} + +export interface DownloadSeries { + points: DownloadSeriesPoint[]; +} + +export type DownloadSeriesInterval = 'day' | 'week' | 'month'; + export interface LoginProviders { loginProviders: Record; } diff --git a/webui/src/pages/extension-detail/extension-detail-overview.tsx b/webui/src/pages/extension-detail/extension-detail-overview.tsx index adf8f6538..1f2b11b64 100644 --- a/webui/src/pages/extension-detail/extension-detail-overview.tsx +++ b/webui/src/pages/extension-detail/extension-detail-overview.tsx @@ -24,6 +24,7 @@ import { Extension, ExtensionReference, VERSION_ALIASES } from '../../extension- import { ExtensionListRoutes } from '../extension-list/extension-list-routes'; import { ExtensionDetailRoutes } from './extension-detail-routes'; import { ExtensionDetailDownloadsMenu } from './extension-detail-downloads-menu'; +import { WeeklyDownloads } from './weekly-downloads'; export const ExtensionDetailOverview: FunctionComponent = props => { const [loading, setLoading] = useState(true); @@ -350,6 +351,7 @@ export const ExtensionDetailOverview: FunctionComponent + {renderVersionSection()} {otherAliases.length || extension.versionAlias.length ? ( diff --git a/webui/src/pages/extension-detail/use-extension-download-series.ts b/webui/src/pages/extension-detail/use-extension-download-series.ts new file mode 100644 index 000000000..4451eeb3e --- /dev/null +++ b/webui/src/pages/extension-detail/use-extension-download-series.ts @@ -0,0 +1,53 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ + +import { useContext } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { DateTime } from 'luxon'; +import { MainContext } from '../../context'; +import { controllerFromSignal } from '../../query-client'; +import { DownloadSeriesPoint } from '../../extension-registry-types'; + +const WEEKS = 52; +// 6 extra leading days so the first plotted point already has a full trailing-7-day window. +const LEAD_IN_DAYS = 6; + +/** + * Loads roughly the last {@link WEEKS} weeks of *daily* downloads for an extension, up to and + * including today, as the react-query result (`data` is the ordered {@link DownloadSeriesPoint} + * array). Callers turn this into a trailing 7-day ("weekly downloads") view; daily granularity is + * what lets that window end on today rather than the last complete calendar week. Gate with + * `options.enabled` on `RegistryVersion.analyticsEnabled`, since the endpoint 404s when analytics + * is disabled. + */ +export const useExtensionDownloadSeries = (namespace: string, name: string, options?: { enabled?: boolean }) => { + const { service } = useContext(MainContext); + return useQuery({ + queryKey: ['extension-downloads', namespace, name], + queryFn: async ({ signal }): Promise => { + const today = DateTime.utc().startOf('day'); + // `to` is exclusive, so today + 1 day includes today's (still-accruing) bucket. + const to = today.plus({ days: 1 }); + const from = to.minus({ weeks: WEEKS }).minus({ days: LEAD_IN_DAYS }); + const series = await service.getExtensionDownloadSeries(controllerFromSignal(signal), { + namespace, + name, + from: from.toFormat('yyyy-MM-dd'), + to: to.toFormat('yyyy-MM-dd'), + interval: 'day' + }); + return series.points; + }, + ...options + }); +}; diff --git a/webui/src/pages/extension-detail/weekly-downloads.tsx b/webui/src/pages/extension-detail/weekly-downloads.tsx new file mode 100644 index 000000000..91f95bbcb --- /dev/null +++ b/webui/src/pages/extension-detail/weekly-downloads.tsx @@ -0,0 +1,99 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ + +import { FunctionComponent, useContext, useMemo } from 'react'; +import { Box, Typography, styled, useTheme } from '@mui/material'; +import { SparkLineChart } from '@mui/x-charts/SparkLineChart'; +import { MainContext } from '../../context'; +import { Eyebrow, cardSurface } from '../../components/page-primitives'; +import { Extension } from '../../extension-registry-types'; +import { useExtensionDownloadSeries } from './use-extension-download-series'; + +const DownloadsCard = styled(Box)(({ theme }) => ({ + ...cardSurface(theme), + padding: '0.75rem 1rem' +})); + +const DownloadsCount = styled(Typography)(({ theme }) => ({ + fontSize: '1.75rem', + lineHeight: 1.1, + fontWeight: 700, + color: theme.palette.text.primary, + fontVariantNumeric: 'tabular-nums' +})) as typeof Typography; + +const WINDOW_DAYS = 7; + +/** Trailing {@link WINDOW_DAYS}-day rolling sums of a daily series (each point covers that day and + * the previous six), so the last value is the downloads of the last week ending today. */ +function trailingWeeklySums(daily: number[]): number[] { + const rolling: number[] = []; + let windowSum = 0; + for (let i = 0; i < daily.length; i++) { + windowSum += daily[i]; + if (i >= WINDOW_DAYS) { + windowSum -= daily[i - WINDOW_DAYS]; + } + if (i >= WINDOW_DAYS - 1) { + rolling.push(windowSum); + } + } + return rolling; +} + +/** + * "Weekly downloads" sidebar card: the downloads of the last 7 days (ending today) plus a trailing + * 7-day trend sparkline over the last year. Renders nothing when download analytics are disabled + * server-side (the endpoint 404s) or when the extension has no downloads in the window, so it stays + * out of the way on registries without data. + */ +export const WeeklyDownloads: FunctionComponent<{ extension: Extension }> = ({ extension }) => { + const theme = useTheme(); + const { version } = useContext(MainContext); + const analyticsEnabled = version?.analyticsEnabled ?? false; + + const { data: points } = useExtensionDownloadSeries(extension.namespace, extension.name, { + enabled: analyticsEnabled + }); + + const counts = useMemo(() => trailingWeeklySums(points?.map(point => point.count) ?? []), [points]); + const hasDownloads = counts.some(count => count > 0); + if (!analyticsEnabled || counts.length === 0 || !hasDownloads) { + return null; + } + + const latestWeek = counts[counts.length - 1]; + + return ( + + + Weekly downloads + + {latestWeek.toLocaleString()} + + (value === null ? '' : `${value.toLocaleString()} downloads`)} + /> + + + + + ); +}; diff --git a/webui/test/unit/components/weekly-downloads.spec.tsx b/webui/test/unit/components/weekly-downloads.spec.tsx new file mode 100644 index 000000000..7f3554ed7 --- /dev/null +++ b/webui/test/unit/components/weekly-downloads.spec.tsx @@ -0,0 +1,76 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ + +import { describe, it, expect, vi } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import { renderWithProviders } from '../support/test-providers'; +import { WeeklyDownloads } from '../../../src/pages/extension-detail/weekly-downloads'; +import { DownloadSeriesPoint, Extension, RegistryVersion } from '../../../src/extension-registry-types'; +import { ExtensionRegistryService } from '../../../src/extension-registry-service'; + +// The real chart pulls in SVG measurement APIs jsdom lacks; stub it so the test exercises +// the component's own logic (gating, the headline number, and the series it feeds the chart). +vi.mock('@mui/x-charts/SparkLineChart', () => ({ + SparkLineChart: ({ data }: { data: number[] }) =>
+})); + +const extension = { namespace: 'redhat', name: 'java' } as unknown as Extension; +const analyticsEnabled: RegistryVersion = { version: '1.0.0', analyticsEnabled: true }; + +function points(counts: number[]): DownloadSeriesPoint[] { + return counts.map((count, i) => ({ t: `2026-01-${String(i + 1).padStart(2, '0')}`, count })); +} + +function serviceReturning(series: DownloadSeriesPoint[]): ExtensionRegistryService { + return { + getExtensionDownloadSeries: vi.fn().mockResolvedValue({ points: series }) + } as unknown as ExtensionRegistryService; +} + +describe('WeeklyDownloads', () => { + it('shows the last-7-days total and the trailing-week trend when analytics is enabled', async () => { + // 14 daily points of 1000 → every trailing-7-day sum is 7000; rolling length = 14 - 6 = 8. + const service = serviceReturning(points(Array(14).fill(1000))); + renderWithProviders(, { + mainContext: { service, version: analyticsEnabled } + }); + + expect(await screen.findByText((7000).toLocaleString())).toBeInTheDocument(); + expect(screen.getByText(/weekly downloads/i)).toBeInTheDocument(); + expect(screen.getByTestId('sparkline')).toHaveAttribute('data-length', '8'); + expect(service.getExtensionDownloadSeries).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ namespace: 'redhat', name: 'java', interval: 'day' }) + ); + }); + + it('renders nothing (and never calls the endpoint) when analytics is disabled', () => { + const service = serviceReturning(points(Array(14).fill(1))); + renderWithProviders(, { + mainContext: { service, version: { version: '1.0.0', analyticsEnabled: false } } + }); + + expect(screen.queryByText(/weekly downloads/i)).not.toBeInTheDocument(); + expect(service.getExtensionDownloadSeries).not.toHaveBeenCalled(); + }); + + it('renders nothing when the extension has no downloads in the window', async () => { + const service = serviceReturning(points(Array(14).fill(0))); + renderWithProviders(, { + mainContext: { service, version: analyticsEnabled } + }); + + await waitFor(() => expect(service.getExtensionDownloadSeries).toHaveBeenCalled()); + expect(screen.queryByText(/weekly downloads/i)).not.toBeInTheDocument(); + }); +}); From 9975d4278e6252e5a7c12698f69c2bc3421019b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20G=C3=B3mez?= Date: Fri, 31 Jul 2026 14:31:18 +0200 Subject: [PATCH 2/3] fix: bad fill color for downloads chart (cherry picked from commit 2f5528277fa81221f108af1848bbf3cbd9669ee7) --- webui/src/pages/extension-detail/weekly-downloads.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/webui/src/pages/extension-detail/weekly-downloads.tsx b/webui/src/pages/extension-detail/weekly-downloads.tsx index 91f95bbcb..fd6fdd4ff 100644 --- a/webui/src/pages/extension-detail/weekly-downloads.tsx +++ b/webui/src/pages/extension-detail/weekly-downloads.tsx @@ -66,7 +66,8 @@ export const WeeklyDownloads: FunctionComponent<{ extension: Extension }> = ({ e enabled: analyticsEnabled }); - const counts = useMemo(() => trailingWeeklySums(points?.map(point => point.count) ?? []), [points]); + const daily = useMemo(() => points ?? [], [points]); + const counts = useMemo(() => trailingWeeklySums(daily.map(point => point.count)), [daily]); const hasDownloads = counts.some(count => count > 0); if (!analyticsEnabled || counts.length === 0 || !hasDownloads) { return null; @@ -90,6 +91,8 @@ export const WeeklyDownloads: FunctionComponent<{ extension: Extension }> = ({ e showHighlight color={theme.palette.secondary.main} valueFormatter={value => (value === null ? '' : `${value.toLocaleString()} downloads`)} + // `.MuiAreaElement-root` is the sparkline's area path; lighten its fill to a wash. + sx={{ '& .MuiAreaElement-root': { fillOpacity: 0.14 } }} /> From 5a246b622a93ae04b0cae5ee5b5bb74368703a88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20G=C3=B3mez?= Date: Mon, 24 Aug 2026 10:26:27 +0200 Subject: [PATCH 3/3] refactor: rework the weekly downloads card (cherry picked from commit f72f64f73161e91b43d9fbab4af54370853e7717) --- webui/CHANGELOG.md | 1 + .../use-extension-download-series.ts | 16 +- .../extension-detail/weekly-downloads.tsx | 197 +++++++++++++----- .../unit/components/weekly-downloads.spec.tsx | 104 ++++++++- 4 files changed, 247 insertions(+), 71 deletions(-) diff --git a/webui/CHANGELOG.md b/webui/CHANGELOG.md index 3a45e1468..e408c7501 100644 --- a/webui/CHANGELOG.md +++ b/webui/CHANGELOG.md @@ -33,6 +33,7 @@ This change log covers only the frontend library (webui) of Open VSX. - Add a `userMenuContent` slot to `PageSettings.elements`: extra entries for the logged-in account menu, rendered above the admin entry. The slot receives a `MenuEntry` component to build entries with, so each entry is styled by the menu it appears in — the desktop and mobile menus style theirs differently, and a consumer cannot match both on its own - Add an `adminPages` slot to `PageSettings.elements`: extra admin dashboard pages, each declaring a name, icon, optional description and optional category, and each appearing in the side panel, as a card on the dashboard overview and as a route. Contributions are additive — a category name matching a built-in group appends to it, and a page whose path would shadow a built-in one is ignored - Widen the published API for consumers building their own pages: the request layer (`sendRequest`, `sendNonRetriableRequest`, `ErrorResponse`, `controllerFromSignal`), `MainContext`, `AppProviders`, `NotFound`, `createDefaultTheme` with the `MONO_FONT`/`NAVBAR_HEIGHT` tokens, the `createRoute`/`createAbsoluteURL`/`addQuery`/`formatCompactNumber`/`toRelativeTime` utils, the `useDebouncedCallback` and `useGridCursor` hooks, the navbar-chrome, search-focus and page-search-bar hooks, the category icon helpers, `ExtensionDetailRoutes`, and the `itemIcon`/`MenuItemText` building blocks for `userMenuContent` entries +- Add a weekly downloads card to the extension detail page, shown only when the registry reports download analytics as enabled: the last 7 days' downloads, a sparkline of the weekly totals for the year behind it, and the period the headline covers. Hovering moves a marker line and reads out that week instead, and the card shows a skeleton in the same shape while the series loads ### Changed diff --git a/webui/src/pages/extension-detail/use-extension-download-series.ts b/webui/src/pages/extension-detail/use-extension-download-series.ts index 4451eeb3e..270960c8b 100644 --- a/webui/src/pages/extension-detail/use-extension-download-series.ts +++ b/webui/src/pages/extension-detail/use-extension-download-series.ts @@ -19,16 +19,14 @@ import { controllerFromSignal } from '../../query-client'; import { DownloadSeriesPoint } from '../../extension-registry-types'; const WEEKS = 52; -// 6 extra leading days so the first plotted point already has a full trailing-7-day window. -const LEAD_IN_DAYS = 6; /** - * Loads roughly the last {@link WEEKS} weeks of *daily* downloads for an extension, up to and - * including today, as the react-query result (`data` is the ordered {@link DownloadSeriesPoint} - * array). Callers turn this into a trailing 7-day ("weekly downloads") view; daily granularity is - * what lets that window end on today rather than the last complete calendar week. Gate with - * `options.enabled` on `RegistryVersion.analyticsEnabled`, since the endpoint 404s when analytics - * is disabled. + * Loads the last {@link WEEKS} whole weeks of *daily* downloads for an extension, ending today, as + * the react-query result (`data` is the ordered {@link DownloadSeriesPoint} array). The range is an + * exact multiple of 7 days so callers can fold it into whole weeks with nothing left over; daily + * granularity is what lets those weeks end on today rather than on the last complete calendar week. + * Gate with `options.enabled` on `RegistryVersion.analyticsEnabled`, since the endpoint 404s when + * analytics is disabled. */ export const useExtensionDownloadSeries = (namespace: string, name: string, options?: { enabled?: boolean }) => { const { service } = useContext(MainContext); @@ -38,7 +36,7 @@ export const useExtensionDownloadSeries = (namespace: string, name: string, opti const today = DateTime.utc().startOf('day'); // `to` is exclusive, so today + 1 day includes today's (still-accruing) bucket. const to = today.plus({ days: 1 }); - const from = to.minus({ weeks: WEEKS }).minus({ days: LEAD_IN_DAYS }); + const from = to.minus({ weeks: WEEKS }); const series = await service.getExtensionDownloadSeries(controllerFromSignal(signal), { namespace, name, diff --git a/webui/src/pages/extension-detail/weekly-downloads.tsx b/webui/src/pages/extension-detail/weekly-downloads.tsx index fd6fdd4ff..39883afb7 100644 --- a/webui/src/pages/extension-detail/weekly-downloads.tsx +++ b/webui/src/pages/extension-detail/weekly-downloads.tsx @@ -11,92 +11,179 @@ * SPDX-License-Identifier: EPL-2.0 *****************************************************************************/ -import { FunctionComponent, useContext, useMemo } from 'react'; -import { Box, Typography, styled, useTheme } from '@mui/material'; +import { FunctionComponent, useContext, useMemo, useState } from 'react'; +import { Box, Skeleton, Typography, alpha, styled, useTheme } from '@mui/material'; import { SparkLineChart } from '@mui/x-charts/SparkLineChart'; +import { lineClasses } from '@mui/x-charts/LineChart'; +import { chartsAxisHighlightClasses } from '@mui/x-charts/ChartsAxisHighlight'; +import { DateTime } from 'luxon'; import { MainContext } from '../../context'; -import { Eyebrow, cardSurface } from '../../components/page-primitives'; -import { Extension } from '../../extension-registry-types'; +import { Eyebrow } from '../../components/page-primitives'; +import { DownloadSeriesPoint, Extension } from '../../extension-registry-types'; import { useExtensionDownloadSeries } from './use-extension-download-series'; -const DownloadsCard = styled(Box)(({ theme }) => ({ - ...cardSurface(theme), - padding: '0.75rem 1rem' -})); - +/** Kept modest on purpose: the headline shares its row with the sparkline, which needs the width. */ const DownloadsCount = styled(Typography)(({ theme }) => ({ - fontSize: '1.75rem', - lineHeight: 1.1, + fontSize: '1.25rem', + lineHeight: 1.2, fontWeight: 700, color: theme.palette.text.primary, fontVariantNumeric: 'tabular-nums' })) as typeof Typography; -const WINDOW_DAYS = 7; - -/** Trailing {@link WINDOW_DAYS}-day rolling sums of a daily series (each point covers that day and - * the previous six), so the last value is the downloads of the last week ending today. */ -function trailingWeeklySums(daily: number[]): number[] { - const rolling: number[] = []; - let windowSum = 0; - for (let i = 0; i < daily.length; i++) { - windowSum += daily[i]; - if (i >= WINDOW_DAYS) { - windowSum -= daily[i - WINDOW_DAYS]; - } - if (i >= WINDOW_DAYS - 1) { - rolling.push(windowSum); +const Period = styled(Typography)(({ theme }) => ({ + fontSize: '0.75rem', + lineHeight: 1.4, + color: theme.palette.text.secondary, + fontVariantNumeric: 'tabular-nums' +})) as typeof Typography; + +const DAY_AND_MONTH = { month: 'short', day: 'numeric' } as const; + +const WEEK_DAYS = 7; + +/** + * Tuned against the headline beside it: the row is bottom-aligned, so its height is the chart's and + * anything much taller leaves dead space above the number rather than a bigger curve. + */ +const CHART_HEIGHT_PX = 48; + +const asDate = (point: DownloadSeriesPoint): DateTime => DateTime.fromISO(point.t, { zone: 'utc' }); + +/** "Aug 21, 2026" for a single day, or "Aug 15 – Aug 21, 2026" across a week. */ +function formatPeriod(from: DownloadSeriesPoint, to: DownloadSeriesPoint): string | undefined { + const start = asDate(from); + const end = asDate(to); + if (!start.isValid || !end.isValid) { + return undefined; + } + + const endLabel = end.toLocaleString({ ...DAY_AND_MONTH, year: 'numeric' }); + return start.hasSame(end, 'day') ? endLabel : `${start.toLocaleString(DAY_AND_MONTH)} – ${endLabel}`; +} + +/** + * Folds a daily series into consecutive {@link WEEK_DAYS}-day totals, aligned so the last week ends + * on the most recent day. Any leading remainder shorter than a whole week is dropped, so every + * plotted point is a full week sharing no days with its neighbours — a rise or fall on the curve is + * a real week-on-week change. Oldest first. + */ +function weeklyTotals(daily: number[]): number[] { + const weeks: number[] = []; + for (let end = daily.length; end >= WEEK_DAYS; end -= WEEK_DAYS) { + let total = 0; + for (let day = end - WEEK_DAYS; day < end; day++) { + total += daily[day]; } + weeks.unshift(total); } - return rolling; + return weeks; } /** - * "Weekly downloads" sidebar card: the downloads of the last 7 days (ending today) plus a trailing - * 7-day trend sparkline over the last year. Renders nothing when download analytics are disabled - * server-side (the endpoint 404s) or when the extension has no downloads in the window, so it stays - * out of the way on registries without data. + * The sidebar slot, shared by the loaded and loading states so neither shifts. Deliberately not a + * card: it sits flush with the resources group below it, which is styled the same way. + */ +const sectionSx = { + display: 'flex', + flexDirection: 'column', + flex: { xs: 'none', sm: 'none', md: 1, lg: 1, xl: 'none' }, + mb: { xs: 2, sm: 2, md: 0, lg: 0, xl: 2 } +} as const; + +/** Same shape and heights as the loaded section, so the sidebar does not jump when the series lands. */ +const LoadingCard: FunctionComponent = () => ( + + Weekly downloads + + + + + + +); + +/** + * "Weekly downloads" sidebar card: the downloads of the last 7 days, with a sparkline of the weekly + * totals for the year behind it — one point per week, so the headline is simply its last point. + * Hovering reads out that week instead. Renders nothing when download analytics are disabled + * server-side (the endpoint 404s) or when the extension has no downloads in the year. */ export const WeeklyDownloads: FunctionComponent<{ extension: Extension }> = ({ extension }) => { const theme = useTheme(); const { version } = useContext(MainContext); const analyticsEnabled = version?.analyticsEnabled ?? false; + const [hovered, setHovered] = useState(undefined); - const { data: points } = useExtensionDownloadSeries(extension.namespace, extension.name, { + const { data: points, isLoading } = useExtensionDownloadSeries(extension.namespace, extension.name, { enabled: analyticsEnabled }); const daily = useMemo(() => points ?? [], [points]); - const counts = useMemo(() => trailingWeeklySums(daily.map(point => point.count)), [daily]); - const hasDownloads = counts.some(count => count > 0); - if (!analyticsEnabled || counts.length === 0 || !hasDownloads) { + const counts = useMemo(() => weeklyTotals(daily.map(point => point.count)), [daily]); + if (!analyticsEnabled) { + return null; + } + // `isLoading` is the first fetch only, and stays false while the query is disabled + if (isLoading) { + return ; + } + if (counts.length === 0 || !counts.some(count => count > 0)) { return null; } - const latestWeek = counts[counts.length - 1]; + // the last week by default; whole weeks are taken from the end, so a short first week is dropped + const selected = hovered !== undefined && hovered < counts.length ? hovered : counts.length - 1; + const first = daily.length - counts.length * WEEK_DAYS + selected * WEEK_DAYS; + const period = formatPeriod(daily[first], daily[first + WEEK_DAYS - 1]); + // Reserve room for the busiest week, so the headline's width does not track its digit count and + // resize the sparkline beside it as the pointer moves. Data-derived, so it cannot be a class. + const reserved = `${Math.max(...counts).toLocaleString().length}ch`; return ( - - - Weekly downloads - - {latestWeek.toLocaleString()} - - (value === null ? '' : `${value.toLocaleString()} downloads`)} - // `.MuiAreaElement-root` is the sparkline's area path; lighten its fill to a wash. - sx={{ '& .MuiAreaElement-root': { fillOpacity: 0.14 } }} - /> - + + Weekly downloads + {period && {period}} + + {counts[selected].toLocaleString()} + + ({ min: -maxValue / 6, max: maxValue }) }} + clipAreaOffset={{ top: 2, bottom: 2 }} + showHighlight + // A non-'none' axis highlight is also what enables the axis listener, so + // the readout above tracks the pointer anywhere along the curve. + axisHighlight={{ x: 'line' }} + onHighlightedAxisChange={items => setHovered(items[0]?.dataIndex)} + slotProps={{ lineHighlight: { r: 4 } }} + color={theme.palette.secondary.main} + sx={{ + [`& .${lineClasses.area}`]: { opacity: 0.2 }, + [`& .${lineClasses.line}`]: { strokeWidth: 3 }, + [`& .${chartsAxisHighlightClasses.root}`]: { + stroke: theme.palette.secondary.main, + strokeDasharray: 'none', + strokeWidth: 2 + } + }} + /> - + ); }; diff --git a/webui/test/unit/components/weekly-downloads.spec.tsx b/webui/test/unit/components/weekly-downloads.spec.tsx index 7f3554ed7..263bc3084 100644 --- a/webui/test/unit/components/weekly-downloads.spec.tsx +++ b/webui/test/unit/components/weekly-downloads.spec.tsx @@ -13,15 +13,35 @@ import { describe, it, expect, vi } from 'vitest'; import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { renderWithProviders } from '../support/test-providers'; import { WeeklyDownloads } from '../../../src/pages/extension-detail/weekly-downloads'; import { DownloadSeriesPoint, Extension, RegistryVersion } from '../../../src/extension-registry-types'; import { ExtensionRegistryService } from '../../../src/extension-registry-service'; -// The real chart pulls in SVG measurement APIs jsdom lacks; stub it so the test exercises -// the component's own logic (gating, the headline number, and the series it feeds the chart). +// The real chart pulls in SVG measurement APIs jsdom lacks; stub it so the test exercises the +// component's own logic (gating, the headline readout, and the series it feeds the chart). The +// buttons stand in for pointer movement along the axis, which is what the real chart reports +// through onHighlightedAxisChange. vi.mock('@mui/x-charts/SparkLineChart', () => ({ - SparkLineChart: ({ data }: { data: number[] }) =>
+ SparkLineChart: ({ + data, + onHighlightedAxisChange + }: { + data: number[]; + onHighlightedAxisChange?: (items: { axisId: string; dataIndex: number }[]) => void; + }) => ( +
+ {data.map((_, index) => ( +
+ ) })); const extension = { namespace: 'redhat', name: 'java' } as unknown as Extension; @@ -37,9 +57,13 @@ function serviceReturning(series: DownloadSeriesPoint[]): ExtensionRegistryServi } as unknown as ExtensionRegistryService; } +// Two whole weeks, 1..14 downloads per day: week 0 covers Jan 1-7 (28) and week 1, the latest, +// covers Jan 8-14 (77). +const ascending = points(Array.from({ length: 14 }, (_, i) => i + 1)); + describe('WeeklyDownloads', () => { it('shows the last-7-days total and the trailing-week trend when analytics is enabled', async () => { - // 14 daily points of 1000 → every trailing-7-day sum is 7000; rolling length = 14 - 6 = 8. + // 14 daily points of 1000 → two whole weeks of 7000 each const service = serviceReturning(points(Array(14).fill(1000))); renderWithProviders(, { mainContext: { service, version: analyticsEnabled } @@ -47,13 +71,78 @@ describe('WeeklyDownloads', () => { expect(await screen.findByText((7000).toLocaleString())).toBeInTheDocument(); expect(screen.getByText(/weekly downloads/i)).toBeInTheDocument(); - expect(screen.getByTestId('sparkline')).toHaveAttribute('data-length', '8'); + expect(screen.getByTestId('sparkline')).toHaveAttribute('data-length', '2'); expect(service.getExtensionDownloadSeries).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ namespace: 'redhat', name: 'java', interval: 'day' }) ); }); + it('headlines the last week and labels the period it covers', async () => { + renderWithProviders(, { + mainContext: { service: serviceReturning(ascending), version: analyticsEnabled } + }); + + expect(await screen.findByText((77).toLocaleString())).toBeInTheDocument(); + expect(screen.getByText(/Jan 8.*Jan 14, 2026/)).toBeInTheDocument(); + }); + + it('reads out the hovered week, and returns to the last week when the pointer leaves', async () => { + renderWithProviders(, { + mainContext: { service: serviceReturning(ascending), version: analyticsEnabled } + }); + await screen.findByText((77).toLocaleString()); + + await userEvent.click(screen.getByRole('button', { name: 'hover 0' })); + + expect(screen.getByText((28).toLocaleString())).toBeInTheDocument(); + expect(screen.getByText(/Jan 1.*Jan 7, 2026/)).toBeInTheDocument(); + expect(screen.queryByText((77).toLocaleString())).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: 'hover out' })); + + expect(screen.getByText((77).toLocaleString())).toBeInTheDocument(); + expect(screen.getByText(/Jan 8.*Jan 14, 2026/)).toBeInTheDocument(); + }); + + it('reserves headline width for the busiest week, so the sparkline does not resize on hover', async () => { + // a quiet week of 7 then a busy one of 12,257,000 (ten chars with separators) + const uneven = points([...Array(7).fill(1), ...Array(7).fill(1751000)]); + renderWithProviders(, { + mainContext: { service: serviceReturning(uneven), version: analyticsEnabled } + }); + + // asserted on the style attribute: jsdom drops `ch` from the computed style + const reserved = `min-width: ${(12257000).toLocaleString().length}ch`; + const headline = await screen.findByText((12257000).toLocaleString()); + expect(headline.getAttribute('style')).toContain(reserved); + + // the reservation is unchanged while the single-digit week is being read out + await userEvent.click(screen.getByRole('button', { name: 'hover 0' })); + expect(screen.getByText('7').getAttribute('style')).toContain(reserved); + }); + + it('shows a skeleton while the first request is in flight, then the figures', async () => { + let resolve!: (value: { points: DownloadSeriesPoint[] }) => void; + const service = { + getExtensionDownloadSeries: vi.fn().mockReturnValue(new Promise(done => (resolve = done))) + } as unknown as ExtensionRegistryService; + renderWithProviders(, { + mainContext: { service, version: analyticsEnabled } + }); + + // the card's shell is already there, so the sidebar does not shift when the data lands + expect(screen.getByRole('status', { name: 'Loading weekly downloads' })).toBeInTheDocument(); + expect(screen.getByText(/weekly downloads/i)).toBeInTheDocument(); + expect(screen.queryByTestId('sparkline')).not.toBeInTheDocument(); + + resolve({ points: ascending }); + + expect(await screen.findByText((77).toLocaleString())).toBeInTheDocument(); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + expect(screen.getByTestId('sparkline')).toBeInTheDocument(); + }); + it('renders nothing (and never calls the endpoint) when analytics is disabled', () => { const service = serviceReturning(points(Array(14).fill(1))); renderWithProviders(, { @@ -70,7 +159,8 @@ describe('WeeklyDownloads', () => { mainContext: { service, version: analyticsEnabled } }); - await waitFor(() => expect(service.getExtensionDownloadSeries).toHaveBeenCalled()); - expect(screen.queryByText(/weekly downloads/i)).not.toBeInTheDocument(); + // the skeleton shows first, so wait for the card to settle on rendering nothing + await waitFor(() => expect(screen.queryByText(/weekly downloads/i)).not.toBeInTheDocument()); + expect(service.getExtensionDownloadSeries).toHaveBeenCalled(); }); });