'use client' /** * This Source Code is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * * Copyright (c) Infonomic Company Limited */ /** * The analytics timeseries — one column per explicit UTC reporting bucket. * * A column rather than a continuous line is a claim about the data, not a * style: the rows behind this chart are sums of daily aggregates and visitor * hashes rotate at UTC midnight, so nothing is measured between two points. * The dashboard selects the granularity and passes it into this renderer; * the chart never guesses from the number of rows. */ import type React from 'react' import { useMemo, useRef, useState } from 'react' import type { AnalyticsSummaryDay } from '@byline/analytics' import type { AnalyticsDashboardPeriod } from '@byline/analytics/config' import { useTranslation } from '@byline/i18n/react' import cx from 'clsx' import styles from './timeseries.module.css' /** Drawn in a fixed user-space box and stretched to the container width. */ const VIEWBOX_WIDTH = 900 const VIEWBOX_HEIGHT = 180 const COLUMN_GAP_RATIO = 0.38 const MAX_COLUMN_WIDTH = 26 export type AnalyticsChartGranularity = 'day' | 'seven-day' | 'month' export interface AnalyticsChartBucket { from: string to: string granularity: AnalyticsChartGranularity /** Actual number of daily rows, including partial first or last buckets. */ dayCount: number views: number /** Sum of the bucket's daily-unique visitor values. */ visitors: number downloads: number } export interface AnalyticsColumn { index: number /** Full-height mark: that bucket's page views. */ x: number width: number y: number height: number /** Inset mark: summed daily uniques, always no greater than views. */ insetX: number insetWidth: number insetY: number insetHeight: number /** Full-height transparent target, so narrow columns stay easy to hit. */ hitX: number hitWidth: number } /** Resolve chart density outside the renderer so the chosen width is explicit. */ export function resolveAnalyticsChartGranularity( period: AnalyticsDashboardPeriod, dayCount: number ): AnalyticsChartGranularity { if (typeof period === 'number' || (period === 'all' && dayCount <= 90)) return 'day' if (period === 'ytd' || dayCount <= 732) return 'seven-day' return 'month' } /** Combine complete daily query rows using the caller-selected granularity. */ export function bucketAnalyticsTimeseries( days: readonly AnalyticsSummaryDay[], granularity: AnalyticsChartGranularity ): readonly AnalyticsChartBucket[] { if (granularity === 'day') { return days.map((day) => ({ from: day.day, to: day.day, granularity, dayCount: 1, views: day.views, visitors: day.visitors, downloads: day.downloads, })) } if (granularity === 'seven-day') return bucketBySevenDays(days) return bucketByUtcMonth(days) } /** Project reporting buckets into column geometry. */ export function buildAnalyticsColumns( buckets: readonly Pick[] ): readonly AnalyticsColumn[] { if (buckets.length === 0) return [] const ceiling = Math.max(1, ...buckets.map((bucket) => bucket.views)) const step = VIEWBOX_WIDTH / buckets.length const width = Math.max(2, Math.min(step * (1 - COLUMN_GAP_RATIO), MAX_COLUMN_WIDTH)) return buckets.map((bucket, index) => { const centre = index * step + step / 2 const height = (bucket.views / ceiling) * VIEWBOX_HEIGHT const insetHeight = (bucket.visitors / ceiling) * VIEWBOX_HEIGHT const insetWidth = Math.max(1, width / 2) return { index, x: centre - width / 2, width, y: VIEWBOX_HEIGHT - height, height, insetX: centre - insetWidth / 2, insetWidth, insetY: VIEWBOX_HEIGHT - insetHeight, insetHeight, hitX: index * step, hitWidth: step, } }) } /** * Where the hover card sits relative to the plot box. * * `leftPercent` / `topPercent` are percentages of the plot rather than user * -space units because the card is HTML laid over a stretched SVG: the plot * is drawn `preserveAspectRatio="none"`, so anything measured in viewBox * units would skew with the container width (the same reason the axis labels * are HTML). Percentages survive the stretch. */ export interface AnalyticsHoverCardPlacement { /** Horizontal centre of the hovered column, as a percentage of plot width. */ leftPercent: number /** Top edge of the hovered column, as a percentage of plot height. */ topPercent: number /** * How the card lines up with `leftPercent`. Columns near a plot edge anchor * that edge instead of centring, so the card never overhangs the chart. */ align: 'start' | 'center' | 'end' /** * Which side of the column top the card occupies. A column tall enough that * a card above it would clip the top of the plot takes the card below * instead. */ side: 'above' | 'below' } /** Distance from a plot edge, in percent, inside which the card anchors. */ const CARD_EDGE_MARGIN = 15 /** Bar tops above this percentage leave no room for a card above them. */ const CARD_FLIP_THRESHOLD = 45 /** Project one column's geometry into hover-card placement. */ export function resolveHoverCardPlacement(column: AnalyticsColumn): AnalyticsHoverCardPlacement { const leftPercent = ((column.hitX + column.hitWidth / 2) / VIEWBOX_WIDTH) * 100 const topPercent = (column.y / VIEWBOX_HEIGHT) * 100 return { leftPercent, topPercent, align: leftPercent < CARD_EDGE_MARGIN ? 'start' : leftPercent > 100 - CARD_EDGE_MARGIN ? 'end' : 'center', side: topPercent < CARD_FLIP_THRESHOLD ? 'below' : 'above', } } export interface AnalyticsTimeseriesProps { days: readonly AnalyticsSummaryDay[] granularity: AnalyticsChartGranularity locale: string } export function AnalyticsTimeseries({ days, granularity, locale, }: AnalyticsTimeseriesProps): React.JSX.Element { const { t } = useTranslation('byline-admin') const [hovered, setHovered] = useState(null) const plot = useRef(null) const buckets = useMemo(() => bucketAnalyticsTimeseries(days, granularity), [days, granularity]) const columns = useMemo(() => buildAnalyticsColumns(buckets), [buckets]) const numbers = useMemo(() => new Intl.NumberFormat(locale), [locale]) // Stored days are UTC calendar days. Formatting in the viewer's zone would // shift the labels by up to one day. const dayLabel = useMemo( () => new Intl.DateTimeFormat(locale, { month: 'short', day: 'numeric', timeZone: 'UTC' }), [locale] ) const formatDay = (day: string) => dayLabel.format(new Date(`${day}T00:00:00.000Z`)) const formatBucket = (bucket: AnalyticsChartBucket) => bucket.dayCount === 1 ? formatDay(bucket.from) : `${formatDay(bucket.from)} – ${formatDay(bucket.to)}` const visitorLabel = (bucket: AnalyticsChartBucket) => bucket.granularity === 'day' ? t('analytics.stats.dailyUniques') : t('analytics.stats.summedDailyUniques') const active = hovered == null ? undefined : buckets[hovered] const activePlacement = hovered == null || columns[hovered] == null ? undefined : resolveHoverCardPlacement(columns[hovered]) // The hover card stays mounted so it can transition in and out — an element // that mounts already-visible has no previous state to animate from, which // is why a transition alone would still pop. Retaining the last hovered // bucket keeps its figures on screen through the fade-out, rather than // blanking the card the instant the pointer leaves. const lastShown = useRef<{ bucket: AnalyticsChartBucket placement: AnalyticsHoverCardPlacement } | null>(null) if (active != null && activePlacement != null) { lastShown.current = { bucket: active, placement: activePlacement } } const shown = lastShown.current const readBucket = (bucket: AnalyticsChartBucket) => [ formatBucket(bucket), `${t('analytics.stats.views')} ${numbers.format(bucket.views)}`, `${visitorLabel(bucket)} ${numbers.format(bucket.visitors)}`, `${t('analytics.stats.downloads')} ${numbers.format(bucket.downloads)}`, ].join(', ') // One handler on the plot rather than one per column keeps every reporting // range to a single pointer listener and one keyboard tab stop. const trackPointer = (event: React.PointerEvent) => { if (buckets.length === 0 || plot.current == null) return const bounds = plot.current.getBoundingClientRect() if (bounds.width === 0) return const ratio = (event.clientX - bounds.left) / bounds.width setHovered(Math.min(buckets.length - 1, Math.max(0, Math.floor(ratio * buckets.length)))) } const trackKey = (event: React.KeyboardEvent) => { if (buckets.length === 0) return const step = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0 if (step === 0 && event.key !== 'Home' && event.key !== 'End' && event.key !== 'Escape') return event.preventDefault() if (event.key === 'Escape') return setHovered(null) if (event.key === 'Home') return setHovered(0) if (event.key === 'End') return setHovered(buckets.length - 1) setHovered((current) => { const next = (current ?? (step > 0 ? -1 : buckets.length)) + step return Math.min(buckets.length - 1, Math.max(0, next)) }) } return (
setHovered(null)} onKeyDown={trackKey} onBlur={() => setHovered(null)} > {/* The plot and its hover card share a positioned box, so the card can be placed against the plot's own edges rather than the whole block (which also carries the axis and the readout). */}
{/* Sits over the hovered column. `aria-hidden` because the slider's `aria-valuetext` already reads the same figures to assistive technology — the card is the sighted equivalent of that readout, not a second announcement. */} {buckets.length > 0 && ( )}
{/* HTML axis labels remain undistorted while the SVG stretches. */}

{active == null ? ( t('analytics.chart.hint') ) : ( <> {formatBucket(active)} {t('analytics.stats.views')} {numbers.format(active.views)} {visitorLabel(active)} {numbers.format(active.visitors)} {t('analytics.stats.downloads')} {numbers.format(active.downloads)} )}

) } function bucketBySevenDays(days: readonly AnalyticsSummaryDay[]): AnalyticsChartBucket[] { const buckets: AnalyticsChartBucket[] = [] for (let index = 0; index < days.length; index += 7) { const rows = days.slice(index, index + 7) const first = rows[0] const last = rows[rows.length - 1] if (first != null && last != null) { buckets.push(combineRows(first.day, last.day, 'seven-day', rows)) } } return buckets } function bucketByUtcMonth(days: readonly AnalyticsSummaryDay[]): AnalyticsChartBucket[] { const buckets: AnalyticsChartBucket[] = [] let rows: AnalyticsSummaryDay[] = [] let month: string | undefined const flush = () => { const first = rows[0] const last = rows[rows.length - 1] if (first != null && last != null) { buckets.push(combineRows(first.day, last.day, 'month', rows)) } rows = [] } for (const day of days) { const nextMonth = day.day.slice(0, 7) if (month != null && nextMonth !== month) flush() month = nextMonth rows.push(day) } flush() return buckets } function combineRows( from: string, to: string, granularity: Exclude, rows: readonly AnalyticsSummaryDay[] ): AnalyticsChartBucket { let views = 0 let visitors = 0 let downloads = 0 for (const row of rows) { views += row.views visitors += row.visitors downloads += row.downloads } return { from, to, granularity, dayCount: rows.length, views, visitors, downloads } }