diff --git a/assets/js/dashboard/components/graph-tooltip.tsx b/assets/js/dashboard/components/graph-tooltip.tsx new file mode 100644 index 000000000000..fffabcc24f09 --- /dev/null +++ b/assets/js/dashboard/components/graph-tooltip.tsx @@ -0,0 +1,65 @@ +import React, { ReactNode, useLayoutEffect, useRef, useState } from 'react' +import { Transition } from '@headlessui/react' + +export const GraphTooltipWrapper = ({ + x, + y, + maxX, + minWidth, + children, + className, + onClick, + isTouchDevice +}: { + x: number + y: number + maxX: number + minWidth: number + children: ReactNode + className?: string + onClick?: () => void + isTouchDevice?: boolean +}) => { + const ref = useRef(null) + // bigger on mobile to have room between thumb and tooltip + const xOffsetFromCursor = isTouchDevice ? 24 : 12 + const yOffsetFromCursor = isTouchDevice ? 48 : 24 + const [measuredWidth, setMeasuredWidth] = useState(minWidth) + // center tooltip above the cursor, clamped to prevent left/right overflow + const rawLeft = x + xOffsetFromCursor + const tooltipLeft = Math.max(0, Math.min(rawLeft, maxX - measuredWidth)) + + useLayoutEffect(() => { + if (!ref.current) { + return + } + setMeasuredWidth(ref.current.offsetWidth) + }, [children, className, minWidth]) + + return ( + +
+ {children} +
+
+ ) +} diff --git a/assets/js/dashboard/components/graph.test.ts b/assets/js/dashboard/components/graph.test.ts new file mode 100644 index 000000000000..9ae265373b44 --- /dev/null +++ b/assets/js/dashboard/components/graph.test.ts @@ -0,0 +1,106 @@ +import { getSuggestedXTickValues, getXDomain } from './graph' +import * as d3 from 'd3' + +describe(`${getXDomain.name}`, () => { + it('returns [0, 1] for a single bucket to avoid a zero-width domain', () => { + expect(getXDomain(1)).toEqual([0, 1]) + }) + it('returns [0, bucketCount - 1] for multiple buckets', () => { + expect(getXDomain(5)).toEqual([0, 4]) + }) +}) + +const anyRange = [0, 100] +describe(`${getSuggestedXTickValues.name}`, () => { + it('handles 1 bucket', () => { + const data = new Array(1).fill(0) + expect( + getSuggestedXTickValues( + d3.scaleLinear(getXDomain(data.length), anyRange), + data.length + ) + ).toEqual([[0, 1]]) + }) + + it('handles 2 buckets', () => { + const data = new Array(2).fill(0) + expect( + getSuggestedXTickValues( + d3.scaleLinear(getXDomain(data.length), anyRange), + data.length + ) + ).toEqual([[0, 1]]) + }) + + it('handles 7 buckets', () => { + const data = new Array(7).fill(0) + expect( + getSuggestedXTickValues( + d3.scaleLinear(getXDomain(data.length), anyRange), + data.length + ) + ).toEqual([ + [0, 1, 2, 3, 4, 5, 6], + [0, 2, 4, 6], + [0, 5] + ]) + }) + + it('handles 24 buckets (day by hours)', () => { + const data = new Array(24).fill(0) + expect( + getSuggestedXTickValues( + d3.scaleLinear(getXDomain(data.length), anyRange), + data.length + ) + ).toEqual([ + [0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22], + [0, 5, 10, 15, 20], + [0, 10, 20], + [0, 20] + ]) + }) + + it('handles 28 buckets', () => { + const data = new Array(28).fill(0) + expect( + getSuggestedXTickValues( + d3.scaleLinear(getXDomain(data.length), anyRange), + data.length + ) + ).toEqual([ + [0, 5, 10, 15, 20, 25], + [0, 10, 20], + [0, 20] + ]) + }) + + it('handles 91 buckets', () => { + const data = new Array(91).fill(0) + expect( + getSuggestedXTickValues( + d3.scaleLinear(getXDomain(data.length), anyRange), + data.length + ) + ).toEqual([ + [0, 10, 20, 30, 40, 50, 60, 70, 80, 90], + [0, 20, 40, 60, 80], + [0, 50], + [0] + ]) + }) + + it('handles 700 buckets', () => { + const data = new Array(700).fill(0) + expect( + getSuggestedXTickValues( + d3.scaleLinear(getXDomain(data.length), anyRange), + data.length + ) + ).toEqual([ + [0, 100, 200, 300, 400, 500, 600], + [0, 200, 400, 600], + [0, 500] + ]) + }) +}) diff --git a/assets/js/dashboard/components/graph.tsx b/assets/js/dashboard/components/graph.tsx new file mode 100644 index 000000000000..d541d38aeb67 --- /dev/null +++ b/assets/js/dashboard/components/graph.tsx @@ -0,0 +1,722 @@ +import React, { ReactNode, useEffect, useRef } from 'react' +import * as d3 from 'd3' +import classNames from 'classnames' + +const IDEAL_Y_TICK_COUNT = 5 +const MAX_X_TICK_COUNT = 8 + +/** + * To ensure the effect to redraw the chart only runs when needed, + * make sure these props don't change on every render of the parent. + */ +type GraphProps< + T extends ReadonlyArray, + U = { [K in keyof T]: SeriesConfig } +> = { + className: string + width: number + height: number + /** pixels off the chart area that data is still hovered */ + hoverBuffer: number + marginTop: number + marginRight: number + marginBottom: number + /** initial guess for left margin, automatically enlarged to fit y tick texts */ + defaultMarginLeft: number + data: Datum[] + yMax: number + onPointerMove: PointerHandler + onPointerLeave: () => void + onClick?: () => void + yFormat: (domainValue: d3.NumberValue, index: number) => string + settings: U + gradients: { + id: string + stopTop: { color: string; opacity: number } + stopBottom: { color: string; opacity: number } + }[] + children?: ReactNode +} + +export function Graph>({ + children, + ...rest +}: GraphProps) { + const { height, width } = rest + return ( +
+ + {children} +
+ ) +} + +function InnerGraph>({ + className, + width, + height, + hoverBuffer, + marginBottom, + marginTop, + defaultMarginLeft, + marginRight, + data, + yMax, + onPointerMove, + onPointerLeave, + onClick, + yFormat, + settings, + gradients +}: GraphProps) { + const svgRef = useRef(null) + // Effect to fully redraw chart from scratch + useEffect(() => { + if (!svgRef.current) { + return + } + const svgBoundingClientRect = svgRef.current.getBoundingClientRect() + const minClientX = svgBoundingClientRect.left + const maxClientX = svgBoundingClientRect.right + + let marginLeft = defaultMarginLeft + let chartAreaWidth = getChartAreaWidth({ + width, + marginLeft, + marginRight + }) + + // Declare the y (vertical position) scale. + const { + scale: y, + yBottomEdge, + yTopEdge + } = getYScale({ yMax, height, marginTop, marginBottom }) + const optimalYTickValues = getOptimalYTickValues(y, yMax) + + const svg = d3.select(svgRef.current) + + // Hide svg until ready + svg.attr('opacity', 0) + ;({ marginLeft, chartAreaWidth } = fitYAxis({ + buildAxis: (marginLeft, chartAreaWidth) => + svg + .append('g') + .attr('opacity', 0) + .attr('class', 'y-axis--container') + .attr('transform', `translate(${marginLeft}, 0)`) + .call( + d3 + .axisLeft(y) + .tickFormat(yFormat) + .tickSize(0) + .tickValues(optimalYTickValues) + ) + .call((g) => g.select('.domain').remove()) + .call((g) => g.selectAll('.tick').attr('class', 'tick group')) + .call((g) => g.selectAll('.tick text').attr('class', tickTextClass)) + .call((g) => + g + .selectAll('.tick line') + .clone() + .attr('x2', chartAreaWidth) + .attr('class', yTickLineClass) + ), + marginLeft, + chartAreaWidth, + width, + marginRight, + minClientX + })) + const bucketCount = data.length + const { + scale: x, + xLeftEdge, + xRightEdge + } = getXScale({ + domain: getXDomain(bucketCount), + width, + marginLeft, + marginRight + }) + const suggestedXTickValues = getSuggestedXTickValues(x, bucketCount) + + // Add the x-axis + const xAxisSelection = svg + .append('g') + .attr('class', 'x-axis--container') + .attr('transform', `translate(0,${yBottomEdge})`) + + fitXAxis({ + xAxisSelection, + buildAxis: (xTickValues) => + xAxisSelection + .append('g') + .attr('class', 'x-axis') + .attr('opacity', 0) + .call( + d3 + .axisBottom(x) + .tickValues(xTickValues) + .tickSize(4) + .tickFormat(getXTickFormat(data)) + ) + .call((g) => g.select('.domain').remove()) + .call((g) => g.selectAll('.tick').attr('class', 'tick group')) + .call((g) => + g.selectAll('.tick line').attr('class', classNames(xTickLineClass)) + ) + .call((g) => + g + .selectAll('.tick text') + .attr('class', classNames(tickTextClass, 'translate-y-2')) + ), + suggestedXTickValues, + minClientX, + maxClientX + }) + + for (const gradient of gradients) { + addGradient({ + svg, + id: gradient.id, + stopTop: gradient.stopTop, + stopBottom: gradient.stopBottom + }) + } + + const points: Point[] = data.map((d, index) => { + const xValue = x(index) + const yValues: T = d.values.map((v) => + v !== null ? y(v) : null + ) as unknown as T + const dots = drawDots({ svg, settings, x: xValue, yValues }) + return { + x: xValue, + values: yValues, + dots + } + }) + + for (const [seriesIndex, series] of settings.entries()) { + if (series.lines) { + for (const line of series.lines) { + drawLine({ + svg, + datum: data, + isDefined: (d, i) => { + const valueDefined = d.values[seriesIndex] !== null + const atOrOverStart = + line.startIndexInclusive !== undefined + ? i >= line.startIndexInclusive + : true + const beforeEnd = + line.stopIndexExclusive !== undefined + ? i < line.stopIndexExclusive + : true + return valueDefined && atOrOverStart && beforeEnd + }, + xAccessor: (_d, index) => x(index), + yAccessor: (d) => y(d.values[seriesIndex]!), + className: line.lineClassName + }) + } + } + + if (series.underline) { + drawAreaUnderLine({ + svg, + gradientId: series.underline.gradientId, + datum: data, + isDefined: (d) => d.values[seriesIndex] !== null, + xAccessor: (_d, index) => x(index), + y0Accessor: yBottomEdge, + y1Accessor: (d) => y(d.values[seriesIndex]!) + }) + } + } + + const getPosition = ( + event: unknown + ): { xPointer: number; yPointer: number; inHoverableArea: boolean } => { + const [[xPointer, yPointer]] = d3.pointers(event) + + const inHoverableArea = + xPointer >= xLeftEdge - hoverBuffer && + xPointer <= xRightEdge + hoverBuffer && + yPointer >= yTopEdge - hoverBuffer && + // chart is interactive even over x-axis labels + yPointer <= height + return { xPointer, yPointer, inHoverableArea } + } + const getClosestIndexToPointer = (xPointer: number): number => + d3.bisector(({ x }: Point) => x).center(points, xPointer) + + const handleDotsForClosestIndex = (closestIndexToPointer: number | null) => + points.forEach(({ dots }, index) => + dots.attr( + 'data-active', + closestIndexToPointer !== null && index === closestIndexToPointer + ? '' + : null + ) + ) + + svg + .on( + 'pointermove', + (event) => { + const { xPointer, yPointer, inHoverableArea } = getPosition(event) + const closestIndexToPointer = inHoverableArea + ? getClosestIndexToPointer(xPointer) + : null + handleDotsForClosestIndex(closestIndexToPointer) + onPointerMove({ + inHoverableArea: true, + closestIndex: closestIndexToPointer, + x: xPointer, + y: yPointer, + event + }) + }, + { passive: true } + ) + .on( + 'lostpointercapture pointerleave', + () => { + handleDotsForClosestIndex(null) + onPointerLeave() + }, + { passive: true } + ) + + // Unhide chart + svg.attr('opacity', 1) + + return () => { + svg.selectAll('*').remove() + } + }, [ + data, + gradients, + onPointerLeave, + onPointerMove, + settings, + width, + height, + marginBottom, + marginTop, + defaultMarginLeft, + marginRight, + hoverBuffer, + yFormat, + yMax + ]) + + return ( + { + if (e.pointerType === 'touch' && typeof onClick === 'function') { + onClick() + } + }} + ref={svgRef} + viewBox={`0 0 ${width} ${height}`} + className={classNames('w-full h-auto [touch-action:pan-y]', className)} + /> + ) +} + +const yTickLineClass = + 'stroke-gray-150 dark:stroke-gray-800/75 group-first:stroke-gray-300 dark:group-first:stroke-gray-700' +const tickTextClass = 'fill-gray-500 dark:fill-gray-400 text-xs select-none' +const xTickLineClass = 'stroke-gray-300 dark:stroke-gray-700' + +export const getXDomain = (bucketCount: number): [number, number] => { + const xMin = 0 + const xMax = Math.max(bucketCount - 1, 1) + return [xMin, xMax] +} + +const getXScale = ({ + domain, + width, + marginLeft, + marginRight +}: { + domain: [number, number] + width: number + marginLeft: number + marginRight: number +}) => { + const xLeftEdge = marginLeft + const xRightEdge = width - marginRight + const scale = d3.scaleLinear(domain, [xLeftEdge, xRightEdge]) + return { scale, xLeftEdge, xRightEdge } +} + +const getYScale = ({ + yMax, + height, + marginTop, + marginBottom +}: { + yMax: number + height: number + marginTop: number + marginBottom: number +}) => { + const yBottomEdge = height - marginBottom + const yTopEdge = marginTop + const scale = d3 + .scaleLinear([0, yMax], [yBottomEdge, yTopEdge]) + .nice(IDEAL_Y_TICK_COUNT) + return { scale, yBottomEdge, yTopEdge } +} + +const fitXAxis = ({ + xAxisSelection, + buildAxis, + suggestedXTickValues, + minClientX, + maxClientX +}: { + xAxisSelection: d3.Selection + buildAxis: ( + xTickValues: number[] + ) => d3.Selection + suggestedXTickValues: number[][] + minClientX: number + maxClientX: number +}) => { + for (const [index, xTickValues] of suggestedXTickValues.entries()) { + const isLastAttempt = index === suggestedXTickValues.length - 1 + const axis = buildAxis(xTickValues) + + let overlapCount = 0 + let lastTickTextRightEdge = 0 + axis.call((g) => + g.selectAll('.tick text').each(function (_, i, groups) { + const { isOverlappingPrevious, rightEdge } = handleXTickText({ + elem: this as SVGGraphicsElement, + position: + i === 0 ? 'first' : i === groups.length - 1 ? 'last' : 'neither', + minClientX, + maxClientX, + lastTickTextRightEdge + }) + if (isOverlappingPrevious) { + overlapCount++ + } + lastTickTextRightEdge = rightEdge + }) + ) + + if (overlapCount > 0 && !isLastAttempt) { + axis.remove() + } else { + break + } + } + xAxisSelection.call((g) => g.select('.x-axis').attr('opacity', 1)) +} + +const fitYAxis = ({ + buildAxis, + marginLeft: initialMarginLeft, + chartAreaWidth: initialChartAreaWidth, + width, + marginRight, + minClientX +}: { + buildAxis: ( + marginLeft: number, + chartAreaWidth: number + ) => d3.Selection + marginLeft: number + chartAreaWidth: number + width: number + marginRight: number + minClientX: number +}): { marginLeft: number; chartAreaWidth: number } => { + const maxAttempts = 2 + let marginLeft = initialMarginLeft + let chartAreaWidth = initialChartAreaWidth + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + let leftMostYTickText: number | null = null + + const yAxis = buildAxis(marginLeft, chartAreaWidth).call((g) => + g.selectAll('.tick').each(function () { + const rect = (this as SVGGraphicsElement).getBoundingClientRect() + if (leftMostYTickText === null || rect.left < leftMostYTickText) + leftMostYTickText = rect.left + }) + ) + + if (leftMostYTickText !== null) { + const isLastAttempt = attempt === maxAttempts + const textOffset = leftMostYTickText - minClientX + if (textOffset < 0 && !isLastAttempt) { + yAxis.remove() + marginLeft += Math.ceil(-textOffset / 4) * 4 + chartAreaWidth = getChartAreaWidth({ width, marginLeft, marginRight }) + continue + } + yAxis.attr('opacity', null) + } + break + } + + return { marginLeft, chartAreaWidth } +} + +export const getSuggestedXTickValues = ( + scale: d3.ScaleLinear, + bucketCount: number +): number[][] => { + const maxXTicks = Math.min(bucketCount, MAX_X_TICK_COUNT) + const minTicks = 1 + const result = new Set() + for (let tickCount = maxXTicks; tickCount >= minTicks; tickCount--) { + const tickValues = scale.ticks(tickCount) + if (tickValues.every(isWholeNumber)) { + // needs serialization to be comparable for uniqueness in Set + const serializedArray = JSON.stringify(tickValues) + result.add(serializedArray) + } + } + + return [...result].map((serializedArray) => JSON.parse(serializedArray)) +} + +const areIdealYTickValues = (tickValues: number[], yMax: number) => + Math.max(...tickValues) >= yMax && tickValues.every(isWholeNumber) + +const getOptimalYTickValues = ( + scale: d3.ScaleLinear, + yMax: number +) => { + const maxYTicks = IDEAL_Y_TICK_COUNT + const minTicks = 1 + const suggested: number[][] = [] + for (let tickCount = maxYTicks; tickCount >= minTicks; tickCount--) { + const tickValues = scale.ticks(tickCount) + suggested.push(tickValues) + } + return ( + suggested.find((tickValues) => areIdealYTickValues(tickValues, yMax)) ?? + suggested[0] + ) +} + +const handleXTickText = ({ + elem, + position, + minClientX, + maxClientX, + lastTickTextRightEdge +}: { + elem: SVGGraphicsElement + position: 'first' | 'last' | 'neither' + minClientX: number + maxClientX: number + lastTickTextRightEdge: number +}): { isOverlappingPrevious: boolean; rightEdge: number } => { + let textRect = elem.getBoundingClientRect() + + if (position === 'first') { + const distanceFromAxisEdge = textRect.left - minClientX + if (distanceFromAxisEdge < 0) { + d3.select(elem).attr('dx', -distanceFromAxisEdge) + textRect = elem.getBoundingClientRect() + } + } + + if (position === 'last') { + const distanceFromAxisEdge = maxClientX - textRect.right + if (distanceFromAxisEdge < 0) { + d3.select(elem).attr('dx', distanceFromAxisEdge) + textRect = elem.getBoundingClientRect() + } + } + + return { + isOverlappingPrevious: textRect.left < lastTickTextRightEdge, + rightEdge: textRect.right + } +} + +const getXTickFormat = + (data: T[]) => + (bucketIndex: d3.NumberValue) => { + // for low tick counts, it may try to render ticks + // with the value 0.5, 1.5, etc that don't have data defined + const datum = data[bucketIndex.valueOf()] + if (!datum) return '' + return datum.xLabel + } + +const getChartAreaWidth = ({ + width, + marginLeft, + marginRight +}: { + width: number + marginLeft: number + marginRight: number +}) => width - marginLeft - marginRight + +const addGradient = ({ + svg, + id, + stopTop, + stopBottom +}: { + svg: SelectedSVG + id: string + stopTop: { color: string; opacity: number } + stopBottom: { color: string; opacity: number } +}): void => { + const grad = svg + .append('defs') + .append('linearGradient') + .attr('id', id) + .attr('x1', '0%') + .attr('y1', '0%') // top + .attr('x2', '0%') + .attr('y2', `100%`) // bottom + + grad + .append('stop') + .attr('offset', '0%') + .attr('stop-color', stopTop.color) + .attr('stop-opacity', stopTop.opacity) + + grad + .append('stop') + .attr('offset', '100%') + .attr('stop-color', stopBottom.color) + .attr('stop-opacity', stopBottom.opacity) +} + +function drawAreaUnderLine>({ + svg, + gradientId, + isDefined, + xAccessor, + y0Accessor, + y1Accessor, + datum +}: { + svg: SelectedSVG + gradientId: string + isDefined: (d: Datum, index: number) => boolean + xAccessor: (d: Datum, index: number) => number + y0Accessor: number + y1Accessor: (d: Datum, index: number) => number + datum: Datum[] +}) { + const area = d3 + .area>() + .x(xAccessor) + .defined(isDefined) + .y0(y0Accessor) // bottom edge + .y1(y1Accessor) // top edge follows the data + + // draw the filled area with the gradient + svg + .append('path') + .datum(datum) + .attr('fill', `url(#${gradientId})`) + .attr('d', area) +} + +function drawLine>({ + svg, + datum, + isDefined, + xAccessor, + yAccessor, + className +}: { + svg: SelectedSVG + datum: Datum[] + isDefined: (d: Datum, index: number) => boolean + xAccessor: (d: Datum, index: number) => number + yAccessor: (d: Datum, index: number) => number + className?: string +}) { + const line = d3.line>().defined(isDefined).x(xAccessor).y(yAccessor) + + svg + .append('path') + .attr('class', classNames(className)) + .datum(datum) + .attr('d', line) +} + +function drawDots>({ + svg, + settings, + x, + yValues +}: { + svg: SelectedSVG + settings: { [K in keyof T]: SeriesConfig } + x: number + yValues: T +}): SelectedDots { + const dotsForX = svg.append('g').attr('class', 'group') + for (const [seriesIndex, series] of settings.entries()) { + if (series.dot && yValues[seriesIndex] !== null) { + dotsForX + .append('circle') + .attr('r', 2.5) + .attr('class', series.dot.dotClassName) + .attr('transform', `translate(${x},${yValues[seriesIndex]})`) + } + } + + return dotsForX +} + +const isWholeNumber = (v: number) => v % 1 === 0 + +export type Datum> = { + values: T + xLabel: string +} + +type XPos = number +type Point> = { + x: XPos + values: T + dots: SelectedDots +} + +export type SeriesConfig = { + /** a single series can be drawn with multiple lines, like a solid line for some parts and a dashed line for other parts */ + lines?: { + lineClassName: string + startIndexInclusive?: number + stopIndexExclusive?: number + }[] + underline?: { gradientId: string } + dot?: { dotClassName: string } +} + +export type PointerHandler = (opts: { + inHoverableArea: boolean + x: number + y: number + closestIndex: number | null + event: unknown +}) => void + +type SelectedSVG = d3.Selection +type SelectedDots = d3.Selection diff --git a/assets/js/dashboard/dashboard-time-periods.ts b/assets/js/dashboard/dashboard-time-periods.ts index 340cb5a535e0..308329931303 100644 --- a/assets/js/dashboard/dashboard-time-periods.ts +++ b/assets/js/dashboard/dashboard-time-periods.ts @@ -633,7 +633,10 @@ export function getCurrentPeriodDisplayName({ if (isToday(site, dashboardState.date)) { return 'Today' } - return formatDay(dashboardState.date) + return formatDay( + dashboardState.date, + !isThisYear(site, dashboardState.date) + ) } if (dashboardState.period === '24h') { diff --git a/assets/js/dashboard/stats-query.ts b/assets/js/dashboard/stats-query.ts index 1378bb8933f8..e86c3f859a6e 100644 --- a/assets/js/dashboard/stats-query.ts +++ b/assets/js/dashboard/stats-query.ts @@ -15,8 +15,11 @@ type QueryInclude = { imports: boolean imports_meta: boolean time_labels: boolean + partial_time_labels: boolean compare: IncludeCompare compare_match_day_of_week: boolean + present_index?: boolean + empty_metrics?: boolean } export type ReportParams = { @@ -48,8 +51,10 @@ export function createStatsQuery( imports: dashboardState.with_imported, imports_meta: reportParams.include?.imports_meta || false, time_labels: reportParams.include?.time_labels || false, + partial_time_labels: reportParams.include?.partial_time_labels || false, compare: createIncludeCompare(dashboardState), - compare_match_day_of_week: dashboardState.match_day_of_week + compare_match_day_of_week: dashboardState.match_day_of_week, + empty_metrics: reportParams.include?.empty_metrics || false } } } diff --git a/assets/js/dashboard/stats/graph/date-formatter.js b/assets/js/dashboard/stats/graph/date-formatter.js deleted file mode 100644 index caf646111977..000000000000 --- a/assets/js/dashboard/stats/graph/date-formatter.js +++ /dev/null @@ -1,122 +0,0 @@ -import { parseUTCDate, formatMonthYYYY, formatDayShort } from '../../util/date' - -const browserDateFormat = Intl.DateTimeFormat(navigator.language, { - hour: 'numeric' -}) - -const is12HourClock = function () { - return browserDateFormat.resolvedOptions().hour12 -} - -const monthIntervalFormatter = { - long(isoDate, options) { - const formatted = this.short(isoDate, options) - return options.isBucketPartial ? `Partial of ${formatted}` : formatted - }, - short(isoDate, _options) { - return formatMonthYYYY(parseUTCDate(isoDate)) - } -} - -const weekIntervalFormatter = { - long(isoDate, options) { - const formatted = this.short(isoDate, options) - return options.isBucketPartial - ? `Partial week of ${formatted}` - : `Week of ${formatted}` - }, - short(isoDate, options) { - return formatDayShort(parseUTCDate(isoDate), options.shouldShowYear) - } -} - -const dayIntervalFormatter = { - long(isoDate, _options) { - return parseUTCDate(isoDate).format('ddd, D MMM') - }, - short(isoDate, options) { - return formatDayShort(parseUTCDate(isoDate), options.shouldShowYear) - } -} - -const hourIntervalFormatter = { - long(isoDate, options) { - return this.short(isoDate, options) - }, - short(isoDate, _options) { - if (is12HourClock()) { - return parseUTCDate(isoDate).format('ha') - } else { - return parseUTCDate(isoDate).format('HH:mm') - } - } -} - -const minuteIntervalFormatter = { - long(isoDate, options) { - if (options.period == 'realtime') { - const minutesAgo = Math.abs(isoDate) - return minutesAgo === 1 ? '1 minute ago' : minutesAgo + ' minutes ago' - } else { - return this.short(isoDate, options) - } - }, - short(isoDate, options) { - if (options.period === 'realtime') return isoDate + 'm' - - if (is12HourClock()) { - return parseUTCDate(isoDate).format('h:mma') - } else { - return parseUTCDate(isoDate).format('HH:mm') - } - } -} - -// Each interval has a different date and time format. This object maps each -// interval with two functions: `long` and `short`, that formats date and time -// accordingly. -const factory = { - month: monthIntervalFormatter, - week: weekIntervalFormatter, - day: dayIntervalFormatter, - hour: hourIntervalFormatter, - minute: minuteIntervalFormatter -} - -/** - * Returns a function that formats a ISO 8601 timestamp based on the given - * arguments. - * - * The preferred date and time format in the dashboard depends on the selected - * interval and period. For example, in real-time view only the time is necessary, - * while other intervals require dates to be displayed. - * @param {Object} config - Configuration object for determining formatter. - * - * @param {string} config.interval - The interval of the dashboardState, e.g. `minute`, `hour` - * @param {boolean} config.longForm - Whether the formatted result should be in long or - * short form. - * @param {string} config.period - The `DashboardPeriod`, e.g. `12mo`, `day` - * @param {boolean} config.isPeriodFull - Indicates whether the interval has been cut - * off by the requested date range or not. If false, the returned formatted date - * indicates this cut off, e.g. `Partial week of November 8`. - * @param {boolean} config.shouldShowYear - Should the year be appended to the date? - * Defaults to false. Rendering year string is a newer opt-in feature to be enabled where needed. - */ -export default function dateFormatter({ - interval, - longForm, - period, - isPeriodFull, - shouldShowYear = false -}) { - const displayMode = longForm ? 'long' : 'short' - const options = { - period: period, - interval: interval, - isBucketPartial: !isPeriodFull, - shouldShowYear - } - return function (isoDate, _index, _ticks) { - return factory[interval][displayMode](isoDate, options) - } -} diff --git a/assets/js/dashboard/stats/graph/fetch-main-graph.ts b/assets/js/dashboard/stats/graph/fetch-main-graph.ts new file mode 100644 index 000000000000..98a9ef55fb86 --- /dev/null +++ b/assets/js/dashboard/stats/graph/fetch-main-graph.ts @@ -0,0 +1,74 @@ +import { Metric } from '../../../types/query-api' +import { DashboardState } from '../../dashboard-state' +import { DashboardPeriod } from '../../dashboard-time-periods' +import { PlausibleSite } from '../../site-context' +import { createStatsQuery, ReportParams } from '../../stats-query' +import { isRealTimeDashboard } from '../../util/filters' +import * as api from '../../api' + +export function fetchMainGraph( + site: PlausibleSite, + dashboardState: DashboardState, + metric: Metric, + interval: string +): Promise { + const metricToQuery = + metric === 'conversion_rate' ? 'group_conversion_rate' : metric + + const reportParams: ReportParams = { + metrics: [metricToQuery], + dimensions: [`time:${interval}`], + include: { + time_labels: true, + partial_time_labels: true, + empty_metrics: true + } + } + + const statsQuery = createStatsQuery(dashboardState, reportParams) + + if (isRealTimeDashboard(dashboardState)) { + statsQuery.date_range = DashboardPeriod.realtime_30m + } + + return api.stats(site, statsQuery) +} + +export type RevenueMetricValue = { + short: string + value: number + long: string + currency: string +} + +export type ResultItem = { + dimensions: [string] // one item + metrics: MetricValues +} + +export type MetricValue = null | number | RevenueMetricValue + +export type MetricValues = [MetricValue] // one item + +export type MainGraphResponse = { + results: Array + comparison_results: Array< + (ResultItem & { change: [number | null] | null }) | null + > + meta: { + partial_time_labels: string[] | null + comparison_partial_time_labels: string[] | null + time_labels: string[] + time_label_result_indices: (number | null)[] + comparison_time_labels?: string[] + comparison_time_label_result_indices?: (number | null)[] + empty_metrics: MetricValues + } + query: { + interval: string + date_range: [string, string] + comparison_date_range?: [string, string] + dimensions: [string] // one item + metrics: [string] // one item + } +} diff --git a/assets/js/dashboard/stats/graph/fetch-top-stats.test.ts b/assets/js/dashboard/stats/graph/fetch-top-stats.test.ts index 7cc8584b81b1..a560338a46b0 100644 --- a/assets/js/dashboard/stats/graph/fetch-top-stats.test.ts +++ b/assets/js/dashboard/stats/graph/fetch-top-stats.test.ts @@ -19,7 +19,9 @@ const expectedBaseInclude: StatsQuery['include'] = { compare_match_day_of_week: true, imports: true, imports_meta: true, - time_labels: false + time_labels: false, + partial_time_labels: false, + empty_metrics: false } const expectedRealtimeVisitorsQuery: StatsQuery = { diff --git a/assets/js/dashboard/stats/graph/graph-tooltip.js b/assets/js/dashboard/stats/graph/graph-tooltip.js deleted file mode 100644 index ad439abc26e2..000000000000 --- a/assets/js/dashboard/stats/graph/graph-tooltip.js +++ /dev/null @@ -1,215 +0,0 @@ -import React from 'react' -import { createRoot } from 'react-dom/client' -import dateFormatter from './date-formatter' -import { METRIC_LABELS, hasMultipleYears } from './graph-util' -import { MetricFormatterShort } from '../reports/metric-formatter' -import { ChangeArrow } from '../reports/change-arrow' -import { UIMode } from '../../theme-context' - -const renderBucketLabel = function ( - dashboardState, - graphData, - label, - comparison = false -) { - let isPeriodFull = graphData.full_intervals?.[label] - if (comparison) isPeriodFull = true - - const shouldShowYear = hasMultipleYears(graphData) - - const formattedLabel = dateFormatter({ - interval: graphData.interval, - longForm: true, - period: dashboardState.period, - isPeriodFull, - shouldShowYear - })(label) - - if (dashboardState.period === 'realtime') { - return dateFormatter({ - interval: graphData.interval, - longForm: true, - period: dashboardState.period, - shouldShowYear - })(label) - } - - if (graphData.interval === 'hour' || graphData.interval == 'minute') { - const date = dateFormatter({ - interval: 'day', - longForm: true, - period: dashboardState.period, - shouldShowYear - })(label) - return `${date}, ${formattedLabel}` - } - - return formattedLabel -} - -const calculatePercentageDifference = function (oldValue, newValue) { - if (oldValue == 0 && newValue > 0) { - return 100 - } else if (oldValue == 0 && newValue == 0) { - return 0 - } else { - return Math.round(((newValue - oldValue) / oldValue) * 100) - } -} - -const buildTooltipData = function ( - dashboardState, - graphData, - metric, - tooltipModel -) { - const data = tooltipModel.dataPoints.find( - (dataPoint) => dataPoint.dataset.yAxisID == 'y' - ) - const comparisonData = tooltipModel.dataPoints.find( - (dataPoint) => dataPoint.dataset.yAxisID == 'yComparison' - ) - - const label = - data && - renderBucketLabel( - dashboardState, - graphData, - graphData.labels[data.dataIndex] - ) - const comparisonLabel = - comparisonData && - renderBucketLabel( - dashboardState, - graphData, - graphData.comparison_labels[comparisonData.dataIndex], - true - ) - - const value = data && graphData.plot?.[data.dataIndex] - - const formatter = MetricFormatterShort[metric] - const comparisonValue = - comparisonData && graphData.comparison_plot?.[comparisonData.dataIndex] - const comparisonDifference = - label && - comparisonData && - value && - calculatePercentageDifference(comparisonValue, value) - - const formattedValue = value && formatter(value) - const formattedComparisonValue = comparisonData && formatter(comparisonValue) - - return { - label, - formattedValue, - comparisonLabel, - formattedComparisonValue, - comparisonDifference - } -} - -let tooltipRoot - -export default function GraphTooltip(graphData, metric, dashboardState, theme) { - return (context) => { - const tooltipModel = context.tooltip - const offset = document - .getElementById('main-graph-canvas') - .getBoundingClientRect() - let tooltipEl = document.getElementById('chartjs-tooltip-main') - - if (!tooltipEl) { - tooltipEl = document.createElement('div') - tooltipEl.id = 'chartjs-tooltip-main' - tooltipEl.className = 'chartjs-tooltip' - tooltipEl.style.display = 'none' - tooltipEl.style.opacity = 0 - document.body.appendChild(tooltipEl) - tooltipRoot = createRoot(tooltipEl) - } - - const bgClass = theme.mode === UIMode.dark ? 'bg-gray-950' : 'bg-gray-800' - tooltipEl.className = `absolute text-sm font-normal py-3 px-4 pointer-events-none rounded-md z-[100] min-w-[180px] ${bgClass}` - - if (tooltipEl && offset && window.innerWidth < 768) { - tooltipEl.style.top = - offset.y + offset.height + window.scrollY + 15 + 'px' - tooltipEl.style.left = offset.x + 'px' - tooltipEl.style.right = null - tooltipEl.style.opacity = 1 - } - - if (tooltipModel.opacity === 0) { - tooltipEl.style.display = 'none' - return - } - - if (tooltipModel.body) { - const tooltipData = buildTooltipData( - dashboardState, - graphData, - metric, - tooltipModel - ) - - if (!tooltipData.label) { - tooltipEl.style.display = 'none' - return - } - - tooltipRoot.render( -
- - {METRIC_LABELS[metric]} - - {tooltipData.comparisonDifference ? ( -
- -
- ) : null} -
- -
-
- -
- {tooltipData.label} -
- {tooltipData.formattedValue} -
- - {tooltipData.comparisonLabel ? ( -
- -
- {tooltipData.comparisonLabel} -
- - {tooltipData.formattedComparisonValue} - -
- ) : null} -
- - {['month', 'day'].includes(graphData.interval) && ( - <> -
- - Click to view {graphData.interval} - - - )} - - ) - } - tooltipEl.style.display = null - } -} diff --git a/assets/js/dashboard/stats/graph/graph-util.js b/assets/js/dashboard/stats/graph/graph-util.js deleted file mode 100644 index ef66a153227c..000000000000 --- a/assets/js/dashboard/stats/graph/graph-util.js +++ /dev/null @@ -1,126 +0,0 @@ -export const METRIC_LABELS = { - visitors: 'Visitors', - pageviews: 'Pageviews', - events: 'Total conversions', - views_per_visit: 'Views per visit', - visits: 'Visits', - bounce_rate: 'Bounce rate', - visit_duration: 'Visit duration', - conversions: 'Converted visitors', - conversion_rate: 'Conversion rate', - average_revenue: 'Average revenue', - total_revenue: 'Total revenue', - scroll_depth: 'Scroll depth', - time_on_page: 'Time on page' -} - -function plottable(dataArray) { - return dataArray?.map((value) => { - if (typeof value === 'object' && value !== null) { - // Revenue metrics are returned as objects with a `value` property - return value.value - } - - return value || 0 - }) -} - -const buildComparisonDataset = function (comparisonPlot, presentIndex) { - if (!comparisonPlot) return [] - - const data = presentIndex - ? comparisonPlot.slice(0, presentIndex) - : comparisonPlot - - return [ - { - data: plottable(data), - borderColor: 'rgba(99, 102, 241, 0.3)', - pointBackgroundColor: 'rgba(99, 102, 241, 0.2)', - pointHoverBackgroundColor: 'rgba(99, 102, 241, 0.5)', - yAxisID: 'yComparison' - } - ] -} - -const buildDashedComparisonDataset = function (comparisonPlot, presentIndex) { - if (!comparisonPlot || !presentIndex) return [] - const dashedPart = comparisonPlot.slice(presentIndex - 1, presentIndex + 1) - const dashedPlot = new Array(presentIndex - 1).concat(dashedPart) - return [ - { - data: plottable(dashedPlot), - borderDash: [3, 3], - borderColor: 'rgba(99, 102, 241, 0.3)', - pointHoverBackgroundColor: 'rgba(99, 102, 241, 0.5)', - yAxisID: 'yComparison' - } - ] -} -const buildDashedDataset = function (plot, presentIndex) { - if (!presentIndex) return [] - const dashedPart = plot.slice(presentIndex - 1, presentIndex + 1) - const dashedPlot = new Array(presentIndex - 1).concat(dashedPart) - return [ - { - data: plottable(dashedPlot), - borderDash: [3, 3], - borderColor: 'rgb(99, 102, 241)', - pointHoverBackgroundColor: 'rgb(99, 102, 241)', - yAxisID: 'y' - } - ] -} -const buildMainPlotDataset = function (plot, presentIndex) { - const data = presentIndex ? plot.slice(0, presentIndex) : plot - return [ - { - data: plottable(data), - borderColor: 'rgb(99, 102, 241)', - pointBackgroundColor: 'rgb(99, 102, 241)', - pointHoverBackgroundColor: 'rgb(99, 102, 241)', - yAxisID: 'y' - } - ] -} -export const buildDataSet = ( - plot, - comparisonPlot, - present_index, - ctx, - label -) => { - var gradient = ctx.createLinearGradient(0, 0, 0, 300) - var prev_gradient = ctx.createLinearGradient(0, 0, 0, 300) - gradient.addColorStop(0, 'rgba(79, 70, 229, 0.15)') - gradient.addColorStop(1, 'rgba(79, 70, 229, 0)') - prev_gradient.addColorStop(0, 'rgba(79, 70, 229, 0.05)') - prev_gradient.addColorStop(1, 'rgba(79, 70, 229, 0)') - - const defaultOptions = { - label, - borderWidth: 2, - pointBorderColor: 'transparent', - pointHoverRadius: 3, - backgroundColor: gradient, - fill: true - } - - const dataset = [ - ...buildMainPlotDataset(plot, present_index), - ...buildDashedDataset(plot, present_index), - ...buildComparisonDataset(comparisonPlot, present_index), - ...buildDashedComparisonDataset(comparisonPlot, present_index) - ] - - return dataset.map((item) => Object.assign(item, defaultOptions)) -} - -export function hasMultipleYears(graphData) { - return ( - graphData.labels - .filter((date) => typeof date === 'string') - .map((date) => date.split('-')[0]) - .filter((value, index, list) => list.indexOf(value) === index).length > 1 - ) -} diff --git a/assets/js/dashboard/stats/graph/line-graph.js b/assets/js/dashboard/stats/graph/line-graph.js deleted file mode 100644 index 872d09641991..000000000000 --- a/assets/js/dashboard/stats/graph/line-graph.js +++ /dev/null @@ -1,300 +0,0 @@ -import React from 'react' -import { useAppNavigate } from '../../navigation/use-app-navigate' -import { useDashboardStateContext } from '../../dashboard-state-context' -import Chart from 'chart.js/auto' -import GraphTooltip from './graph-tooltip' -import { buildDataSet, METRIC_LABELS, hasMultipleYears } from './graph-util' -import dateFormatter from './date-formatter' -import classNames from 'classnames' -import { hasConversionGoalFilter } from '../../util/filters' -import { MetricFormatterShort } from '../reports/metric-formatter' -import { UIMode, useTheme } from '../../theme-context' -import { Transition } from '@headlessui/react' -import equal from 'fast-deep-equal' - -const calculateMaximumY = function (dataset) { - const yAxisValues = dataset - .flatMap((item) => item.data) - .map((item) => item || 0) - - if (yAxisValues) { - return Math.max(...yAxisValues) - } else { - return 1 - } -} - -class LineGraph extends React.Component { - constructor(props) { - super(props) - this.updateWindowDimensions = this.updateWindowDimensions.bind(this) - } - - getGraphMetric() { - let metric = this.props.graphData.metric - - if ( - metric == 'visitors' && - hasConversionGoalFilter(this.props.dashboardState) - ) { - return 'conversions' - } else { - return metric - } - } - - buildXTicksCallback() { - const { graphData, dashboardState } = this.props - const shouldShowYear = hasMultipleYears(graphData) - return function (val, _index, _ticks) { - if (this.getLabelForValue(val) == '__blank__') return '' - - if (graphData.interval === 'hour' && dashboardState.period !== 'day') { - const date = dateFormatter({ - interval: 'day', - longForm: false, - period: dashboardState.period, - shouldShowYear - })(this.getLabelForValue(val)) - - const hour = dateFormatter({ - interval: graphData.interval, - longForm: false, - period: dashboardState.period, - shouldShowYear - })(this.getLabelForValue(val)) - - return `${date}, ${hour}` - } - - if ( - graphData.interval === 'minute' && - dashboardState.period !== 'realtime' - ) { - return dateFormatter({ - interval: 'hour', - longForm: false, - period: dashboardState.period - })(this.getLabelForValue(val)) - } - - return dateFormatter({ - interval: graphData.interval, - longForm: false, - period: dashboardState.period, - shouldShowYear - })(this.getLabelForValue(val)) - } - } - - updateChart() { - const { graphData, dashboardState, theme } = this.props - const metric = this.getGraphMetric() - const dataSet = buildDataSet( - graphData.plot, - graphData.comparison_plot, - graphData.present_index, - this.ctx, - METRIC_LABELS[metric] - ) - - const maxY = calculateMaximumY(dataSet) - - this.chart.data.labels = graphData.labels - this.chart.data.datasets = dataSet - this.chart.options.scales.y.suggestedMax = maxY - this.chart.options.scales.yComparison.suggestedMax = maxY - this.chart.options.scales.y.ticks.callback = MetricFormatterShort[metric] - this.chart.options.scales.y.ticks.color = - theme.mode === UIMode.dark ? 'rgb(161, 161, 170)' : undefined - this.chart.options.scales.y.grid.color = - theme.mode === UIMode.dark - ? 'rgba(39, 39, 42, 0.75)' - : 'rgb(236, 236, 238)' - this.chart.options.scales.x.ticks.color = - theme.mode === UIMode.dark ? 'rgb(161, 161, 170)' : undefined - this.chart.options.scales.x.ticks.callback = this.buildXTicksCallback() - this.chart.options.plugins.tooltip.external = GraphTooltip( - graphData, - metric, - dashboardState, - theme - ) - - this.chart.update() - } - - regenerateChart() { - const graphEl = document.getElementById('main-graph-canvas') - this.ctx = graphEl.getContext('2d') - - this.chart = new Chart(this.ctx, { - type: 'line', - data: { labels: [], datasets: [] }, - options: { - animation: false, - plugins: { - legend: { display: false }, - tooltip: { - enabled: false, - mode: 'index', - intersect: false, - position: 'average', - external: () => {} - } - }, - responsive: true, - maintainAspectRatio: false, - onResize: this.updateWindowDimensions, - elements: { line: { tension: 0 }, point: { radius: 0 } }, - onClick: this.maybeHopToHoveredPeriod.bind(this), - scale: { - ticks: { precision: 0, maxTicksLimit: 8 } - }, - scales: { - y: { - min: 0, - ticks: {}, - grid: { zeroLineColor: 'transparent', drawBorder: false } - }, - yComparison: { min: 0, display: false, grid: { display: false } }, - x: { grid: { display: false }, ticks: {} } - }, - interaction: { mode: 'index', intersect: false } - } - }) - - this.updateChart() - } - - repositionTooltip(e) { - const tooltipEl = document.getElementById('chartjs-tooltip-main') - if (tooltipEl && window.innerWidth >= 768) { - if (e.clientX > 0.66 * window.innerWidth) { - tooltipEl.style.right = - window.innerWidth - e.clientX + window.pageXOffset + 'px' - tooltipEl.style.left = null - } else { - tooltipEl.style.right = null - tooltipEl.style.left = e.clientX + window.pageXOffset + 'px' - } - tooltipEl.style.top = e.clientY + window.pageYOffset + 'px' - tooltipEl.style.opacity = 1 - } - } - - componentDidMount() { - if (this.props.graphData) { - this.regenerateChart() - } - window.addEventListener('mousemove', this.repositionTooltip) - } - - componentDidUpdate(prevProps) { - const { graphData, theme } = this.props - const tooltip = document.getElementById('chartjs-tooltip-main') - const dataChanged = !equal(graphData, prevProps.graphData) - if (dataChanged || theme.mode !== prevProps.theme.mode) { - if (tooltip) { - tooltip.style.display = 'none' - } - - if (graphData) { - if (this.chart) { - this.updateChart() - } else { - this.regenerateChart() - } - } - } - - if (!graphData) { - if (this.chart) { - this.chart.destroy() - this.chart = null - } - - if (tooltip) { - tooltip.style.display = 'none' - } - } - } - - componentWillUnmount() { - // Ensure that the tooltip doesn't hang around when we are loading more data - const tooltip = document.getElementById('chartjs-tooltip-main') - if (tooltip) { - tooltip.style.opacity = 0 - tooltip.style.display = 'none' - } - window.removeEventListener('mousemove', this.repositionTooltip) - } - - /** - * The current ticks' limits are set to treat iPad (regular/Mini/Pro) as a regular screen. - * @param {*} chart - The chart instance. - * @param {*} dimensions - An object containing the new dimensions *of the chart.* - */ - updateWindowDimensions(chart, dimensions) { - chart.options.scales.x.ticks.maxTicksLimit = dimensions.width < 720 ? 5 : 8 - } - - maybeHopToHoveredPeriod(e) { - const element = this.chart.getElementsAtEventForMode(e, 'index', { - intersect: false - })[0] - const date = this.props.graphData.labels[element.index] - - if (date === '__blank__') { - return - } - - if (this.props.graphData.interval === 'month') { - this.props.navigate({ - search: (searchRecord) => ({ ...searchRecord, period: 'month', date }) - }) - } else if (this.props.graphData.interval === 'day') { - this.props.navigate({ - search: (searchRecord) => ({ ...searchRecord, period: 'day', date }) - }) - } - } - - render() { - const { graphData } = this.props - const canvasClass = classNames('select-none', { - 'cursor-pointer': !['minute', 'hour'].includes(graphData?.interval) - }) - - return ( - - - - ) - } -} - -export function LineGraphContainer(props) { - return
{props.children}
-} - -export default function LineGraphWrapped(props) { - const { dashboardState } = useDashboardStateContext() - const navigate = useAppNavigate() - const theme = useTheme() - return ( - - ) -} diff --git a/assets/js/dashboard/stats/graph/main-graph-data.test.ts b/assets/js/dashboard/stats/graph/main-graph-data.test.ts new file mode 100644 index 000000000000..84994a1c7468 --- /dev/null +++ b/assets/js/dashboard/stats/graph/main-graph-data.test.ts @@ -0,0 +1,126 @@ +import { + getChangeInPercentagePoints, + getRelativeChange, + getLineSegments +} from './main-graph-data' + +describe(`${getChangeInPercentagePoints.name}`, () => { + it('returns the difference', () => { + expect(getChangeInPercentagePoints(70, 60)).toBe(10) + }) + + it('returns a negative value when value is lower', () => { + expect(getChangeInPercentagePoints(30, 50)).toBe(-20) + }) + + it('returns 0 when both values are equal', () => { + expect(getChangeInPercentagePoints(5, 5)).toBe(0) + }) +}) + +describe(`${getRelativeChange.name}`, () => { + it('returns the percentage change rounded to nearest integer', () => { + expect(getRelativeChange(150, 100)).toBe(50) + }) + + it('rounds fractional percentages', () => { + expect(getRelativeChange(10, 3)).toBe(233) // (10-3)/3*100 = 233.33... + }) + + it('returns 100 when comparison is 0 and value is positive', () => { + expect(getRelativeChange(5, 0)).toBe(100) + }) + + it('returns 0 when both are 0', () => { + expect(getRelativeChange(0, 0)).toBe(0) + }) + + it('returns a negative value for a decrease', () => { + expect(getRelativeChange(50, 100)).toBe(-50) + }) +}) + +const seriesValueBase = { + numericValue: 0, + value: 0, + timeLabel: '' +} +const np = () => ({ + isDefined: true, + isPartial: false, + ...seriesValueBase +}) +const p = () => ({ + isDefined: true, + isPartial: true, + ...seriesValueBase +}) +const gap = () => ({ isDefined: false }) as const + +describe(`${getLineSegments.name}`, () => { + it('returns empty for empty input', () => { + expect(getLineSegments([])).toEqual([]) + }) + + it('returns empty for a single point (no edge to draw)', () => { + expect(getLineSegments([np()])).toEqual([]) + }) + + it('returns empty for a single gap', () => { + expect(getLineSegments([gap()])).toEqual([]) + }) + + it('returns a full segment for two non-partial points', () => { + expect(getLineSegments([np(), np()])).toEqual([ + { startIndexInclusive: 0, stopIndexExclusive: 2, type: 'full' } + ]) + }) + + it('returns a partial segment for two partial points', () => { + expect(getLineSegments([p(), p()])).toEqual([ + { startIndexInclusive: 0, stopIndexExclusive: 2, type: 'partial' } + ]) + }) + + it('returns partial when connecting non-partial to partial', () => { + expect(getLineSegments([np(), p()])).toEqual([ + { startIndexInclusive: 0, stopIndexExclusive: 2, type: 'partial' } + ]) + }) + + it('returns partial when connecting partial to non-partial', () => { + expect(getLineSegments([p(), np()])).toEqual([ + { startIndexInclusive: 0, stopIndexExclusive: 2, type: 'partial' } + ]) + }) + + it('handles single full period in the middle of two partial periods', () => { + expect(getLineSegments([p(), np(), p()])).toEqual([ + { startIndexInclusive: 0, stopIndexExclusive: 3, type: 'partial' } + ]) + }) + + it('handles partial periods on both ends', () => { + expect(getLineSegments([p(), np(), np(), p()])).toEqual([ + { startIndexInclusive: 0, stopIndexExclusive: 2, type: 'partial' }, + { startIndexInclusive: 1, stopIndexExclusive: 3, type: 'full' }, + { startIndexInclusive: 2, stopIndexExclusive: 4, type: 'partial' } + ]) + }) + + it('handles leading gaps', () => { + expect( + getLineSegments([gap(), gap(), np(), np(), np(), np(), p()]) + ).toEqual([ + { startIndexInclusive: 2, stopIndexExclusive: 6, type: 'full' }, + { startIndexInclusive: 5, stopIndexExclusive: 7, type: 'partial' } + ]) + }) + + it('handles trailing gaps', () => { + expect(getLineSegments([np(), np(), p(), gap(), gap()])).toEqual([ + { startIndexInclusive: 0, stopIndexExclusive: 2, type: 'full' }, + { startIndexInclusive: 1, stopIndexExclusive: 3, type: 'partial' } + ]) + }) +}) diff --git a/assets/js/dashboard/stats/graph/main-graph-data.ts b/assets/js/dashboard/stats/graph/main-graph-data.ts new file mode 100644 index 000000000000..51f8488e6e53 --- /dev/null +++ b/assets/js/dashboard/stats/graph/main-graph-data.ts @@ -0,0 +1,207 @@ +import { MainGraphResponse, MetricValue, ResultItem } from './fetch-main-graph' + +/** + * Fills gaps in @see MainGraphResponse the series of `results` and `comparisonResults`. + * The BE doesn't return buckets in the series where the value is 0: + * these need to filled by the FE to have a consistent plot. + * + * The assumption is that the two series each are continuously defined. + * + * Extracts the numeric values for the series when they are wrapped. + * + */ +export const remapAndFillData = ({ + data, + getNumericValue, + getValue, + getChange +}: { + data: MainGraphResponse + getNumericValue: (metricValue: MetricValue) => number + getValue: (item: Pick) => MetricValue + getChange: (value: number, comparisonValue: number) => number +}): GraphDatum[] => { + const totalBucketCount = Math.max( + data.meta.comparison_time_label_result_indices?.length ?? 0, + data.meta.time_label_result_indices.length + ) + + const remappedData: GraphDatum[] = new Array(totalBucketCount) + .fill(null) + .map((_, index) => { + const timeLabel = data.meta.time_labels[index] ?? null + const indexOfResult = data.meta.time_label_result_indices[index] ?? null + const comparisonTimeLabel = + (data.meta.comparison_time_labels && + data.meta.comparison_time_labels[index]) ?? + null + const indexOfComparisonResult = + (data.meta.comparison_time_label_result_indices && + data.meta.comparison_time_label_result_indices[index]) ?? + null + + let main: SeriesValue + if (typeof timeLabel === 'string') { + const value = + indexOfResult !== null + ? getValue(data.results[indexOfResult]!) + : getValue({ metrics: data.meta.empty_metrics }) + main = { + isDefined: true, + timeLabel, + value, + numericValue: getNumericValue(value), + isPartial: (data.meta.partial_time_labels ?? []).includes(timeLabel) + } + } else { + main = { isDefined: false } + } + + let comparison: SeriesValue + if (typeof comparisonTimeLabel === 'string') { + const value = + indexOfComparisonResult !== null + ? getValue(data.comparison_results[indexOfComparisonResult]!) + : getValue({ metrics: data.meta.empty_metrics }) + comparison = { + isDefined: true, + timeLabel: comparisonTimeLabel, + value, + numericValue: getNumericValue(value), + isPartial: (data.meta.comparison_partial_time_labels ?? []).includes( + comparisonTimeLabel + ) + } + } else { + comparison = { isDefined: false } + } + + let change = null + + if ( + change === null && + main.isDefined && + comparison.isDefined && + main.value !== null && + comparison.value !== null + ) { + change = getChange(main.numericValue, comparison.numericValue) + } + + return { + main, + comparison, + change + } + }) + + return remappedData +} + +export const getFirstAndLastTimeLabels = ( + response: Pick, + series: MainGraphSeriesName +): [string | null, string | null] => { + const labels = { + [MainGraphSeriesName.main]: response.meta.time_labels, + [MainGraphSeriesName.comparison]: response.meta.comparison_time_labels + }[series] + if (!labels?.length) { + return [null, null] + } + return [labels[0], labels[labels.length - 1]] +} + +export const METRICS_WITH_CHANGE_IN_PERCENTAGE_POINTS = [ + 'bounce_rate', + 'exit_rate', + 'conversion_rate' + // 'group_conversion_rate' +] + +export const getChangeInPercentagePoints = ( + value: number, + comparisonValue: number +): number => { + return value - comparisonValue +} + +export const getRelativeChange = ( + value: number, + comparisonValue: number +): number => { + if (comparisonValue === 0 && value > 0) { + return 100 + } + if (comparisonValue === 0 && value === 0) { + return 0 + } + + return Math.round(((value - comparisonValue) / comparisonValue) * 100) +} + +export const REVENUE_METRICS = ['average_revenue', 'total_revenue'] + +export type LineSegment = { + startIndexInclusive: number + stopIndexExclusive: number + type: 'full' | 'partial' +} + +/** + * Creates segments from points of a series. + * When a point of data is partial, all lines to and from it must be partial lines. + * (If that partial point moves, the lines to and from it move.) + * A full line is drawn only between two or more continuous full periods. + * No line is drawn from or to gaps in the data. + */ +export function getLineSegments(data: SeriesValue[]): LineSegment[] { + return data.reduce((segments: LineSegment[], curr, i) => { + if (i === 0) { + return segments + } + const prev = data[i - 1] + if (!prev.isDefined || !curr.isDefined) { + return segments + } + + const type = prev.isPartial || curr.isPartial ? 'partial' : 'full' + const lastSegment = segments[segments.length - 1] + + if (lastSegment?.type === type && lastSegment.stopIndexExclusive === i) { + return [ + ...segments.slice(0, -1), + { ...lastSegment, stopIndexExclusive: i + 1 } + ] + } + + return [ + ...segments, + { startIndexInclusive: i - 1, stopIndexExclusive: i + 1, type } + ] + }, []) +} + +/** + * A data point for the graph and tooltip. + * It's x position is its index in `GraphDatum[]` array. + * The values for `numericValue`, `comparisonNumericValue` should be plotted on the y axis, when they are defined for the x position. + */ +export type GraphDatum = Record & { + change?: number | null +} + +export enum MainGraphSeriesName { + main = 'main', + comparison = 'comparison' +} + +type SeriesValue = + | { isDefined: false } + | { + isDefined: true + numericValue: number + value: MetricValue + isPartial: boolean + timeLabel: string + } diff --git a/assets/js/dashboard/stats/graph/main-graph.tsx b/assets/js/dashboard/stats/graph/main-graph.tsx new file mode 100644 index 000000000000..8020206cb851 --- /dev/null +++ b/assets/js/dashboard/stats/graph/main-graph.tsx @@ -0,0 +1,652 @@ +import React, { + ReactNode, + useCallback, + useEffect, + useMemo, + useState +} from 'react' +import { UIMode, useTheme } from '../../theme-context' +import { MetricFormatterShort } from '../reports/metric-formatter' +import { DashboardPeriod } from '../../dashboard-time-periods' +import { + formatMonthYYYY, + formatDayShort, + formatTime, + is12HourClock, + parseNaiveDate, + formatDay, + isThisYear +} from '../../util/date' +import classNames from 'classnames' +import { ChangeArrow } from '../reports/change-arrow' +import { Metric } from '../../../types/query-api' +import { useAppNavigate } from '../../navigation/use-app-navigate' +import { Graph, PointerHandler, SeriesConfig } from '../../components/graph' +import { useSiteContext, PlausibleSite } from '../../site-context' +import { GraphTooltipWrapper } from '../../components/graph-tooltip' +import { + MainGraphResponse, + MetricValue, + RevenueMetricValue +} from './fetch-main-graph' +import { + remapAndFillData, + getLineSegments, + GraphDatum, + METRICS_WITH_CHANGE_IN_PERCENTAGE_POINTS, + getChangeInPercentagePoints, + getRelativeChange, + REVENUE_METRICS, + getFirstAndLastTimeLabels, + MainGraphSeriesName +} from './main-graph-data' +import { getMetricLabel } from '../metrics' +import { useDashboardStateContext } from '../../dashboard-state-context' +import { hasConversionGoalFilter } from '../../util/filters' + +const height = 368 +const marginTop = 16 +const marginRight = 4 +const marginBottom = 32 +const defaultMarginLeft = 16 // this is adjusted by the Graph component based on y-axis label width +const hoverBuffer = 4 + +type MainGraphData = MainGraphResponse & { + period: DashboardPeriod + interval: string +} + +export const MainGraph = ({ + width, + data +}: { + width: number + data: MainGraphData +}) => { + const site = useSiteContext() + const { mode } = useTheme() + const navigate = useAppNavigate() + const { primaryGradient, secondaryGradient } = paletteByTheme[mode] + const [isTouchDevice, setIsTouchDevice] = useState(false) + const [tooltip, setTooltip] = useState<{ + x: number + y: number + selectedIndex: number | null + }>({ x: 0, y: 0, selectedIndex: null }) + const { selectedIndex } = tooltip + const metric = data.query.metrics[0] as Metric + const interval = data.interval + const period = data.period + const { + remappedData, + yMax, + dateIsUnambiguous, + yearIsUnambiguous, + settings, + remappedDataInGraphFormat, + gradients + } = useMemo(() => { + const mainSeriesStartEndLabels = getFirstAndLastTimeLabels( + data, + MainGraphSeriesName.main + ) + const comparisonSeriesStartEndLabels = getFirstAndLastTimeLabels( + data, + MainGraphSeriesName.comparison + ) + const remappedData = remapAndFillData({ + getValue: (item) => item.metrics[0], + getNumericValue: REVENUE_METRICS.includes(metric) + ? (v) => (v as RevenueMetricValue).value + : (v) => ((v as number | null) === null ? 0 : (v as number)), + getChange: METRICS_WITH_CHANGE_IN_PERCENTAGE_POINTS.includes(metric) + ? getChangeInPercentagePoints + : getRelativeChange, + data + }) + + let yMax = 1 + + // can't be done in a single pass with remapAndFillData + // because we need the xLabels formatting parameters to be known + const remappedDataInGraphFormat = remappedData.map( + ({ main, comparison }, bucketIndex) => { + const dataPoint = { + values: [ + main.isDefined ? main.numericValue : null, + comparison.isDefined ? comparison.numericValue : null + ] as const, + xLabel: main.isDefined + ? getBucketLabel(main.timeLabel, { + shouldShowDate: !isDateUnambiguous({ + startEndLabels: mainSeriesStartEndLabels + }), + shouldShowYear: !isYearUnambiguous({ + site, + startEndLabels: mainSeriesStartEndLabels + }), + interval, + period, + bucketIndex, + totalBuckets: remappedData.length + }) + : '' + } + if (main.isDefined && main.numericValue > yMax) { + yMax = main.numericValue + } + if (comparison.isDefined && comparison.numericValue > yMax) { + yMax = comparison.numericValue + } + return dataPoint + } + ) + + const gradients = [primaryGradient, secondaryGradient] + const mainLineSegments = getLineSegments(remappedData.map((d) => d.main)) + const comparisonLineSegments = getLineSegments( + remappedData.map((d) => d.comparison) + ) + + const mainSeries: SeriesConfig = { + lines: mainLineSegments.map(({ type, ...rest }) => ({ + lineClassName: classNames( + sharedPathClass, + mainPathClass, + { partial: dashedPathClass, full: roundedPathClass }[type] + ), + ...rest + })), + underline: { gradientId: primaryGradient.id }, + dot: { dotClassName: classNames(sharedDotClass, mainDotClass) } + } + + const comparisonSeries: SeriesConfig = { + lines: comparisonLineSegments.map(({ type, ...rest }) => ({ + lineClassName: classNames( + sharedPathClass, + comparisonPathClass, + { partial: dashedPathClass, full: roundedPathClass }[type] + ), + ...rest + })), + underline: { gradientId: secondaryGradient.id }, + dot: { dotClassName: classNames(sharedDotClass, comparisonDotClass) } + } + + const settings: [SeriesConfig, SeriesConfig] = [ + mainSeries, + comparisonSeries + ] + + const yearIsUnambiguous = isYearUnambiguous({ + site, + startEndLabels: [ + ...mainSeriesStartEndLabels, + ...comparisonSeriesStartEndLabels + ] + }) + const dateIsUnambiguous = isDateUnambiguous({ + startEndLabels: [ + ...mainSeriesStartEndLabels, + ...comparisonSeriesStartEndLabels + ] + }) + + return { + remappedData, + remappedDataInGraphFormat, + yMax, + dateIsUnambiguous, + yearIsUnambiguous, + settings, + gradients + } + }, [site, data, interval, period, primaryGradient, secondaryGradient, metric]) + + const getFormattedValue = useCallback( + (value: MetricValue) => MetricFormatterShort[metric](value), + [metric] + ) + const yFormat = useCallback( + (numericValue: d3.NumberValue) => + MetricFormatterShort[metric](numericValue), + [metric] + ) + + const onPointerMove = useCallback( + ({ inHoverableArea, closestIndex, x, y, event }) => { + if (event instanceof PointerEvent) { + setIsTouchDevice(event.pointerType === 'touch') + } + if (!inHoverableArea) { + setTooltip({ selectedIndex: null, x: 0, y: 0 }) + } else { + setTooltip({ + selectedIndex: closestIndex, + x: Math.floor(x), + y: Math.floor(y) + }) + } + }, + [] + ) + + const onPointerLeave = useCallback(() => { + setTooltip({ selectedIndex: null, x: 0, y: 0 }) + }, []) + + const showZoomToPeriod = ['month', 'day'].includes(interval) + const selectedDatum = selectedIndex !== null && remappedData[selectedIndex] + + const zoomDate = + selectedDatum && selectedDatum.main.isDefined + ? selectedDatum.main.timeLabel + : null + + return ( + > + className={showZoomToPeriod && selectedDatum ? 'cursor-pointer' : ''} + width={width} + height={height} + hoverBuffer={hoverBuffer} + marginTop={marginTop} + marginRight={marginRight} + marginBottom={marginBottom} + defaultMarginLeft={defaultMarginLeft} + settings={settings} + data={remappedDataInGraphFormat} + yMax={yMax} + onPointerMove={onPointerMove} + onPointerLeave={onPointerLeave} + onClick={ + selectedIndex !== null && + showZoomToPeriod && + typeof zoomDate === 'string' + ? () => + navigate({ + search: (currentSearch) => ({ + ...currentSearch, + date: zoomDate, + period: { + month: DashboardPeriod.month, + day: DashboardPeriod.day + }[interval] + }) + }) + : undefined + } + yFormat={yFormat} + gradients={gradients} + > + {selectedDatum && ( + + )} + + ) +} + +const MainGraphTooltip = ({ + metric, + getFormattedValue, + interval, + period, + shouldShowDate, + shouldShowYear, + maxX, + x, + y, + datum, + showZoomToPeriod, + bucketIndex, + totalBuckets, + isTouchDevice +}: { + metric: Metric + getFormattedValue: (value: MetricValue) => string + interval: string + period: DashboardPeriod + shouldShowYear: boolean + shouldShowDate: boolean + x: number + y: number + datum: GraphDatum + showZoomToPeriod?: boolean + bucketIndex: number + totalBuckets: number + maxX: number + isTouchDevice: boolean +}) => { + const { dashboardState } = useDashboardStateContext() + const metricLabel = getMetricLabel(metric, { + hasConversionGoalFilter: hasConversionGoalFilter(dashboardState) + }) + const { main, comparison, change } = datum + + return ( + + + + ) +} + +export const MainGraphContainer = React.forwardRef< + HTMLDivElement, + { children: ReactNode } +>((props, ref) => { + return ( +
+ {props.children} +
+ ) +}) + +type BucketLabelParams = { + shouldShowYear: boolean + shouldShowDate: boolean + /* "month" | "week" | "day" | "hour" | "minute" */ + interval: string + period: DashboardPeriod + bucketIndex: number + totalBuckets: number +} + +const getBucketLabel = ( + // in the format "YYYY-MM-DD" or "YYYY-MM-DD HH:MM:SS" + xValue: string, + { + shouldShowYear, + shouldShowDate, + period, + interval, + bucketIndex, + totalBuckets + }: BucketLabelParams +) => { + const parsedDate = parseNaiveDate(xValue) + switch (interval) { + case 'month': + return formatMonthYYYY(parsedDate) + case 'week': + case 'day': + return formatDayShort(parsedDate, shouldShowYear) + case 'hour': { + const time = formatTime(parsedDate, { + use12HourClock: is12HourClock(), + includeMinutes: false + }) + if (shouldShowDate) { + return `${formatDayShort(parsedDate, shouldShowYear)}, ${time}` + } + return time + } + case 'minute': { + if (period === DashboardPeriod.realtime) { + const minutesAgo = totalBuckets - bucketIndex + return `-${minutesAgo}m` + } + const time = formatTime(parsedDate, { + use12HourClock: is12HourClock(), + includeMinutes: true + }) + if (shouldShowDate) { + return `${formatDayShort(parsedDate, shouldShowYear)}, ${time}` + } + return time + } + default: + return '' + } +} + +const getFullBucketLabel = ( + // in the format "YYYY-MM-DD" or "YYYY-MM-DD HH:MM:SS" + xValue: string, + { + shouldShowYear, + shouldShowDate, + period, + interval, + bucketIndex, + totalBuckets, + isPartial + }: BucketLabelParams & { isPartial: boolean } +) => { + const parsedDate = parseNaiveDate(xValue) + switch (interval) { + case 'month': { + const month = getBucketLabel(xValue, { + shouldShowYear, + shouldShowDate, + interval, + period, + bucketIndex, + totalBuckets + }) + return isPartial ? `Partial of ${month}` : month + } + case 'week': { + const date = getBucketLabel(xValue, { + shouldShowYear, + shouldShowDate, + interval, + period, + bucketIndex, + totalBuckets + }) + return isPartial ? `Partial week of ${date}` : `Week of ${date}` + } + case 'day': + return formatDay(parsedDate, shouldShowYear) + case 'hour': { + const time = formatTime(parsedDate, { + use12HourClock: is12HourClock(), + includeMinutes: false + }) + if (shouldShowDate) { + return `${formatDay(parsedDate, shouldShowYear)}, ${time}` + } + return time + } + case 'minute': { + if (period === DashboardPeriod.realtime) { + const minutesAgo = totalBuckets - bucketIndex + return minutesAgo === 1 ? `1 minute ago` : `${minutesAgo} minutes ago` + } + const time = formatTime(parsedDate, { + use12HourClock: is12HourClock(), + includeMinutes: true + }) + if (shouldShowDate) { + return `${formatDay(parsedDate, shouldShowYear)}, ${time}` + } + return time + } + default: + return '' + } +} + +function isYearUnambiguous({ + site, + startEndLabels +}: { + site: PlausibleSite + startEndLabels: (string | null)[] +}): boolean { + return startEndLabels + .filter((item) => typeof item === 'string') + .every( + (item, _index, items) => + parseNaiveDate(items[0]).isSame(parseNaiveDate(item), 'year') && + isThisYear(site, parseNaiveDate(items[0])) + ) +} + +function isDateUnambiguous({ + startEndLabels +}: { + startEndLabels: (string | null)[] +}): boolean { + return startEndLabels + .filter((item) => typeof item === 'string') + .every((item, _index, items) => + parseNaiveDate(items[0]).isSame(parseNaiveDate(item), 'day') + ) +} + +const paletteByTheme = { + [UIMode.dark]: { + primaryGradient: { + id: 'primary-gradient', + stopTop: { color: '#4f46e5', opacity: 0.15 }, + stopBottom: { color: '#4f46e5', opacity: 0 } + }, + secondaryGradient: { + id: 'secondary-gradient', + stopTop: { color: '#4f46e5', opacity: 0.05 }, + stopBottom: { color: '#4f46e5', opacity: 0 } + } + }, + [UIMode.light]: { + primaryGradient: { + id: 'primary-gradient', + stopTop: { color: '#4f46e5', opacity: 0.15 }, + stopBottom: { color: '#4f46e5', opacity: 0 } + }, + secondaryGradient: { + id: 'secondary-gradient', + + stopTop: { color: '#4f46e5', opacity: 0.05 }, + stopBottom: { color: '#4f46e5', opacity: 0 } + } + } +} + +const sharedPathClass = 'fill-none stroke-2' +const mainPathClass = 'stroke-indigo-500 dark:stroke-indigo-400 z-2' +const comparisonPathClass = 'stroke-indigo-500/20 dark:stroke-indigo-400/20 z-1' +const roundedPathClass = '[stroke-linecap:round] [stroke-linejoin:round]' +const dashedPathClass = '[stroke-dasharray:3,3]' +const sharedDotClass = + 'opacity-0 group-data-active:opacity-100 transition-opacity duration-100' +const mainDotClass = 'fill-indigo-500 dark:fill-indigo-400' +const comparisonDotClass = 'fill-indigo-500/20 dark:fill-indigo-400/20' + +export function useMainGraphWidth( + mainGraphContainer: React.RefObject +): { width: number } { + const [width, setWidth] = useState(0) + + useEffect(() => { + const resizeObserver = new ResizeObserver(([e]) => { + setWidth(e.contentRect.width) + }) + + if (mainGraphContainer.current) { + resizeObserver.observe(mainGraphContainer.current) + } + + return () => { + resizeObserver.disconnect() + } + }, [mainGraphContainer]) + + return { + width + } +} diff --git a/assets/js/dashboard/stats/graph/visitor-graph.tsx b/assets/js/dashboard/stats/graph/visitor-graph.tsx index c88d2061e542..6bcc4f2e8c3b 100644 --- a/assets/js/dashboard/stats/graph/visitor-graph.tsx +++ b/assets/js/dashboard/stats/graph/visitor-graph.tsx @@ -1,14 +1,12 @@ import React, { useState, useEffect, useRef, useCallback } from 'react' -import * as api from '../../api' import * as storage from '../../util/storage' import TopStats from './top-stats' import { fetchTopStats } from './fetch-top-stats' +import { fetchMainGraph } from './fetch-main-graph' import { IntervalPicker, useStoredInterval } from './interval-picker' import StatsExport from './stats-export' import WithImportedSwitch from './with-imported-switch' import { NoticesIcon } from './notices' -import * as url from '../../util/url' -import LineGraphWithRouter, { LineGraphContainer } from './line-graph' import { useDashboardStateContext } from '../../dashboard-state-context' import { PlausibleSite, useSiteContext } from '../../site-context' import { useQuery, useQueryClient } from '@tanstack/react-query' @@ -17,6 +15,7 @@ import { DashboardPeriod } from '../../dashboard-time-periods' import { DashboardState } from '../../dashboard-state' import { nowForSite } from '../../util/date' import { getStaleTime } from '../../hooks/api-client' +import { MainGraph, MainGraphContainer, useMainGraphWidth } from './main-graph' // height of at least one row of top stats const DEFAULT_TOP_STATS_LOADING_HEIGHT_PX = 85 @@ -27,6 +26,8 @@ export default function VisitorGraph({ updateImportedDataInView?: (v: boolean) => void }) { const topStatsBoundary = useRef(null) + const mainGraphContainer = useRef(null) + const { width } = useMainGraphWidth(mainGraphContainer) const site = useSiteContext() const { dashboardState } = useDashboardStateContext() const isRealtime = dashboardState.period === DashboardPeriod.realtime @@ -72,19 +73,24 @@ export default function VisitorGraph({ enabled: !!selectedMetric, queryKey: [ 'main-graph', - { dashboardState, metric: selectedMetric, interval: selectedInterval } + { dashboardState, metric: selectedMetric!, interval: selectedInterval } ] as const, queryFn: async ({ queryKey }) => { const [_, opts] = queryKey - const data = await api.get( - url.apiPath(site, '/main-graph'), + const data = await fetchMainGraph( + site, opts.dashboardState, - { - metric: opts.metric, - interval: opts.interval - } + opts.metric, + opts.interval ) - return { ...data, interval: opts.interval } + + // pack dashboard period and interval used for the request next to data + // so they'd never be out of sync with each other + return { + ...data, + period: opts.dashboardState.period, + interval: opts.interval + } }, placeholderData: (previousData) => previousData, staleTime: ({ queryKey, meta }) => { @@ -270,20 +276,16 @@ export default function VisitorGraph({ /> )} - - {mainGraphQuery.data && ( + + {!!mainGraphQuery.data && !!width && ( <> {!showGraphLoader && ( - + )} {showGraphLoader && } )} - + {(!(topStatsQuery.data && mainGraphQuery.data) || showFullLoader) && ( diff --git a/assets/js/dashboard/util/date.js b/assets/js/dashboard/util/date.js index 8fff39c09c87..e8541b17aaeb 100644 --- a/assets/js/dashboard/util/date.js +++ b/assets/js/dashboard/util/date.js @@ -3,6 +3,14 @@ import utc from 'dayjs/plugin/utc' dayjs.extend(utc) +const browserDateFormat = Intl.DateTimeFormat(navigator.language, { + hour: 'numeric' +}) + +export function is12HourClock() { + return browserDateFormat.resolvedOptions().hour12 +} + export function utcNow() { return dayjs() } @@ -32,14 +40,21 @@ export function formatYearShort(date) { return date.getUTCFullYear().toString().substring(2) } -export function formatDay(date) { - if (date.year() !== dayjs().year()) { +export function formatDay(date, includeYear = false) { + if (includeYear) { return date.format('ddd, DD MMM YYYY') } else { return date.format('ddd, DD MMM') } } +export function formatTime(date, { use12HourClock, includeMinutes }) { + if (use12HourClock) { + return includeMinutes ? date.format('h:mma') : date.format('ha') + } + return date.format('HH:mm') +} + export function formatDayShort(date, includeYear = false) { if (includeYear) { return date.format('D MMM YY') diff --git a/assets/js/dashboard/util/date.test.ts b/assets/js/dashboard/util/date.test.ts index 4be17e2ff530..215e059ced03 100644 --- a/assets/js/dashboard/util/date.test.ts +++ b/assets/js/dashboard/util/date.test.ts @@ -1,6 +1,8 @@ import { dateForSite, formatDayShort, + formatTime, + formatMonthYYYY, formatISO, nowForSite, parseNaiveDate, @@ -126,3 +128,87 @@ describe('formatting site-timezoned datetimes from database works flawlessly', ( ) }) }) + +describe(formatMonthYYYY.name, () => { + it('formats a date as "Month YYYY"', () => { + expect(formatMonthYYYY(parseNaiveDate('2025-06-15'))).toEqual('June 2025') + }) + + it('formats January correctly', () => { + expect(formatMonthYYYY(parseNaiveDate('2024-01-01'))).toEqual( + 'January 2024' + ) + }) +}) + +describe(formatDayShort.name, () => { + it('formats without year by default', () => { + expect(formatDayShort(parseNaiveDate('2025-06-05'))).toEqual('5 Jun') + }) + + it('includes 2-digit year when requested', () => { + expect(formatDayShort(parseNaiveDate('2025-06-05'), true)).toEqual( + '5 Jun 25' + ) + }) +}) + +describe(formatTime.name, () => { + describe('12-hour clock', () => { + it('formats hour without minutes as ha', () => { + expect( + formatTime(parseNaiveDate('2025-06-15 14:00:00'), { + use12HourClock: true, + includeMinutes: false + }) + ).toEqual('2pm') + }) + + it('formats hour with minutes as h:mma', () => { + expect( + formatTime(parseNaiveDate('2025-06-15 14:30:00'), { + use12HourClock: true, + includeMinutes: true + }) + ).toEqual('2:30pm') + }) + + it('formats midnight correctly', () => { + expect( + formatTime(parseNaiveDate('2025-06-15 00:00:00'), { + use12HourClock: true, + includeMinutes: false + }) + ).toEqual('12am') + }) + }) + + describe('24-hour clock', () => { + it('formats hour without minutes as HH:mm (not HH format because that would look weird) ', () => { + expect( + formatTime(parseNaiveDate('2025-06-15 14:00:00'), { + use12HourClock: false, + includeMinutes: false + }) + ).toEqual('14:00') + }) + + it('formats hour with minutes as HH:mm', () => { + expect( + formatTime(parseNaiveDate('2025-06-15 14:30:00'), { + use12HourClock: false, + includeMinutes: true + }) + ).toEqual('14:30') + }) + + it('pads single-digit hours', () => { + expect( + formatTime(parseNaiveDate('2025-06-15 09:00:00'), { + use12HourClock: false, + includeMinutes: false + }) + ).toEqual('09:00') + }) + }) +})