diff --git a/.codespellignore b/.codespellignore index 23d01f85ba4a..3a9f207dc13c 100644 --- a/.codespellignore +++ b/.codespellignore @@ -7,3 +7,4 @@ referer referers statics firs +IST diff --git a/assets/js/dashboard/components/graph-tooltip.tsx b/assets/js/dashboard/components/graph-tooltip.tsx new file mode 100644 index 000000000000..630b78a4fe48 --- /dev/null +++ b/assets/js/dashboard/components/graph-tooltip.tsx @@ -0,0 +1,56 @@ +import React, { ReactNode, useLayoutEffect, useRef, useState } from 'react' +import { + Transition, + TransitionClasses, + TransitionEvents +} from '@headlessui/react' + +export const GraphTooltipWrapper = ({ + x, + y, + maxX, + minWidth, + children, + className, + transition +}: { + x: number + y: number + maxX: number + minWidth: number + children: ReactNode + className?: string + transition?: TransitionClasses & TransitionEvents +}) => { + const ref = useRef(null) + const xOffsetFromCursor = 12 + const yOffsetFromCursor = 24 + const [measuredWidth, setMeasuredWidth] = useState(minWidth) + // clamp 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..c781a63c06aa --- /dev/null +++ b/assets/js/dashboard/components/graph.tsx @@ -0,0 +1,860 @@ +import React, { + ReactNode, + useCallback, + useEffect, + useRef, + useState +} from 'react' +import * as d3 from 'd3' +import classNames from 'classnames' + +const IDEAL_Y_TICK_COUNT = 5 +const MAX_X_TICK_COUNT = 8 + +type GraphYValues = ReadonlyArray + +/** + * 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 GraphYValues, + 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 + onPointerEnter: (event: unknown) => void + onPointerMove: PointerHandler + onPointerLeave: (event: unknown) => void + onGotPointerCapture: (event: unknown) => void + onClick?: PointerHandler + yFormat: (domainValue: d3.NumberValue, index: number) => string + /** + * Things are drawn in the order of settings, + * so if one series needs to be drawn on top of the other, + * it has to be after the other in the settings array. + */ + settings: U + gradients: { + id: string + stopTop: { color: string; opacity: number } + stopBottom: { color: string; opacity: number } + }[] + children?: ReactNode + highlightedIndex?: number | null +} + +/** + * Usage: + * By setting `T` to `Readonly<[number | null, number | null, number | null]>>` + * the graph is configured to draw 3 series. + */ +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, + onGotPointerCapture, + onPointerEnter, + onClick, + yFormat, + settings, + gradients, + highlightedIndex +}: GraphProps) { + const [extraMarginLeft, setExtraMarginLeft] = useState(0) + + const marginLeft = defaultMarginLeft + extraMarginLeft + const xLeftEdge = marginLeft + const xRightEdge = width - marginRight + const yTopEdge = marginTop + const yBottomEdge = height - marginBottom + + const svgRef = useRef(null) + const pointsRef = useRef[] | null>(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 + + // Declare the y (vertical position) scale. + const y = getYScale({ yMax, yBottomEdge, yTopEdge }) + + const optimalYTickValues = getOptimalYTickValues(y, yMax) + + const svg = d3.select(svgRef.current) + + const cleanup = () => { + pointsRef.current = null + svg.selectAll('*').remove() + } + + // Hide svg until ready + svg.attr('opacity', 0) + const { textOffset } = fitYAxis({ + buildAxis: () => + svg + .append('g') + .attr('class', 'y-axis--container') + .attr('transform', `translate(${xLeftEdge}, 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', + getChartAreaWidth({ + width, + marginLeft, + marginRight + }) + ) + .attr('class', yTickLineClass) + ), + minClientX + }) + const adjustmentIncrement = 4 + if (textOffset < 0) { + const adjustmentSteps = Math.ceil(-textOffset / adjustmentIncrement) + setExtraMarginLeft((curr) => curr + adjustmentSteps * adjustmentIncrement) + return cleanup + } else if (extraMarginLeft !== 0 && textOffset > 1 * adjustmentIncrement) { + const adjustmentSteps = Math.floor(textOffset / adjustmentIncrement) + setExtraMarginLeft((curr) => + Math.max(curr - adjustmentSteps * adjustmentIncrement, 0) + ) + return cleanup + } + + const bucketCount = data.length + const x = getXScale({ + domain: getXDomain(bucketCount), + xLeftEdge, + xRightEdge + }) + 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') + .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[] = [] + for (const [seriesIndex, series] of settings.entries()) { + 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]!) + }) + } + + 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 + }) + } + } + + for (const [i, d] of data.entries()) { + const point = + points[i] ?? getPoint({ index: i, datum: d, xScale: x, yScale: y }) + const dotForSeries = drawDot({ + svg, + series, + x: point.x, + y: point.values[seriesIndex] + }) + points[i] = { + ...point, + dots: [...point.dots, dotForSeries] as { [K in keyof T]: SelectedDot } + } + } + } + + pointsRef.current = points + + // Unhide chart + svg.attr('opacity', 1) + + return cleanup + }, [ + data, + gradients, + height, + marginRight, + defaultMarginLeft, + extraMarginLeft, + marginTop, + settings, + width, + yFormat, + yMax, + marginLeft, + yBottomEdge, + yTopEdge, + xLeftEdge, + xRightEdge + ]) + + const isInHoverableArea = useCallback( + (xPointer: number, yPointer: number): boolean => { + return ( + xPointer >= xLeftEdge - hoverBuffer && + xPointer <= xRightEdge + hoverBuffer && + yPointer >= yTopEdge - hoverBuffer && + // chart is interactive even over x-axis labels + yPointer <= height + ) + }, + [height, hoverBuffer, xLeftEdge, xRightEdge, yTopEdge] + ) + + useEffect(() => { + const currentSvg = svgRef.current + if (currentSvg && pointsRef.current) { + const points = pointsRef.current + const svg = d3.select(currentSvg) + + svg.on( + 'pointermove', + (event) => { + const { xPointer, yPointer } = getPosition(event) + const inHoverableArea = isInHoverableArea(xPointer, yPointer) + const closestIndexToPointer = inHoverableArea + ? getClosestIndexToPointer(xPointer, points) + : null + onPointerMove({ + inHoverableArea, + closestPoint: + closestIndexToPointer !== null + ? { + index: closestIndexToPointer, + x: points[closestIndexToPointer].x, + values: points[closestIndexToPointer].values + } + : null, + xPointer, + yPointer, + event + }) + }, + { passive: true } + ) + return () => { + if (currentSvg) { + const svg = d3.select(currentSvg) + svg.on('pointermove', null) + } + } + } + }, [onPointerMove, isInHoverableArea, data]) + + useEffect(() => { + const currentSvg = svgRef.current + if (currentSvg && pointsRef.current) { + const svg = d3.select(currentSvg) + svg.on( + 'gotpointercapture', + (event) => { + onGotPointerCapture(event) + }, + { passive: true } + ) + } + return () => { + if (currentSvg) { + const svg = d3.select(currentSvg) + svg.on('gotpointercapture', null) + } + } + }, [onGotPointerCapture, isInHoverableArea, data]) + + useEffect(() => { + const currentSvg = svgRef.current + if (currentSvg && pointsRef.current) { + const svg = d3.select(currentSvg) + svg.on( + 'pointerenter', + (event) => { + onPointerEnter(event) + }, + { passive: true } + ) + } + return () => { + if (currentSvg) { + const svg = d3.select(currentSvg) + svg.on('pointerenter', null) + } + } + }, [onPointerEnter, isInHoverableArea, data]) + + useEffect(() => { + const currentSvg = svgRef.current + if (currentSvg && pointsRef.current) { + const svg = d3.select(currentSvg) + svg.on( + 'lostpointercapture pointerleave', + (event) => { + onPointerLeave(event) + }, + { passive: true } + ) + } + + return () => { + if (currentSvg) { + const svg = d3.select(currentSvg) + svg.on('lostpointercapture pointerleave', null) + } + } + }, [onPointerLeave, isInHoverableArea, data]) + + useEffect(() => { + const currentSvg = svgRef.current + if (currentSvg && pointsRef.current) { + const svg = d3.select(currentSvg) + const points = pointsRef.current + if (typeof onClick !== 'function') { + svg.on('click', null) + } else { + svg.on('click', (event) => { + const { xPointer, yPointer } = getPosition(event) + const inHoverableArea = isInHoverableArea(xPointer, yPointer) + const closestIndexToPointer = inHoverableArea + ? getClosestIndexToPointer(xPointer, points) + : null + onClick({ + inHoverableArea, + closestPoint: + closestIndexToPointer !== null + ? { + index: closestIndexToPointer, + x: points[closestIndexToPointer].x, + values: points[closestIndexToPointer].values + } + : null, + xPointer, + yPointer, + event + }) + }) + } + } + return () => { + if (currentSvg) { + const svg = d3.select(currentSvg) + svg.on('click', null) + } + } + }, [onClick, isInHoverableArea, data]) + + useEffect(() => { + pointsRef.current?.forEach(({ dots }, index) => + dots.forEach((g) => + g.attr( + 'data-active', + highlightedIndex !== null && index === highlightedIndex ? '' : null + ) + ) + ) + }, [highlightedIndex, data]) + + return ( + + ) +} + +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, + xLeftEdge, + xRightEdge +}: { + domain: [number, number] + xLeftEdge: number + xRightEdge: number +}): d3.ScaleLinear => + d3.scaleLinear(domain, [xLeftEdge, xRightEdge]) + +const getYScale = ({ + yMax, + yBottomEdge, + yTopEdge +}: { + yMax: number + yBottomEdge: number + yTopEdge: number +}): d3.ScaleLinear => + d3.scaleLinear([0, yMax], [yBottomEdge, yTopEdge]).nice(IDEAL_Y_TICK_COUNT) + +const fitXAxis = ({ + 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 + } + } +} + +const fitYAxis = ({ + buildAxis, + minClientX +}: { + buildAxis: () => d3.Selection + minClientX: number +}): { textOffset: number } => { + let leftMostYTickText: number | null = null + + buildAxis().call((g) => + g.selectAll('.tick').each(function () { + const rect = (this as SVGGraphicsElement).getBoundingClientRect() + if (leftMostYTickText === null || rect.left < leftMostYTickText) + leftMostYTickText = rect.left + }) + ) + return leftMostYTickText !== null + ? { textOffset: leftMostYTickText - minClientX } + : { textOffset: 0 } +} + +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 } => { + const textContent = elem.textContent + // empty texts can't overlap + if (!textContent?.length) { + return { isOverlappingPrevious: false, rightEdge: lastTickTextRightEdge } + } + 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 drawDot({ + svg, + series, + x, + y +}: { + svg: SelectedSVG + series: SeriesConfig + x: number + y: number | null +}): SelectedDot { + const group = svg.append('g').attr('class', 'group') + if (series.dot && y !== null) { + group + .append('circle') + .attr('r', 2.5) + .attr('class', series.dot.dotClassName) + .attr('transform', `translate(${x},${y})`) + } + return group +} + +function getPoint({ + index, + datum, + xScale, + yScale +}: { + index: number + datum: Datum + xScale: d3.ScaleLinear + yScale: d3.ScaleLinear +}): Point { + return { + x: xScale(index), + values: datum.values.map((v): number | null => + v !== null ? yScale(v) : null + ) as unknown as T, + dots: [] as Point['dots'] + } +} + +function getClosestIndexToPointer( + xPointer: number, + points: Point[] +): number { + return d3.bisector(({ x }: Point) => x).center(points, xPointer) +} + +const getPosition = ( + event: unknown +): { xPointer: number; yPointer: number } => { + const [[xPointer, yPointer]] = d3.pointers(event) + return { xPointer, yPointer } +} + +const isWholeNumber = (v: number) => v % 1 === 0 + +export type Datum = { + values: T + xLabel: string +} + +type XPos = number +type Point = { + x: XPos + values: T + dots: { [K in keyof T]: SelectedDot } +} + +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 + xPointer: number + yPointer: number + closestPoint: ({ index: number } & Pick, 'x' | 'values'>) | null + event: unknown +}) => void + +type SelectedSVG = d3.Selection +type SelectedDot = 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/site-context.test.tsx b/assets/js/dashboard/site-context.test.tsx index d8329a39514e..aa0df4d4672c 100644 --- a/assets/js/dashboard/site-context.test.tsx +++ b/assets/js/dashboard/site-context.test.tsx @@ -55,20 +55,6 @@ describe('parseSiteFromDataset', () => { background: undefined, isDbip: false, flags: {}, - validIntervalsByPeriod: { - '12mo': ['day', 'week', 'month'], - '7d': ['hour', 'day'], - '28d': ['day', 'week'], - '30d': ['day', 'week'], - '91d': ['day', 'week', 'month'], - '6mo': ['day', 'week', 'month'], - all: ['week', 'month'], - custom: ['day', 'week', 'month'], - day: ['minute', 'hour'], - month: ['day', 'week'], - realtime: ['minute'], - year: ['day', 'week', 'month'] - }, shared: false, isConsolidatedView: false } diff --git a/assets/js/dashboard/site-context.tsx b/assets/js/dashboard/site-context.tsx index ada56d7a8128..1dff059bacb9 100644 --- a/assets/js/dashboard/site-context.tsx +++ b/assets/js/dashboard/site-context.tsx @@ -21,7 +21,6 @@ export function parseSiteFromDataset(dataset: DOMStringMap): PlausibleSite { background: dataset.background, isDbip: dataset.isDbip === 'true', flags: JSON.parse(dataset.flags!), - validIntervalsByPeriod: JSON.parse(dataset.validIntervalsByPeriod!), shared: !!dataset.sharedLinkAuth, isConsolidatedView: dataset.isConsolidatedView === 'true' } @@ -53,7 +52,6 @@ export const siteContextDefaultValue = { background: undefined as string | undefined, isDbip: false, flags: {} as FeatureFlags, - validIntervalsByPeriod: {} as Record>, shared: false, isConsolidatedView: false } diff --git a/assets/js/dashboard/stats-query.ts b/assets/js/dashboard/stats-query.ts index 1378bb8933f8..0f356fdf5625 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,11 @@ 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, + present_index: reportParams.include?.present_index || 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..b944f6ed272c --- /dev/null +++ b/assets/js/dashboard/stats/graph/fetch-main-graph.ts @@ -0,0 +1,76 @@ +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, + present_index: 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 + present_index: number + } + 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..05d1d86386ef 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,10 @@ 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, + present_index: 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( - - ) - } - 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/interval-picker.tsx b/assets/js/dashboard/stats/graph/interval-picker.tsx index c6df5c5242a4..c088b00dbbfb 100644 --- a/assets/js/dashboard/stats/graph/interval-picker.tsx +++ b/assets/js/dashboard/stats/graph/interval-picker.tsx @@ -1,81 +1,26 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react' +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Popover, Transition } from '@headlessui/react' import { ChevronDownIcon } from '@heroicons/react/20/solid' import classNames from 'classnames' import * as storage from '../../util/storage' import { isModifierPressed, isTyping, Keybind } from '../../keybinding' import { useDashboardStateContext } from '../../dashboard-state-context' -import { PlausibleSite } from '../../site-context' import { useMatch } from 'react-router-dom' import { rootRoute } from '../../router' import { BlurMenuButtonOnEscape, popover } from '../../components/popover' -import { DashboardState } from '../../dashboard-state' -import { Dayjs } from 'dayjs' -import { DashboardPeriod } from '../../dashboard-time-periods' - -const INTERVAL_LABELS: Record = { - minute: 'Minutes', - hour: 'Hours', - day: 'Days', - week: 'Weeks', - month: 'Months' -} - -function validIntervals( - site: Pick, - dashboardState: Pick -): string[] { - if ( - dashboardState.period === DashboardPeriod.custom && - dashboardState.from && - dashboardState.to - ) { - if (dashboardState.to.diff(dashboardState.from, 'days') < 7) { - return ['day'] - } else if (dashboardState.to.diff(dashboardState.from, 'months') < 1) { - return ['day', 'week'] - } else if (dashboardState.to.diff(dashboardState.from, 'months') < 12) { - return ['day', 'week', 'month'] - } else { - return ['week', 'month'] - } - } else { - return site.validIntervalsByPeriod[dashboardState.period] - } -} - -export function getDefaultInterval( - dashboardState: Pick, - validIntervals: string[] -): string { - const defaultByPeriod: Record = { - day: 'hour', - '24h': 'hour', - '7d': 'day', - '6mo': 'month', - '12mo': 'month', - year: 'month' - } - - if ( - dashboardState.period === DashboardPeriod.custom && - dashboardState.from && - dashboardState.to - ) { - return defaultForCustomPeriod(dashboardState.from, dashboardState.to) - } else { - return defaultByPeriod[dashboardState.period] || validIntervals[0] - } -} - -function defaultForCustomPeriod(from: Dayjs, to: Dayjs): string { - if (to.diff(from, 'days') < 30) { - return 'day' - } else if (to.diff(from, 'months') < 6) { - return 'week' - } else { - return 'month' - } +import { + Interval, + GetIntervalProps, + validIntervals, + getDefaultInterval +} from './intervals' + +const INTERVAL_LABELS: Record = { + [Interval.minute]: 'Minutes', + [Interval.hour]: 'Hours', + [Interval.day]: 'Days', + [Interval.week]: 'Weeks', + [Interval.month]: 'Months' } function getStoredInterval(period: string, domain: string): string | null { @@ -88,18 +33,56 @@ function getStoredInterval(period: string, domain: string): string | null { } } -function storeInterval(period: string, domain: string, interval: string): void { +function storeInterval( + period: string, + domain: string, + interval: Interval +): void { storage.setItem(`interval__${period}__${domain}`, interval) } -export const useStoredInterval = ( - site: PlausibleSite, - { to, from, period }: Pick -) => { - const availableIntervals = validIntervals(site, { to, from, period }) +export const useStoredInterval = (props: GetIntervalProps) => { + const { period, from, to, site, comparison, compare_from, compare_to } = props + + // Dayjs objects are new references on every render, so we + // use valueOf() (ms since epoch) to get stable primitive + // values for dependency arrays. + const customFrom = from?.valueOf() + const customTo = to?.valueOf() + const customComparisonFrom = compare_from?.valueOf() + const customComparisonTo = compare_to?.valueOf() + + const { availableIntervals, storableIntervals } = useMemo(() => { + return { + availableIntervals: validIntervals(props), + storableIntervals: validIntervals({ ...props, comparison: null }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + site, + period, + customFrom, + customTo, + comparison, + customComparisonFrom, + customComparisonTo + ]) + + const isValid = useCallback( + (interval: string | null): interval is Interval => + !!interval && (availableIntervals as string[]).includes(interval), + [availableIntervals] + ) - const isValid = (interval: string | null): interval is string => - !!interval && availableIntervals.includes(interval) + // We skip storing interval selections that are only available + // due to a custom comparison period. E.g. even though `month` + // interval is available when comparing today with a whole year, + // we shouldn't store `interval__day__site.com = month`. + const isStorable = useCallback( + (interval: string | null): interval is Interval => + !!interval && (storableIntervals as string[]).includes(interval), + [storableIntervals] + ) const storedInterval = getStoredInterval(period, site.domain) @@ -110,11 +93,13 @@ export const useStoredInterval = ( }, [availableIntervals]) const onIntervalClick = useCallback( - (interval: string) => { - storeInterval(period, site.domain, interval) + (interval: Interval) => { + if (isStorable(interval)) { + storeInterval(period, site.domain, interval) + } setSelectedInterval(interval) }, - [period, site.domain] + [period, site, isStorable] ) return { @@ -122,7 +107,7 @@ export const useStoredInterval = ( ? selectedInterval : isValid(storedInterval) ? storedInterval - : getDefaultInterval({ to, from, period }, availableIntervals), + : getDefaultInterval(props), onIntervalClick, availableIntervals } @@ -133,9 +118,9 @@ export function IntervalPicker({ onIntervalClick, options }: { - selectedInterval: string - onIntervalClick: (interval: string) => void - options: string[] + selectedInterval: Interval + onIntervalClick: (interval: Interval) => void + options: Interval[] }): JSX.Element | null { const menuElement = useRef(null) const { dashboardState } = useDashboardStateContext() diff --git a/assets/js/dashboard/stats/graph/intervals.test.ts b/assets/js/dashboard/stats/graph/intervals.test.ts new file mode 100644 index 000000000000..4003e30af2c1 --- /dev/null +++ b/assets/js/dashboard/stats/graph/intervals.test.ts @@ -0,0 +1,338 @@ +import { DEFAULT_SITE } from '../../../../test-utils/app-context-providers' +import { ComparisonMode, DashboardPeriod } from '../../dashboard-time-periods' +import { getDefaultInterval, validIntervals } from './intervals' +import dayjs from 'dayjs' + +const site = DEFAULT_SITE + +const noComparison = { + comparison: null, + compare_from: null, + compare_to: null +} + +const noCustom = { + ...noComparison, + from: null, + to: null +} + +describe(`${validIntervals.name}`, () => { + describe('fixed periods', () => { + it('returns [minute] for realtime', () => { + expect( + validIntervals({ site, period: DashboardPeriod.realtime, ...noCustom }) + ).toEqual(['minute']) + }) + + it('returns [minute, hour] for day', () => { + expect( + validIntervals({ site, period: DashboardPeriod.day, ...noCustom }) + ).toEqual(['minute', 'hour']) + }) + + it('returns [minute, hour] for 24h', () => { + expect( + validIntervals({ site, period: DashboardPeriod['24h'], ...noCustom }) + ).toEqual(['minute', 'hour']) + }) + + it('returns [hour, day] for 7d', () => { + expect( + validIntervals({ site, period: DashboardPeriod['7d'], ...noCustom }) + ).toEqual(['hour', 'day']) + }) + + it('returns [day, week, month] for 6mo', () => { + expect( + validIntervals({ site, period: DashboardPeriod['6mo'], ...noCustom }) + ).toEqual(['day', 'week', 'month']) + }) + + it('returns [day, week, month] for 12mo', () => { + expect( + validIntervals({ site, period: DashboardPeriod['12mo'], ...noCustom }) + ).toEqual(['day', 'week', 'month']) + }) + + it('returns [day, week, month] for year', () => { + expect( + validIntervals({ site, period: DashboardPeriod.year, ...noCustom }) + ).toEqual(['day', 'week', 'month']) + }) + }) + + describe('custom period', () => { + it('returns [minute, hour] for one day', () => { + expect( + validIntervals({ + site, + period: DashboardPeriod.custom, + from: dayjs('2024-01-01'), + to: dayjs('2024-01-01'), + ...noComparison + }) + ).toEqual(['minute', 'hour']) + }) + + it('returns [hour, day] for a range of 7 days', () => { + expect( + validIntervals({ + site, + period: DashboardPeriod.custom, + from: dayjs('2024-01-01'), + to: dayjs('2024-01-07'), + ...noComparison + }) + ).toEqual(['hour', 'day']) + }) + + it('returns [day, week] for a range of 8 days', () => { + expect( + validIntervals({ + site, + period: DashboardPeriod.custom, + from: dayjs('2024-01-01'), + to: dayjs('2024-01-08'), + ...noComparison + }) + ).toEqual(['day', 'week']) + }) + + it('returns [day, week] for a range of exactly one month', () => { + expect( + validIntervals({ + site, + period: DashboardPeriod.custom, + from: dayjs('2024-01-01'), + to: dayjs('2024-01-31'), + ...noComparison + }) + ).toEqual(['day', 'week']) + }) + + it('returns [day, week, month] for a range that barely spans two months', () => { + expect( + validIntervals({ + site, + period: DashboardPeriod.custom, + from: dayjs('2024-01-01'), + to: dayjs('2024-02-01'), + ...noComparison + }) + ).toEqual(['day', 'week', 'month']) + }) + + it('returns [day, week, month] for a range of exactly one year', () => { + expect( + validIntervals({ + site, + period: DashboardPeriod.custom, + from: dayjs('2024-01-01'), + to: dayjs('2024-12-31'), + ...noComparison + }) + ).toEqual(['day', 'week', 'month']) + }) + + it('returns [week, month] for a range that exceeds 12 months', () => { + expect( + validIntervals({ + site, + period: DashboardPeriod.custom, + from: dayjs('2024-01-01'), + to: dayjs('2025-01-01'), + ...noComparison + }) + ).toEqual(['week', 'month']) + }) + }) + + describe('custom main vs comparison period', () => { + it('uses custom comparison range when it is coarser than the custom main range', () => { + expect( + validIntervals({ + site, + period: DashboardPeriod.custom, + from: dayjs('2024-06-01'), + to: dayjs('2024-06-02'), + comparison: ComparisonMode.custom, + compare_from: dayjs('2023-01-01'), + compare_to: dayjs('2024-01-01') + }) + ).toEqual(['week', 'month']) + }) + + it('uses custom main range when it is coarser than the custom comparison range', () => { + expect( + validIntervals({ + site, + period: DashboardPeriod.custom, + from: dayjs('2023-01-01'), + to: dayjs('2024-01-01'), + comparison: ComparisonMode.custom, + compare_from: dayjs('2024-06-01'), + compare_to: dayjs('2024-06-02') + }) + ).toEqual(['week', 'month']) + }) + + it('uses custom comparison range when it is coarser than the fixed main period', () => { + expect( + validIntervals({ + site, + period: DashboardPeriod.day, + from: null, + to: null, + comparison: ComparisonMode.custom, + compare_from: dayjs('2023-01-01'), + compare_to: dayjs('2024-01-01') + }) + ).toEqual(['week', 'month']) + }) + + it('uses fixed main period when it is coarser than the custom comparison period', () => { + expect( + validIntervals({ + site, + period: DashboardPeriod['12mo'], + from: null, + to: null, + comparison: ComparisonMode.custom, + compare_from: dayjs('2024-01-01'), + compare_to: dayjs('2024-01-01') + }) + ).toEqual(['day', 'week', 'month']) + }) + }) +}) + +describe(`${getDefaultInterval.name}`, () => { + describe('fixed periods', () => { + it('returns hour for day', () => { + expect( + getDefaultInterval({ site, period: DashboardPeriod.day, ...noCustom }) + ).toBe('hour') + }) + + it('returns hour for 24h', () => { + expect( + getDefaultInterval({ + site, + period: DashboardPeriod['24h'], + ...noCustom + }) + ).toBe('hour') + }) + + it('returns day for 7d', () => { + expect( + getDefaultInterval({ site, period: DashboardPeriod['7d'], ...noCustom }) + ).toBe('day') + }) + + it('returns month for 6mo', () => { + expect( + getDefaultInterval({ + site, + period: DashboardPeriod['6mo'], + ...noCustom + }) + ).toBe('month') + }) + + it('returns month for 12mo', () => { + expect( + getDefaultInterval({ + site, + period: DashboardPeriod['12mo'], + ...noCustom + }) + ).toBe('month') + }) + + it('returns month for year', () => { + expect( + getDefaultInterval({ site, period: DashboardPeriod.year, ...noCustom }) + ).toBe('month') + }) + }) + + describe('custom period', () => { + it('returns hour for a single date', () => { + expect( + getDefaultInterval({ + site, + period: DashboardPeriod.custom, + from: dayjs('2024-01-01'), + to: dayjs('2024-01-01'), + ...noComparison + }) + ).toBe('hour') + }) + + it('returns day for a range under 30 days', () => { + expect( + getDefaultInterval({ + site, + period: DashboardPeriod.custom, + from: dayjs('2024-01-01'), + to: dayjs('2024-01-20'), + ...noComparison + }) + ).toBe('day') + }) + + it('returns week for a range below 6 months', () => { + expect( + getDefaultInterval({ + site, + period: DashboardPeriod.custom, + from: dayjs('2024-01-01'), + to: dayjs('2024-05-31'), + ...noComparison + }) + ).toBe('week') + }) + + it('returns month for a range that barely spans 7 months', () => { + expect( + getDefaultInterval({ + site, + period: DashboardPeriod.custom, + from: dayjs('2023-01-01'), + to: dayjs('2023-07-01'), + ...noComparison + }) + ).toBe('month') + }) + + it('returns day for a fixed 7d period even when comparing with a whole year', () => { + expect( + getDefaultInterval({ + site, + period: DashboardPeriod['7d'], + from: null, + to: null, + comparison: ComparisonMode.custom, + compare_from: dayjs('2024-01-01'), + compare_to: dayjs('2024-12-01') + }) + ).toBe('day') + }) + + it('returns default for comparison range instead when default for main is not appropriate', () => { + expect( + getDefaultInterval({ + site, + period: DashboardPeriod.day, + from: null, + to: null, + comparison: ComparisonMode.custom, + compare_from: dayjs('2024-01-01'), + compare_to: dayjs('2024-12-31') + }) + ).toBe('month') + }) + }) +}) diff --git a/assets/js/dashboard/stats/graph/intervals.ts b/assets/js/dashboard/stats/graph/intervals.ts new file mode 100644 index 000000000000..2c0405882086 --- /dev/null +++ b/assets/js/dashboard/stats/graph/intervals.ts @@ -0,0 +1,211 @@ +import { PlausibleSite } from '../../site-context' +import { DashboardState } from '../../dashboard-state' +import { Dayjs } from 'dayjs' +import { ComparisonMode, DashboardPeriod } from '../../dashboard-time-periods' +import { dateForSite, nowForSite } from '../../util/date' + +export enum Interval { + minute = 'minute', + hour = 'hour', + day = 'day', + week = 'week', + month = 'month' +} + +export type GetIntervalProps = { site: PlausibleSite } & Pick< + DashboardState, + 'period' | 'to' | 'from' | 'comparison' | 'compare_to' | 'compare_from' +> + +type DayjsRange = { from: Dayjs; to: Dayjs } + +type FixedPeriod = Exclude + +const VALID_INTERVALS_BY_FIXED_PERIOD: Record = { + realtime: [Interval.minute], + realtime_30m: [Interval.minute], + day: [Interval.minute, Interval.hour], + '24h': [Interval.minute, Interval.hour], + '7d': [Interval.hour, Interval.day], + '28d': [Interval.day, Interval.week], + '30d': [Interval.day, Interval.week], + '91d': [Interval.day, Interval.week, Interval.month], + month: [Interval.day, Interval.week], + '6mo': [Interval.day, Interval.week, Interval.month], + '12mo': [Interval.day, Interval.week, Interval.month], + year: [Interval.day, Interval.week, Interval.month] +} + +const INTERVAL_COARSENESS: Record = { + [Interval.minute]: 0, + [Interval.hour]: 1, + [Interval.day]: 2, + [Interval.week]: 3, + [Interval.month]: 4 +} + +/** + * Returns the intervals available for the current dashboard state. + * + * When a custom comparison period is active, the valid intervals for both the + * main period and the comparison period are computed independently and the + * coarser set is returned — ensuring the chosen interval is granular enough to + * be meaningful for whichever date range is longer. For all other comparison + * modes the valid intervals are determined solely by the main period. + */ +export function validIntervals({ + site, + period, + to, + from, + comparison, + compare_to, + compare_from +}: GetIntervalProps): Interval[] { + const mainIntervals = validIntervalsForMainPeriod(site, period, from, to) + const comparisonIntervals = validIntervalsForCustomComparison( + comparison, + compare_from, + compare_to + ) + return comparisonIntervals + ? coarser(mainIntervals, comparisonIntervals) + : mainIntervals +} + +/** + * Returns the default interval for the current dashboard state. + * + * The default is always derived from the main period. The only exception is + * when a custom comparison period is active and that period does not support + * the main-period default — in that case the default falls back to whatever is + * appropriate for the comparison date range. + */ +export function getDefaultInterval({ + site, + period, + to, + from, + comparison, + compare_to, + compare_from +}: GetIntervalProps): Interval { + const defaultForMain = defaultForMainPeriod(site, period, from, to) + + const validComparisonIntervals = validIntervalsForCustomComparison( + comparison, + compare_from, + compare_to + ) + + if ( + !validComparisonIntervals || + validComparisonIntervals.includes(defaultForMain) + ) { + return defaultForMain + } else { + return defaultForCustomPeriod({ from: compare_from!, to: compare_to! }) + } +} + +function max_coarseness(intervals: Interval[]): number { + return Math.max(...intervals.map((i) => INTERVAL_COARSENESS[i])) +} + +function coarser(a: Interval[], b: Interval[]): Interval[] { + return max_coarseness(a) >= max_coarseness(b) ? a : b +} + +function validIntervalsForMainPeriod( + site: PlausibleSite, + period: DashboardPeriod, + from: Dayjs | null, + to: Dayjs | null +): Interval[] { + if (period === DashboardPeriod.custom && from && to) { + return validIntervalsForCustomPeriod({ from, to }) + } + if (period === 'all') { + return validIntervalsForAllTimePeriod(site) + } + return VALID_INTERVALS_BY_FIXED_PERIOD[period as FixedPeriod] +} + +function validIntervalsForCustomComparison( + comparison: ComparisonMode | null, + compare_from: Dayjs | null, + compare_to: Dayjs | null +): Interval[] | null { + if (comparison === ComparisonMode.custom && compare_from && compare_to) { + return validIntervalsForCustomPeriod({ from: compare_from, to: compare_to }) + } + return null +} + +function defaultForMainPeriod( + site: PlausibleSite, + period: DashboardPeriod, + from: Dayjs | null, + to: Dayjs | null +): Interval { + if (period === DashboardPeriod.custom && from && to) { + return defaultForCustomPeriod({ from, to }) + } + if (period === 'all') { + return validIntervalsForAllTimePeriod(site).includes(Interval.day) + ? Interval.day + : Interval.month + } + + switch (period) { + case 'day': + return Interval.hour + case '24h': + return Interval.hour + case '7d': + return Interval.day + case '6mo': + return Interval.month + case '12mo': + return Interval.month + case 'year': + return Interval.month + default: + return VALID_INTERVALS_BY_FIXED_PERIOD[period as FixedPeriod][0] + } +} + +function validIntervalsForCustomPeriod({ to, from }: DayjsRange): Interval[] { + if (to.diff(from, 'days') < 1) { + return [Interval.minute, Interval.hour] + } + if (to.diff(from, 'days') < 7) { + return [Interval.hour, Interval.day] + } + if (to.diff(from, 'months') < 1) { + return [Interval.day, Interval.week] + } + if (to.diff(from, 'months') < 12) { + return [Interval.day, Interval.week, Interval.month] + } + return [Interval.week, Interval.month] +} + +function validIntervalsForAllTimePeriod(site: PlausibleSite): Interval[] { + const to = nowForSite(site) + const from = site.statsBegin ? dateForSite(site.statsBegin, site) : to + + return validIntervalsForCustomPeriod({ from, to }) +} + +function defaultForCustomPeriod({ from, to }: DayjsRange): Interval { + if (to.diff(from, 'days') < 1) { + return Interval.hour + } else if (to.diff(from, 'days') < 30) { + return Interval.day + } else if (to.diff(from, 'months') < 6) { + return Interval.week + } else { + return Interval.month + } +} 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..1f81532eb644 --- /dev/null +++ b/assets/js/dashboard/stats/graph/main-graph-data.test.ts @@ -0,0 +1,111 @@ +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: '' +} + +// not current +const nc = () => ({ + isCurrent: false, + isPartial: false, + isDefined: true, + ...seriesValueBase +}) +// current +const c = () => ({ + isCurrent: true, + isPartial: false, + isDefined: 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([nc()])).toEqual([]) + }) + + it('returns empty for a single gap', () => { + expect(getLineSegments([gap()])).toEqual([]) + }) + + it('returns a full segment for two points', () => { + expect(getLineSegments([nc(), nc()])).toEqual([ + { startIndexInclusive: 0, stopIndexExclusive: 2, type: 'full' } + ]) + }) + + it('returns a current segment when connecting to current point', () => { + expect(getLineSegments([nc(), c()])).toEqual([ + { startIndexInclusive: 0, stopIndexExclusive: 2, type: 'current' } + ]) + }) + + it('handles more points when the last point is current', () => { + expect(getLineSegments([nc(), nc(), nc(), c()])).toEqual([ + { startIndexInclusive: 0, stopIndexExclusive: 3, type: 'full' }, + { startIndexInclusive: 2, stopIndexExclusive: 4, type: 'current' } + ]) + }) + + it('handles leading gaps', () => { + expect( + getLineSegments([gap(), gap(), nc(), nc(), nc(), nc(), c()]) + ).toEqual([ + { startIndexInclusive: 2, stopIndexExclusive: 6, type: 'full' }, + { startIndexInclusive: 5, stopIndexExclusive: 7, type: 'current' } + ]) + }) + + it('handles trailing gaps', () => { + expect(getLineSegments([nc(), nc(), nc(), gap(), gap()])).toEqual([ + { startIndexInclusive: 0, stopIndexExclusive: 3, type: 'full' } + ]) + }) +}) 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..9303d83ee35d --- /dev/null +++ b/assets/js/dashboard/stats/graph/main-graph-data.ts @@ -0,0 +1,214 @@ +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), + isCurrent: data.meta.present_index === index + } + } 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 + ), + isCurrent: false + } + } 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' | 'current' +} + +/** + * Creates segments from points of a series. + * + * When a point of data is 'current' (only the last point of the series can be), + * then the line that connects it is dashed. If the 'current' point moves, so + * does the line connecting it. + * + * 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 = curr.isCurrent ? 'current' : '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 + isCurrent: 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..5e73b8246313 --- /dev/null +++ b/assets/js/dashboard/stats/graph/main-graph.tsx @@ -0,0 +1,756 @@ +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' +import { Interval } from './intervals' + +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: Interval +} + +type MainGraphYValues = Readonly< + [ + // first element is comparison series + number | null, + // second element is main series + number | null + ] +> + +type TooltipState = { + x: number + y: number + selectedIndex: number | null + persistent: boolean +} +const initialTooltipState: TooltipState = { + x: 0, + y: 0, + selectedIndex: null, + persistent: false +} + +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(null) + const [tooltip, setTooltip] = useState(initialTooltipState) + const { selectedIndex } = tooltip + const metric = data.query.metrics[0] as Metric + const interval = data.interval + const period = data.period + + useEffect(() => { + setTooltip(initialTooltipState) + }, [data]) + + const { + remappedData, + yMax, + dateIsUnambiguous, + yearIsUnambiguous, + mainPeriodLengthInDays, + mainPeriodLengthInMonths, + 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: [ + comparison.isDefined ? comparison.numericValue : null, + main.isDefined ? main.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, + { current: 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, + roundedPathClass + ), + ...rest + })), + underline: { gradientId: secondaryGradient.id }, + dot: { dotClassName: classNames(sharedDotClass, comparisonDotClass) } + } + + const settings: [SeriesConfig, SeriesConfig] = [ + comparisonSeries, + mainSeries + ] + + const yearIsUnambiguous = isYearUnambiguous({ + site, + startEndLabels: [ + ...mainSeriesStartEndLabels, + ...comparisonSeriesStartEndLabels + ] + }) + const dateIsUnambiguous = isDateUnambiguous({ + startEndLabels: [ + ...mainSeriesStartEndLabels, + ...comparisonSeriesStartEndLabels + ] + }) + const mainPeriodStart = parseNaiveDate(data.query.date_range[0]) + const mainPeriodEnd = parseNaiveDate(data.query.date_range[1]) + const mainPeriodLengthInDays = mainPeriodEnd.diff(mainPeriodStart, 'days') + const mainPeriodLengthInMonths = mainPeriodEnd + .startOf('month') + .diff(mainPeriodStart.startOf('month'), 'months') + + return { + remappedData, + remappedDataInGraphFormat, + yMax, + dateIsUnambiguous, + yearIsUnambiguous, + mainPeriodLengthInDays, + mainPeriodLengthInMonths, + 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, closestPoint, xPointer, yPointer, event }) => { + if (event instanceof PointerEvent && event.pointerType === 'touch') { + return setIsTouchDevice(true) + } + setIsTouchDevice(false) + if (!inHoverableArea || !closestPoint) { + return setTooltip(initialTooltipState) + } + return setTooltip({ + selectedIndex: closestPoint.index, + x: Math.floor(xPointer), + y: Math.floor(yPointer), + persistent: false + }) + }, + [] + ) + + const onGotPointerCapture = useCallback((event: unknown) => { + if (event instanceof PointerEvent && event.pointerType === 'touch') { + return setIsTouchDevice(true) + } + }, []) + + const onPointerEnter = useCallback((event: unknown) => { + if (event instanceof PointerEvent && event.pointerType === 'touch') { + return setIsTouchDevice(true) + } + }, []) + + const onPointerLeave = useCallback(() => { + setTooltip(initialTooltipState) + }, []) + + const showZoomToPeriod = canZoomToPeriod( + interval, + mainPeriodLengthInDays, + mainPeriodLengthInMonths + ) + const selectedDatum = selectedIndex !== null && remappedData[selectedIndex] + + const zoomDate = + showZoomToPeriod && selectedDatum && selectedDatum.main.isDefined + ? selectedDatum.main.timeLabel + : null + + const zoomToPeriod = useCallback( + (date: string) => { + setTooltip(initialTooltipState) + navigate({ + search: (currentSearch) => ({ + ...currentSearch, + date, + period: + interval === Interval.month + ? DashboardPeriod.month + : DashboardPeriod.day + }) + }) + }, + [navigate, interval] + ) + + const onClick = useCallback>( + ({ inHoverableArea, closestPoint }) => { + if (isTouchDevice) { + if (inHoverableArea && closestPoint) { + return setTooltip({ + selectedIndex: closestPoint.index, + x: closestPoint.x, + y: Math.min(...closestPoint.values.filter((y) => y !== null)), + persistent: true + }) + } + return setTooltip(initialTooltipState) + } + if (typeof zoomDate === 'string') { + return zoomToPeriod(zoomDate) + } + }, + [zoomDate, zoomToPeriod, isTouchDevice] + ) + + return ( + + className={showZoomToPeriod && selectedDatum ? 'cursor-pointer' : ''} + highlightedIndex={selectedIndex} + width={width} + height={height} + hoverBuffer={hoverBuffer} + marginTop={marginTop} + marginRight={marginRight} + marginBottom={marginBottom} + defaultMarginLeft={defaultMarginLeft} + settings={settings} + data={remappedDataInGraphFormat} + yMax={yMax} + onPointerEnter={onPointerEnter} + onGotPointerCapture={onGotPointerCapture} + onPointerMove={onPointerMove} + onPointerLeave={onPointerLeave} + onClick={onClick} + yFormat={yFormat} + gradients={gradients} + > + {!!selectedDatum && isTouchDevice !== null && ( + zoomToPeriod(zoomDate) + : undefined + } + /> + )} + + ) +} + +const MainGraphTooltip = ({ + metric, + getFormattedValue, + interval, + period, + shouldShowDate, + shouldShowYear, + maxX, + x, + y, + datum, + showZoomToPeriod, + bucketIndex, + totalBuckets, + persistent, + onClick +}: { + metric: Metric + getFormattedValue: (value: MetricValue) => string + interval: Interval + period: DashboardPeriod + shouldShowYear: boolean + shouldShowDate: boolean + x: number + y: number + datum: GraphDatum + showZoomToPeriod?: boolean + bucketIndex: number + totalBuckets: number + maxX: number + persistent: boolean + onClick?: () => void +}) => { + 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 + interval: Interval + 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 Interval.month: + return formatMonthYYYY(parsedDate) + case Interval.week: + case Interval.day: + return formatDayShort(parsedDate, shouldShowYear) + case Interval.hour: { + const time = formatTime(parsedDate, { + use12HourClock: is12HourClock(), + includeMinutes: false + }) + if (shouldShowDate) { + return `${formatDayShort(parsedDate, shouldShowYear)}, ${time}` + } + return time + } + case Interval.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 + } + } +} + +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 Interval.month: { + const month = getBucketLabel(xValue, { + shouldShowYear, + shouldShowDate, + interval, + period, + bucketIndex, + totalBuckets + }) + return isPartial ? `Partial of ${month}` : month + } + case Interval.week: { + const date = getBucketLabel(xValue, { + shouldShowYear, + shouldShowDate, + interval, + period, + bucketIndex, + totalBuckets + }) + return isPartial ? `Partial week of ${date}` : `Week of ${date}` + } + case Interval.day: + return formatDay(parsedDate, shouldShowYear) + case Interval.hour: { + const time = formatTime(parsedDate, { + use12HourClock: is12HourClock(), + includeMinutes: false + }) + if (shouldShowDate) { + return `${formatDay(parsedDate, shouldShowYear)}, ${time}` + } + return time + } + case Interval.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 + } + } +} + +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') + ) +} + +function canZoomToPeriod( + interval: Interval, + mainPeriodLengthInDays: number, + mainPeriodLengthInMonths: number +) { + return ( + (interval === Interval.day && mainPeriodLengthInDays > 0) || + (interval === Interval.month && mainPeriodLengthInMonths > 0) + ) +} + +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' +const comparisonPathClass = + 'stroke-[rgb(222,221,255)] dark:stroke-[rgb(45,46,76)]' +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-[rgb(222,221,255)] dark:fill-[rgb(45,46,76)]' + +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..7b21320ea6a9 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 @@ -34,10 +35,14 @@ export default function VisitorGraph({ const startOfDay = nowForSite(site).startOf('day') const { selectedInterval, onIntervalClick, availableIntervals } = - useStoredInterval(site, { + useStoredInterval({ + site: site, to: dashboardState.to, from: dashboardState.from, - period: dashboardState.period + period: dashboardState.period, + comparison: dashboardState.comparison, + compare_to: dashboardState.compare_to, + compare_from: dashboardState.compare_from }) const [selectedMetric, setSelectedMetric] = useState( @@ -72,19 +77,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 +280,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') + }) + }) +}) diff --git a/assets/test-utils/app-context-providers.tsx b/assets/test-utils/app-context-providers.tsx index c0287a14d27f..3dd82307e63a 100644 --- a/assets/test-utils/app-context-providers.tsx +++ b/assets/test-utils/app-context-providers.tsx @@ -43,7 +43,6 @@ export const DEFAULT_SITE: PlausibleSite = { background: '', isDbip: false, flags: {}, - validIntervalsByPeriod: {}, shared: false, isConsolidatedView: false } diff --git a/extra/lib/plausible/stats/goal/revenue.ex b/extra/lib/plausible/stats/goal/revenue.ex index 1be42759d667..62fc6350b10e 100644 --- a/extra/lib/plausible/stats/goal/revenue.ex +++ b/extra/lib/plausible/stats/goal/revenue.ex @@ -56,6 +56,10 @@ defmodule Plausible.Stats.Goal.Revenue do query.revenue_currencies[:default] || get_goal_dimension_revenue_currency(query, dimension_values) + format_revenue_metric(value, currency) + end + + def format_revenue_metric(value, currency) do if currency do money = Money.new!(value || 0, currency) diff --git a/lib/plausible/stats/api_query_parser.ex b/lib/plausible/stats/api_query_parser.ex index 1aea69dcb437..53af31245ce8 100644 --- a/lib/plausible/stats/api_query_parser.ex +++ b/lib/plausible/stats/api_query_parser.ex @@ -221,14 +221,14 @@ defmodule Plausible.Stats.ApiQueryParser do end end - defp parse_dimensions(dimensions) when is_list(dimensions) do + def parse_dimensions(dimensions) when is_list(dimensions) do parse_list( dimensions, &parse_dimension_entry(&1, "Invalid dimensions '#{i(dimensions)}'") ) end - defp parse_dimensions(nil), do: {:ok, []} + def parse_dimensions(nil), do: {:ok, []} def parse_order_by(order_by) when is_list(order_by) do parse_list(order_by, &parse_order_by_entry/1) diff --git a/lib/plausible/stats/dashboard/query_parser.ex b/lib/plausible/stats/dashboard/query_parser.ex index 277d947573c6..d15f4f8de055 100644 --- a/lib/plausible/stats/dashboard/query_parser.ex +++ b/lib/plausible/stats/dashboard/query_parser.ex @@ -14,20 +14,23 @@ defmodule Plausible.Stats.Dashboard.QueryParser do @valid_comparison_shorthand_keys Map.keys(@valid_comparison_shorthands) - def parse(params) do + def parse(params, opts \\ []) do with {:ok, input_date_range} <- parse_input_date_range(params), {:ok, relative_date} <- parse_relative_date(params), - {:ok, filters} <- parse_filters(params), + {:ok, dimensions} <- ApiQueryParser.parse_dimensions(params["dimensions"]), + {:ok, filters} <- ApiQueryParser.parse_filters(params["filters"]), {:ok, metrics} <- parse_metrics(params), {:ok, include} <- parse_include(params) do {:ok, ParsedQueryParams.new!(%{ input_date_range: input_date_range, relative_date: relative_date, + dimensions: dimensions, filters: filters, metrics: metrics, include: include, - skip_goal_existence_check: true + skip_goal_existence_check: true, + now: Keyword.get(opts, :now) })} end end @@ -79,6 +82,9 @@ defmodule Plausible.Stats.Dashboard.QueryParser do compare: compare, compare_match_day_of_week: params["include"]["compare_match_day_of_week"] == true, time_labels: params["include"]["time_labels"] == true, + partial_time_labels: params["include"]["partial_time_labels"] == true, + present_index: params["include"]["present_index"] == true, + empty_metrics: params["include"]["empty_metrics"] == true, trim_relative_date_range: true, drop_unavailable_time_on_page: true, drop_unavailable_revenue_metrics: true @@ -114,8 +120,4 @@ defmodule Plausible.Stats.Dashboard.QueryParser do defp parse_include_compare(_) do {:ok, nil} end - - defp parse_filters(%{"filters" => filters}) when is_list(filters) do - Plausible.Stats.ApiQueryParser.parse_filters(filters) - end end diff --git a/lib/plausible/stats/datetime_range.ex b/lib/plausible/stats/datetime_range.ex index a9584038910f..6cee97fa1c0b 100644 --- a/lib/plausible/stats/datetime_range.ex +++ b/lib/plausible/stats/datetime_range.ex @@ -72,4 +72,8 @@ defmodule Plausible.Stats.DateTimeRange do Date.range(first, last) end + + def length(%__MODULE__{first: first, last: last}, unit) do + DateTime.diff(last, first, unit) + end end diff --git a/lib/plausible/stats/interval.ex b/lib/plausible/stats/interval.ex index d5a067d91bad..e86ffbbdbb59 100644 --- a/lib/plausible/stats/interval.ex +++ b/lib/plausible/stats/interval.ex @@ -1,31 +1,19 @@ defmodule Plausible.Stats.Interval do @moduledoc """ - Collection of functions to work with intervals. - - The interval of a query defines the granularity of the data. You can think of - it as a `GROUP BY` clause. Possible values are `minute`, `hour`, `day`, - `week`, and `month`. + [DEPRECATED] Stats API v2 handles "intervals" as time dimensions. + See `Plausible.Stats.ApiQueryParser.parse_dimensions/1`. """ alias Plausible.Stats.{DateTimeRange, Query} - @type t() :: String.t() - @type(opt() :: {:site, Plausible.Site.t()} | {:from, Date.t()}, {:to, Date.t()}) - @type opts :: list(opt()) - @typep period() :: String.t() - - @intervals ~w(minute hour day week month) - @spec list() :: [t()] - def list, do: @intervals + @intervals ["minute", "hour", "day", "week", "month"] - @spec valid?(term()) :: boolean() def valid?(interval) do interval in @intervals end - @spec default_for_query(Query.t()) :: t() @doc """ - Returns the suggested interval (i.e. the time dimension) for the given query. + Returns the suggested interval for a given Stats API v1 (legacy) query. """ def default_for_query(query) @@ -56,60 +44,4 @@ defmodule Plausible.Stats.Interval do :year -> "month" end end - - @valid_by_period %{ - "realtime" => ["minute"], - "day" => ["minute", "hour"], - "24h" => ["minute", "hour"], - "7d" => ["hour", "day"], - "28d" => ["day", "week"], - "30d" => ["day", "week"], - "91d" => ["day", "week", "month"], - "month" => ["day", "week"], - "6mo" => ["day", "week", "month"], - "12mo" => ["day", "week", "month"], - "year" => ["day", "week", "month"], - "custom" => ["day", "week", "month"], - "all" => ["day", "week", "month"] - } - - @spec valid_by_period(opts()) :: map() - def valid_by_period(opts \\ []) do - site = Keyword.fetch!(opts, :site) - - table = - with %Date{} = from <- Keyword.get(opts, :from), - %Date{} = to <- Keyword.get(opts, :to), - true <- abs(Plausible.Times.diff(from, to, :month)) > 12 do - Map.replace(@valid_by_period, "custom", ["week", "month"]) - else - _ -> - @valid_by_period - end - - with %Date{} = stats_start <- Plausible.Sites.stats_start_date(site), - true <- abs(Plausible.Times.diff(Date.utc_today(), stats_start, :month)) > 12 do - Map.replace(table, "all", ["week", "month"]) - else - _ -> - table - end - end - - @spec valid_for_period?(period(), t(), opts()) :: boolean() - @doc """ - Returns whether the given interval is valid for a time period. - - Intervals longer than periods are not supported, e.g. current month stats with - a month interval, or today stats with a week interval. - - There are two dynamic states: - * `custom` period is only applicable with `month` or `week` intervals, - if the `opts[:from]` and `opts[:to]` range difference exceeds 12 months - * `all` period's interval options depend on particular site's `stats_start_date` - - daily interval is excluded if the all-time range exceeds 12 months - """ - def valid_for_period?(period, interval, opts \\ []) do - interval in Map.get(valid_by_period(opts), period, []) - end end diff --git a/lib/plausible/stats/metrics.ex b/lib/plausible/stats/metrics.ex index c4673ae73818..fc603b706e21 100644 --- a/lib/plausible/stats/metrics.ex +++ b/lib/plausible/stats/metrics.ex @@ -7,6 +7,8 @@ defmodule Plausible.Stats.Metrics do use Plausible + @revenue_metrics on_ee(do: Plausible.Stats.Goal.Revenue.revenue_metrics(), else: []) + @all_metrics [ :visitors, :visits, @@ -21,22 +23,36 @@ defmodule Plausible.Stats.Metrics do :time_on_page, :percentage, :scroll_depth - ] ++ on_ee(do: Plausible.Stats.Goal.Revenue.revenue_metrics(), else: []) + ] ++ @revenue_metrics @metric_mappings Enum.into(@all_metrics, %{}, fn metric -> {to_string(metric), metric} end) def metric?(value), do: Enum.member?(@all_metrics, value) on_ee do - def default_value(metric, query, dimensions) - when metric in [:average_revenue, :total_revenue], - do: Plausible.Stats.Goal.Revenue.format_revenue_metric(nil, query, dimensions) + # Default value in a goal breakdown depends on per-row currency + def default_value(metric, query, row_dimensions) when metric in @revenue_metrics do + Plausible.Stats.Goal.Revenue.format_revenue_metric(nil, query, row_dimensions) + end + end + + def default_value(metric, _query, _dimensions), do: default_value(metric) + + on_ee do + # When revenue metrics are queried without event:goal dimension, + # a single default currency is expected. + def default_value(metric, query) when metric in @revenue_metrics do + currency = query.revenue_currencies.default + Plausible.Stats.Goal.Revenue.format_revenue_metric(nil, currency) + end end - def default_value(:visit_duration, _query, _dimensions), do: nil - def default_value(:exit_rate, _query, _dimensions), do: nil - def default_value(:scroll_depth, _query, _dimensions), do: nil - def default_value(:time_on_page, _query, _dimensions), do: nil + def default_value(metric, _query), do: default_value(metric) + + def default_value(:visit_duration), do: nil + def default_value(:exit_rate), do: nil + def default_value(:scroll_depth), do: nil + def default_value(:time_on_page), do: nil @float_metrics [ :views_per_visit, @@ -45,8 +61,8 @@ defmodule Plausible.Stats.Metrics do :conversion_rate, :group_conversion_rate ] - def default_value(metric, _query, _dimensions) when metric in @float_metrics, do: 0.0 - def default_value(_metric, _query, _dimensions), do: 0 + def default_value(metric) when metric in @float_metrics, do: 0.0 + def default_value(_metric), do: 0 def from_string!(str) do Map.fetch!(@metric_mappings, str) diff --git a/lib/plausible/stats/query_builder.ex b/lib/plausible/stats/query_builder.ex index d739b9bb0e88..40dc2a6ae121 100644 --- a/lib/plausible/stats/query_builder.ex +++ b/lib/plausible/stats/query_builder.ex @@ -35,6 +35,7 @@ defmodule Plausible.Stats.QueryBuilder do :ok <- validate_custom_props_access(site, query), :ok <- validate_case_sensitive_filter_modifier(query), :ok <- validate_toplevel_only_filter_dimension(query), + :ok <- validate_time_dimension_granularity(query), :ok <- validate_special_metrics_filters(query), :ok <- validate_behavioral_filters(query), :ok <- validate_filtered_goals_exist(query, parsed_query_params), @@ -329,6 +330,22 @@ defmodule Plausible.Stats.QueryBuilder do end end + @max_hours_for_minute_interval 30 + + defp validate_time_dimension_granularity(query) do + if Time.time_dimension(query) == "time:minute" and + DateTimeRange.length(query.utc_time_range, :minute) > @max_hours_for_minute_interval * 60 do + {:error, + %QueryError{ + code: :invalid_dimensions, + message: + "Invalid dimensions. Dimension `time:minute` is only supported for time ranges up to 30 hours." + }} + else + :ok + end + end + @special_metrics [:conversion_rate, :group_conversion_rate] defp validate_special_metrics_filters(query) do special_metric? = Enum.any?(@special_metrics, &(&1 in query.metrics)) diff --git a/lib/plausible/stats/query_include.ex b/lib/plausible/stats/query_include.ex index 778b6405fdf7..49f72637feeb 100644 --- a/lib/plausible/stats/query_include.ex +++ b/lib/plausible/stats/query_include.ex @@ -4,6 +4,16 @@ defmodule Plausible.Stats.QueryInclude do defstruct imports: false, imports_meta: false, time_labels: false, + # `time_label_result_indices` is a convenience for our main graph component. It + # is not yet ready for a public API release because it should also account for + # breakdowns by multiple dimensions (time + non-time). Also, at this point it is + # still unclear whether `time_labels` will stay in the public API or not. + time_label_result_indices: false, + # Another flag to simplify frontend code by not having to repeat the logic defining + # default values for metrics (especially revenue metrics). + empty_metrics: false, + present_index: false, + partial_time_labels: false, total_rows: false, trim_relative_date_range: false, compare: nil, @@ -19,6 +29,10 @@ defmodule Plausible.Stats.QueryInclude do imports: boolean(), imports_meta: boolean(), time_labels: boolean(), + time_label_result_indices: boolean(), + empty_metrics: boolean(), + present_index: boolean(), + partial_time_labels: boolean(), total_rows: boolean(), trim_relative_date_range: boolean(), compare: diff --git a/lib/plausible/stats/query_result.ex b/lib/plausible/stats/query_result.ex index 66e59ca7f5ce..6f16b760c9a7 100644 --- a/lib/plausible/stats/query_result.ex +++ b/lib/plausible/stats/query_result.ex @@ -11,6 +11,7 @@ defmodule Plausible.Stats.QueryResult do alias Plausible.Stats.{Query, QueryRunner, Filters} defstruct results: [], + comparison_results: nil, meta: %{}, query: nil @@ -43,10 +44,11 @@ defmodule Plausible.Stats.QueryResult do `results` should already-built by Plausible.Stats.QueryRunner """ - def from(%QueryRunner{results: results} = runner) do + def from(%QueryRunner{results: results, comparison_results: comparison_results} = runner) do struct!( __MODULE__, results: results, + comparison_results: comparison_results, meta: meta(runner) |> Jason.OrderedObject.new(), query: query(runner) |> Jason.OrderedObject.new() ) @@ -56,7 +58,14 @@ defmodule Plausible.Stats.QueryResult do %{} |> add_imports_meta(runner.main_query) |> add_metric_warnings_meta(runner.main_query) - |> add_time_labels_meta(runner.main_query) + |> add_empty_metrics_meta(runner.main_query) + |> add_time_labels_meta(runner) + |> add_time_labels_result_indices_meta(runner) + |> add_comparison_time_labels_meta(runner) + |> add_comparison_time_label_result_indices_meta(runner) + |> add_present_index_meta(runner.main_query) + |> add_partial_time_labels_meta(runner.main_query) + |> add_comparison_partial_time_labels_meta(runner) |> add_total_rows_meta(runner.main_query, runner.total_rows) |> Enum.sort_by(&elem(&1, 0)) end @@ -85,7 +94,19 @@ defmodule Plausible.Stats.QueryResult do end end - defp add_time_labels_meta(meta, query) do + defp add_empty_metrics_meta(meta, query) do + if query.include.empty_metrics and "event:goal" not in query.dimensions do + Map.put( + meta, + :empty_metrics, + Enum.map(query.metrics, &Plausible.Stats.Metrics.default_value(&1, query)) + ) + else + meta + end + end + + defp add_time_labels_meta(meta, %QueryRunner{main_query: query}) do if query.include.time_labels do Map.put(meta, :time_labels, Plausible.Stats.Time.time_labels(query)) else @@ -93,6 +114,87 @@ defmodule Plausible.Stats.QueryResult do end end + defp add_comparison_time_labels_meta(meta, %QueryRunner{main_query: query} = runner) do + if query.include.time_labels && query.include.compare do + Map.put( + meta, + :comparison_time_labels, + Plausible.Stats.Time.time_labels(runner.comparison_query) + ) + else + meta + end + end + + defp add_time_labels_result_indices_meta(meta, %QueryRunner{main_query: query} = runner) do + time_labels = meta[:time_labels] + + if query.include.time_label_result_indices and is_list(time_labels) do + Map.put( + meta, + :time_label_result_indices, + result_indices_for_time_labels(time_labels, runner.main_results) + ) + else + meta + end + end + + defp add_comparison_time_label_result_indices_meta( + meta, + %QueryRunner{main_query: query} = runner + ) do + comp_time_labels = meta[:comparison_time_labels] + + if query.include.time_label_result_indices and is_list(comp_time_labels) do + Map.put( + meta, + :comparison_time_label_result_indices, + result_indices_for_time_labels(comp_time_labels, runner.comparison_results) + ) + else + meta + end + end + + defp add_present_index_meta(meta, query) do + time_labels = meta[:time_labels] + + if query.include.present_index and is_list(time_labels) do + Map.put(meta, :present_index, Plausible.Stats.Time.present_index(time_labels, query)) + else + meta + end + end + + defp add_partial_time_labels_meta(meta, query) do + time_labels = meta[:time_labels] + + if query.include.partial_time_labels and is_list(time_labels) do + Map.put( + meta, + :partial_time_labels, + Plausible.Stats.Time.partial_time_labels(time_labels, query) + ) + else + meta + end + end + + defp add_comparison_partial_time_labels_meta(meta, %QueryRunner{main_query: query} = runner) do + comparison_time_labels = meta[:comparison_time_labels] + + if query.include.partial_time_labels and is_list(comparison_time_labels) do + Map.put( + meta, + :comparison_partial_time_labels, + Plausible.Stats.Time.partial_time_labels(comparison_time_labels, runner.comparison_query) + ) + else + meta + end + end + defp add_total_rows_meta(meta, query, total_rows) do if query.include.total_rows do Map.put(meta, :total_rows, total_rows) @@ -209,6 +311,15 @@ defmodule Plausible.Stats.QueryResult do defp metric_warning(_metric, _query), do: nil + defp result_indices_for_time_labels(time_labels, results_list) do + index_lookup_map = + results_list + |> Enum.with_index() + |> Map.new(fn {%{dimensions: [dim]}, idx} -> {dim, idx} end) + + Enum.map(time_labels, &Map.get(index_lookup_map, &1)) + end + defp to_iso8601(datetime, timezone) do datetime |> DateTime.shift_zone!(timezone) @@ -217,8 +328,25 @@ defmodule Plausible.Stats.QueryResult do end defimpl Jason.Encoder, for: Plausible.Stats.QueryResult do - def encode(%Plausible.Stats.QueryResult{results: results, meta: meta, query: query}, opts) do - Jason.OrderedObject.new(results: results, meta: meta, query: query) + def encode( + %Plausible.Stats.QueryResult{ + results: results, + comparison_results: comparison_results, + meta: meta, + query: query + }, + opts + ) do + if comparison_results do + Jason.OrderedObject.new( + results: results, + comparison_results: comparison_results, + meta: meta, + query: query + ) + else + Jason.OrderedObject.new(results: results, meta: meta, query: query) + end |> Jason.Encoder.encode(opts) end end diff --git a/lib/plausible/stats/query_runner.ex b/lib/plausible/stats/query_runner.ex index 8088d54cafea..626289099218 100644 --- a/lib/plausible/stats/query_runner.ex +++ b/lib/plausible/stats/query_runner.ex @@ -87,27 +87,60 @@ defmodule Plausible.Stats.QueryRunner do end end - defp get_time_lookup(query, comparison_query) do - if Time.time_dimension(query) && comparison_query do - Enum.zip( - Time.time_labels(query), - Time.time_labels(comparison_query) - ) - |> Map.new() - else - %{} + # Assembles the final results, optionally attaching comparison data. + # + # Without a comparison, main results are returned as-is and comparison_results + # is nil. + # + # With comparisons, timeseries and non-time-dimension breakdowns are handled + # separately because they have fundamentally different shapes: + # + # - Non-time breakdowns (e.g. by page, source) return one row per dimension + # group. The comparison query is filtered to the same set of dimension + # values as the main query, so every comparison result is guaranteed to + # have a matching main result. Comparison data is merged inline into each + # result row; comparison_results is nil. + # + # - Timeseries (single "time:*" dimension) keep results and comparison_results + # as separate lists of only non-empty rows. Each comparison row carries a + # `change` field computed against the positionally-aligned original bucket + # (or nil when there is no corresponding original bucket). + defp build_results_list(%__MODULE__{main_query: query, main_results: main_results} = runner) do + case {query.include.compare, query.dimensions} do + {nil, _dimensions} -> + struct!(runner, + results: main_results, + comparison_results: nil + ) + + {_non_nil_compare, ["time:" <> _]} -> + struct!(runner, + results: main_results, + comparison_results: build_comparison_results(runner) + ) + + {_non_nil_compare, _dimensions} -> + struct!(runner, + results: merge_with_comparison_results(main_results, runner), + comparison_results: nil + ) end end - defp build_results_list(%__MODULE__{main_query: query, main_results: main_results} = runner) do - results = - case query.dimensions do - ["time:" <> _] -> main_results |> add_empty_timeseries_rows(runner) - _ -> main_results - end - |> merge_with_comparison_results(runner) - - struct!(runner, results: results) + defp build_comparison_results(%__MODULE__{main_query: query} = runner) do + main_map = index_by_dimensions(runner.main_results) + + comp_label_to_main_label = + Enum.zip(Time.time_labels(runner.comparison_query), Time.time_labels(query)) + |> Map.new() + + Enum.map(runner.comparison_results, fn %{dimensions: [comp_label]} = comp_row -> + main_label = Map.get(comp_label_to_main_label, comp_label) + main_metrics = main_label && Map.get(main_map, [main_label]) + change = calculate_metric_changes(query, main_metrics, comp_row.metrics) + + Map.put(comp_row, :change, change) + end) end defp execute_query(query, site) do @@ -181,75 +214,36 @@ defmodule Plausible.Stats.QueryRunner do |> Enum.at(goal_index - 1) end - # Special case: If comparison and single time dimension, add 0 rows - otherwise - # comparisons would not be shown for timeseries with 0 values. - defp add_empty_timeseries_rows(results_list, %__MODULE__{main_query: query}) - when not is_nil(query.include.compare) do - indexed_results = index_by_dimensions(results_list) - - empty_timeseries_rows = - Time.time_labels(query) - |> Enum.reject(fn dimension_value -> Map.has_key?(indexed_results, [dimension_value]) end) - |> Enum.map(fn dimension_value -> - %{ - metrics: empty_metrics(query, [dimension_value]), - dimensions: [dimension_value] - } - end) - - results_list ++ empty_timeseries_rows - end - - defp add_empty_timeseries_rows(results_list, _), do: results_list - defp merge_with_comparison_results(results_list, runner) do - comparison_map = (runner.comparison_results || []) |> index_by_dimensions() - time_lookup = get_time_lookup(runner.main_query, runner.comparison_query) - - Enum.map( - results_list, - &add_comparison_results(&1, runner.main_query, comparison_map, time_lookup) - ) + comparison_map = index_by_dimensions(runner.comparison_results) + Enum.map(results_list, &add_comparison_results(&1, runner.main_query, comparison_map)) end - defp add_comparison_results(row, query, comparison_map, time_lookup) - when not is_nil(query.include.compare) do - dimensions = get_comparison_dimensions(row.dimensions, query, time_lookup) - comparison_metrics = get_comparison_metrics(comparison_map, dimensions, query) + defp add_comparison_results(row, query, comparison_map) do + comparison_metrics = metrics_for_dimension_group(comparison_map, row.dimensions, query) change = Enum.zip([query.metrics, row.metrics, comparison_metrics]) - |> Enum.map(fn {metric, metric_value, comparison_value} -> - Compare.calculate_change(metric, comparison_value, metric_value) + |> Enum.map(fn {metric, main_value, comp_value} -> + Compare.calculate_change(metric, comp_value, main_value) end) Map.merge(row, %{ comparison: %{ - dimensions: dimensions, + dimensions: row.dimensions, metrics: comparison_metrics, change: change } }) end - defp add_comparison_results(row, _, _, _), do: row - - defp get_comparison_dimensions(dimensions, query, time_lookup) do - query.dimensions - |> Enum.zip(dimensions) - |> Enum.map(fn - {"time:" <> _, value} -> time_lookup[value] - {_, value} -> value - end) - end - defp index_by_dimensions(results_list) do results_list |> Map.new(fn entry -> {entry.dimensions, entry.metrics} end) end - defp get_comparison_metrics(comparison_map, dimensions, query) do - Map.get_lazy(comparison_map, dimensions, fn -> empty_metrics(query, dimensions) end) + defp metrics_for_dimension_group(lookup_map, dimensions, query) do + Map.get_lazy(lookup_map, dimensions, fn -> empty_metrics(query, dimensions) end) end defp empty_metrics(query, dimensions) do @@ -257,6 +251,15 @@ defmodule Plausible.Stats.QueryRunner do |> Enum.map(fn metric -> Metrics.default_value(metric, query, dimensions) end) end + defp calculate_metric_changes(query, main_metrics, comparison_metrics) do + if main_metrics do + Enum.zip([query.metrics, main_metrics, comparison_metrics]) + |> Enum.map(fn {metric, main_value, comp_value} -> + Compare.calculate_change(metric, comp_value, main_value) + end) + end + end + defp total_rows([]), do: 0 defp total_rows([first_row | _rest]), do: first_row.total_rows end diff --git a/lib/plausible/stats/time.ex b/lib/plausible/stats/time.ex index 91758784f818..27e7ec0dcba2 100644 --- a/lib/plausible/stats/time.ex +++ b/lib/plausible/stats/time.ex @@ -120,6 +120,109 @@ defmodule Plausible.Stats.Time do |> Enum.map(&format_datetime/1) end + def partial_time_labels(time_labels, query) do + time_dimension = time_dimension(query) + + range_start = to_naive_in_tz!(query.utc_time_range.first, query.timezone) + range_end = to_naive_in_tz!(query.utc_time_range.last, query.timezone) + now = to_naive_in_tz!(query.now, query.timezone) + + cutoff = if NaiveDateTime.before?(now, range_end), do: now, else: range_end + + first_bucket = List.first(time_labels) + last_bucket = List.last(time_labels) + + first_partial? = + case bucket_start(first_bucket, time_dimension) do + nil -> false + start -> NaiveDateTime.after?(range_start, start) + end + + last_partial? = + case bucket_end(last_bucket, time_dimension) do + nil -> false + bucket_end -> NaiveDateTime.after?(bucket_end, cutoff) + end + + [ + if(first_partial?, do: first_bucket), + if(last_partial?, do: last_bucket) + ] + |> Enum.uniq() + |> Enum.reject(&is_nil/1) + end + + defp bucket_start(label, "time:week") do + case Date.from_iso8601(label) do + {:ok, date} -> NaiveDateTime.new!(Date.beginning_of_week(date), ~T[00:00:00]) + _ -> nil + end + end + + defp bucket_start(label, _time_dimension) do + case Date.from_iso8601(label) do + {:ok, date} -> + NaiveDateTime.new!(date, ~T[00:00:00]) + + _ -> + case NaiveDateTime.from_iso8601(label) do + {:ok, naive_datetime} -> naive_datetime + _ -> nil + end + end + end + + defp bucket_end(label, time_dimension) do + shift_unit = + case time_dimension do + "time:month" -> :month + "time:week" -> :week + "time:day" -> :day + "time:hour" -> :hour + "time:minute" -> :minute + end + + case bucket_start(label, time_dimension) do + nil -> nil + start -> NaiveDateTime.shift(start, [{shift_unit, 1}, {:second, -1}]) + end + end + + defp to_naive_in_tz!(utc_datetime, timezone) do + utc_datetime + |> DateTime.shift_zone!(timezone) + |> DateTime.to_naive() + end + + def present_index(time_labels, query) do + now = DateTime.shift_zone!(query.now, query.timezone) + + current_label = + case time_dimension(query) do + "time:month" -> + DateTime.to_date(now) + |> Date.beginning_of_month() + |> Date.to_string() + + "time:week" -> + DateTime.to_date(now) + |> date_or_weekstart(Query.date_range(query)) + |> Date.to_string() + + "time:day" -> + DateTime.to_date(now) + |> Date.to_string() + + "time:hour" -> + Calendar.strftime(now, "%Y-%m-%d %H:00:00") + + "time:minute" -> + Calendar.strftime(now, "%Y-%m-%d %H:%M:00") + end + + Enum.find_index(time_labels, &(&1 == current_label)) + end + def date_or_weekstart(date, date_range) do weekstart = Date.beginning_of_week(date) diff --git a/lib/plausible/stats/timeseries.ex b/lib/plausible/stats/timeseries.ex index 919af51b3353..fdf1e48e61ef 100644 --- a/lib/plausible/stats/timeseries.ex +++ b/lib/plausible/stats/timeseries.ex @@ -7,7 +7,7 @@ defmodule Plausible.Stats.Timeseries do use Plausible use Plausible.ClickhouseRepo - alias Plausible.Stats.{Comparisons, Query, QueryRunner, Metrics, Time, QueryOptimizer} + alias Plausible.Stats.{Query, QueryRunner, Metrics, Time, QueryOptimizer} @time_dimension %{ "month" => "time:month", @@ -28,17 +28,10 @@ defmodule Plausible.Stats.Timeseries do |> Query.set_include(:drop_unavailable_revenue_metrics, true) |> QueryOptimizer.optimize() - comparison_query = - if(query.include.compare, - do: Comparisons.get_comparison_query(query), - else: nil - ) - query_result = QueryRunner.run(site, query) { - build_result(query_result, query, fn entry -> entry end), - build_result(query_result, comparison_query, fn entry -> entry.comparison end), + build_result(query_result, query), query_result.meta } end @@ -47,23 +40,21 @@ defmodule Plausible.Stats.Timeseries do # Given a query result, build a legacy timeseries result # Format is %{ date => %{ date: date_string, [metric] => value } } with a bunch of special cases for the UI - defp build_result(query_result, %Query{} = query, extract_entry) do + defp build_result(query_result, %Query{} = query) do query_result.results - |> Enum.map(&extract_entry.(&1)) - |> Enum.map(fn %{dimensions: [time_dimension_value], metrics: metrics} -> - metrics_map = Enum.zip(query.metrics, metrics) |> Map.new() - - { - time_dimension_value, - Map.put(metrics_map, :date, time_dimension_value) - } + |> Enum.map(fn + %{dimensions: [time_dimension_value], metrics: metrics} -> + metrics_map = Enum.zip(query.metrics, metrics) |> Map.new() + + { + time_dimension_value, + Map.put(metrics_map, :date, time_dimension_value) + } end) |> Map.new() |> add_labels(query) end - defp build_result(_, _, _), do: nil - defp add_labels(results_map, query) do query |> Time.time_labels() diff --git a/lib/plausible_web/controllers/api/external_stats_controller.ex b/lib/plausible_web/controllers/api/external_stats_controller.ex index 77ad190aece5..2ca65f31787d 100644 --- a/lib/plausible_web/controllers/api/external_stats_controller.ex +++ b/lib/plausible_web/controllers/api/external_stats_controller.ex @@ -76,9 +76,10 @@ defmodule PlausibleWeb.Api.ExternalStatsController do @max_breakdown_limit 1000 defp validate_or_default_limit(%{"limit" => limit}) do - with {limit, ""} when limit > 0 and limit <= @max_breakdown_limit <- Integer.parse(limit) do - {:ok, limit} - else + case Integer.parse(limit) do + {limit, ""} when limit > 0 and limit <= @max_breakdown_limit -> + {:ok, limit} + _ -> {:error, "Please provide limit as a number between 1 and #{@max_breakdown_limit}."} end @@ -257,7 +258,7 @@ defmodule PlausibleWeb.Api.ExternalStatsController do :ok <- validate_filters(site, query.filters), {:ok, metrics} <- parse_and_validate_metrics(params, query), :ok <- ensure_custom_props_access(site, query) do - {results, _, meta} = Plausible.Stats.timeseries(site, query, metrics) + {results, meta} = Plausible.Stats.timeseries(site, query, metrics) payload = case meta[:imports_warning] do @@ -313,7 +314,7 @@ defmodule PlausibleWeb.Api.ExternalStatsController do defp validate_period(_), do: :ok @valid_intervals ["day", "month"] - @valid_intervals_str Enum.map(@valid_intervals, &("`" <> &1 <> "`")) |> Enum.join(", ") + @valid_intervals_str Enum.map_join(@valid_intervals, ", ", &("`" <> &1 <> "`")) defp validate_interval(%{"interval" => interval}) do if interval in @valid_intervals do diff --git a/lib/plausible_web/controllers/api/stats_controller.ex b/lib/plausible_web/controllers/api/stats_controller.ex index 4711d1182e8e..1b7d4eb57872 100644 --- a/lib/plausible_web/controllers/api/stats_controller.ex +++ b/lib/plausible_web/controllers/api/stats_controller.ex @@ -11,7 +11,6 @@ defmodule PlausibleWeb.Api.StatsController do Comparisons, Exploration, Filters, - Time, TableDecider, Dashboard, ParsedQueryParams, @@ -33,228 +32,23 @@ defmodule PlausibleWeb.Api.StatsController do def query(conn, params) do site = conn.assigns.site + now = conn.private[:now] - with {:ok, %ParsedQueryParams{} = params} <- Dashboard.QueryParser.parse(params), + with {:ok, %ParsedQueryParams{} = params} <- Dashboard.QueryParser.parse(params, now: now), {:ok, %Query{} = query} <- QueryBuilder.build(site, params, debug_metadata(conn)) do + query = + if query.include.time_labels do + Query.set_include(query, :time_label_result_indices, true) + else + query + end + json(conn, Plausible.Stats.query(site, query)) else {:error, %QueryError{message: message}} -> bad_request(conn, message) end end - @doc """ - Returns a time-series based on given parameters. - - ## Parameters - - This API accepts the following parameters: - - * `period` - x-axis of the graph, e.g. `12mo`, `day`, `custom`. - - * `metric` - y-axis of the graph, e.g. `visits`, `visitors`, `pageviews`. - See the Stats API ["Metrics"](https://plausible.io/docs/stats-api#metrics) - section for more details. Defaults to `visitors`. - - * `interval` - granularity of the time-series data. You can think of it as - a `GROUP BY` clause. Possible values are `minute`, `hour`, `date`, `week`, - and `month`. The default depends on the `period` parameter. Check - `Plausible.Query.from/2` for each default. - - * `filters` - optional filters to drill down data. See the Stats API - ["Filtering"](https://plausible.io/docs/stats-api#filtering) section for - more details. - - * `with_imported` - boolean indicating whether to include Google Analytics - imported data or not. Defaults to `false`. - - Full example: - ```elixir - %{ - "from" => "2021-09-06", - "interval" => "month", - "metric" => "visitors", - "period" => "custom", - "to" => "2021-12-13" - } - ``` - - ## Response - - Returns a map with the following keys: - - * `plot` - list of values for the requested metric representing the y-axis - of the graph. - - * `labels` - list of date times representing the x-axis of the graph. - - * `present_index` - index of the element representing the current date in - `labels` and `plot` lists. - - * `interval` - the interval used for querying. - - * `includes_imported` - boolean indicating whether imported data - was queried or not. - - * `full_intervals` - map of dates indicating whether the interval has been - cut off by the requested date range or not. For example, if looking at a - month week-by-week, some weeks may be cut off by the month boundaries. - It's useful to adjust the graph display slightly in case the interval is - not 'full' so that the user understands why the numbers might be lower for - those partial periods. - - Full example: - ```elixir - %{ - "full_intervals" => %{ - "2021-09-01" => false, - "2021-10-01" => true, - "2021-11-01" => true, - "2021-12-01" => false - }, - "interval" => "month", - "labels" => ["2021-09-01", "2021-10-01", "2021-11-01", "2021-12-01"], - "plot" => [0, 0, 0, 0], - "present_index" => nil, - "includes_imported" => false - } - ``` - - """ - def main_graph(conn, params) do - site = conn.assigns[:site] - now = conn.private[:now] - - with {:ok, dates} <- parse_date_params(params), - :ok <- validate_interval(params), - :ok <- validate_interval_granularity(site, params, dates), - params <- realtime_period_to_30m(params), - query = Query.from(site, params, debug_metadata: debug_metadata(conn), now: now), - query <- Query.set_include(query, :trim_relative_date_range, true), - {:ok, metric} <- parse_and_validate_graph_metric(params, query) do - {timeseries_result, comparison_result, _meta} = Stats.timeseries(site, query, [metric]) - - labels = label_timeseries(timeseries_result, comparison_result) - present_index = present_index_for(site, query, labels) - full_intervals = build_full_intervals(query, labels) - - json(conn, %{ - metric: metric, - plot: plot_timeseries(timeseries_result, metric), - labels: labels, - comparison_plot: comparison_result && plot_timeseries(comparison_result, metric), - comparison_labels: comparison_result && label_timeseries(comparison_result, nil), - present_index: present_index, - full_intervals: full_intervals - }) - else - {:error, message} when is_binary(message) -> bad_request(conn, message) - end - end - - defp plot_timeseries(timeseries, metric) do - Enum.map(timeseries, & &1[metric]) - end - - defp label_timeseries(main_result, nil) do - Enum.map(main_result, & &1.date) - end - - @blank_value "__blank__" - defp label_timeseries(main_result, comparison_result) do - blanks_to_fill = Enum.count(comparison_result) - Enum.count(main_result) - - if blanks_to_fill > 0 do - blanks = List.duplicate(@blank_value, blanks_to_fill) - Enum.map(main_result, & &1.date) ++ blanks - else - Enum.map(main_result, & &1.date) - end - end - - defp build_full_intervals( - %Query{interval: "week"} = query, - labels - ) do - date_range = Query.date_range(query) - build_intervals(labels, date_range, &Date.beginning_of_week/1, &Date.end_of_week/1) - end - - defp build_full_intervals( - %Query{interval: "month"} = query, - labels - ) do - date_range = Query.date_range(query) - build_intervals(labels, date_range, &Date.beginning_of_month/1, &Date.end_of_month/1) - end - - defp build_full_intervals(_query, _labels) do - nil - end - - def build_intervals(labels, date_range, start_fn, end_fn) do - for label <- labels, into: %{} do - case Date.from_iso8601(label) do - {:ok, date} -> - interval_start = start_fn.(date) - interval_end = end_fn.(date) - - within_interval? = - Enum.member?(date_range, interval_start) && Enum.member?(date_range, interval_end) - - {label, within_interval?} - - _ -> - {label, false} - end - end - end - - defp present_index_for(site, query, dates) do - case query.interval do - "hour" -> - current_date = - DateTime.now!(site.timezone) - |> Calendar.strftime("%Y-%m-%d %H:00:00") - - Enum.find_index(dates, &(&1 == current_date)) - - "day" -> - current_date = - DateTime.now!(site.timezone) - |> DateTime.to_date() - |> Date.to_string() - - Enum.find_index(dates, &(&1 == current_date)) - - "week" -> - date_range = Query.date_range(query) - - current_date = - DateTime.now!(site.timezone) - |> DateTime.to_date() - |> Time.date_or_weekstart(date_range) - |> Date.to_string() - - Enum.find_index(dates, &(&1 == current_date)) - - "month" -> - current_date = - DateTime.now!(site.timezone) - |> DateTime.to_date() - |> Date.beginning_of_month() - |> Date.to_string() - - Enum.find_index(dates, &(&1 == current_date)) - - "minute" -> - current_date = - DateTime.now!(site.timezone) - |> Calendar.strftime("%Y-%m-%d %H:%M:00") - - Enum.find_index(dates, &(&1 == current_date)) - end - end - def sources(conn, params) do site = conn.assigns[:site] params = Map.put(params, "property", "visit:source") @@ -1522,75 +1316,6 @@ defmodule PlausibleWeb.Api.StatsController do end) end - defp validate_interval(params) do - with %{"interval" => interval} <- params, - true <- Plausible.Stats.Interval.valid?(interval) do - :ok - else - %{} -> - :ok - - false -> - values = Enum.join(Plausible.Stats.Interval.list(), ", ") - {:error, "Invalid value for interval. Accepted values are: #{values}"} - end - end - - defp validate_interval_granularity(site, params, dates) do - case params do - %{"interval" => interval, "period" => "custom", "from" => _, "to" => _} -> - if Plausible.Stats.Interval.valid_for_period?("custom", interval, - site: site, - from: dates["from"], - to: dates["to"] - ) do - :ok - else - {:error, - "Invalid combination of interval and period. Custom ranges over 12 months must come with greater granularity, e.g. `period=custom,interval=week`"} - end - - %{"interval" => interval, "period" => period} -> - if Plausible.Stats.Interval.valid_for_period?(period, interval, site: site) do - :ok - else - {:error, - "Invalid combination of interval and period. Interval must be smaller than the selected period, e.g. `period=day,interval=minute`"} - end - - _ -> - :ok - end - end - - defp parse_and_validate_graph_metric(params, query) do - metric = - case params["metric"] do - nil -> :visitors - "conversions" -> :visitors - m -> Plausible.Stats.Metrics.from_string!(m) - end - - requires_goal_filter? = metric in [:conversion_rate, :events] - has_goal_filter? = toplevel_goal_filter?(query) - - requires_page_filter? = metric == :scroll_depth - - has_page_filter? = - Filters.filtering_on_dimension?(query, "event:page", behavioral_filters: :ignore) - - cond do - requires_goal_filter? and not has_goal_filter? -> - {:error, "Metric `#{metric}` can only be queried with a goal filter"} - - requires_page_filter? and not has_page_filter? -> - {:error, "Metric `#{metric}` can only be queried with a page filter"} - - true -> - {:ok, metric} - end - end - defp bad_request(conn, message, extra \\ %{}) do payload = Map.merge(extra, %{error: message}) diff --git a/lib/plausible_web/templates/stats/stats.html.heex b/lib/plausible_web/templates/stats/stats.html.heex index 8a30d8cb7052..34d7ead35970 100644 --- a/lib/plausible_web/templates/stats/stats.html.heex +++ b/lib/plausible_web/templates/stats/stats.html.heex @@ -46,9 +46,6 @@ } data-flags={Jason.encode!(@flags)} data-segments={Jason.encode!(@segments)} - data-valid-intervals-by-period={ - Plausible.Stats.Interval.valid_by_period(site: @site) |> Jason.encode!() - } data-is-consolidated-view={Jason.encode!(@consolidated_view?)} data-consolidated-view-available={Jason.encode!(@consolidated_view_available?)} data-exploration-available={Jason.encode!(@exploration_available?)} diff --git a/test/plausible/stats/dashboard/query_parser_test.exs b/test/plausible/stats/dashboard/query_parser_test.exs index d7704ec44660..32b08eaa8dbc 100644 --- a/test/plausible/stats/dashboard/query_parser_test.exs +++ b/test/plausible/stats/dashboard/query_parser_test.exs @@ -249,12 +249,20 @@ defmodule Plausible.Stats.Dashboard.QueryParserTest do params = Map.merge(@base_params, %{"metrics" => []}) assert {:error, %QueryError{code: :invalid_metrics}} = parse(params) end + end - test "now can't be fixed externally" do + describe "fixing now" do + test "now can't be fixed externally via params" do params = Map.merge(@base_params, %{"now" => "2026-02-17T10:08:52.272894Z"}) {:ok, parsed} = parse(params) assert parsed.now == nil end + + test "now can be fixed as an optional extra argument to parse/2" do + {:ok, parsed} = parse(@base_params, now: ~U[2026-02-17 10:08:00Z]) + + assert parsed.now == ~U[2026-02-17 10:08:00Z] + end end end diff --git a/test/plausible/stats/interval_test.exs b/test/plausible/stats/interval_test.exs index e1a78c31a89b..7999d2a49723 100644 --- a/test/plausible/stats/interval_test.exs +++ b/test/plausible/stats/interval_test.exs @@ -39,121 +39,4 @@ defmodule Plausible.Stats.IntervalTest do "hour" end end - - describe "valid_by_period/1" do - test "for a newly created site" do - site = build(:site, stats_start_date: Date.utc_today()) - - assert valid_by_period(site: site) == %{ - "realtime" => ["minute"], - "day" => ["minute", "hour"], - "24h" => ["minute", "hour"], - "month" => ["day", "week"], - "7d" => ["hour", "day"], - "28d" => ["day", "week"], - "30d" => ["day", "week"], - "91d" => ["day", "week", "month"], - "6mo" => ["day", "week", "month"], - "12mo" => ["day", "week", "month"], - "year" => ["day", "week", "month"], - "custom" => ["day", "week", "month"], - "all" => ["day", "week", "month"] - } - end - - test "for a site with stats starting over 12m ago" do - site = build(:site, stats_start_date: Date.shift(Date.utc_today(), month: -13)) - - assert valid_by_period(site: site) == %{ - "realtime" => ["minute"], - "day" => ["minute", "hour"], - "24h" => ["minute", "hour"], - "month" => ["day", "week"], - "7d" => ["hour", "day"], - "28d" => ["day", "week"], - "30d" => ["day", "week"], - "91d" => ["day", "week", "month"], - "6mo" => ["day", "week", "month"], - "12mo" => ["day", "week", "month"], - "year" => ["day", "week", "month"], - "custom" => ["day", "week", "month"], - "all" => ["week", "month"] - } - end - - test "for a query range exceeding 12m" do - ago_13m = Date.shift(Date.utc_today(), month: -13) - site = build(:site, stats_start_date: ago_13m) - - assert valid_by_period(site: site, from: ago_13m, to: Date.utc_today()) == %{ - "realtime" => ["minute"], - "day" => ["minute", "hour"], - "24h" => ["minute", "hour"], - "month" => ["day", "week"], - "7d" => ["hour", "day"], - "28d" => ["day", "week"], - "30d" => ["day", "week"], - "91d" => ["day", "week", "month"], - "6mo" => ["day", "week", "month"], - "12mo" => ["day", "week", "month"], - "year" => ["day", "week", "month"], - "custom" => ["week", "month"], - "all" => ["week", "month"] - } - end - end - - describe "valid_for_period/3" do - test "common" do - site = insert(:site) - assert valid_for_period?("month", "day", site: site) - refute valid_for_period?("30d", "month", site: site) - refute valid_for_period?("realtime", "week", site: site) - end - - test "for a newly created site" do - site = build(:site, stats_start_date: Date.utc_today()) - assert valid_for_period?("all", "day", site: site) - - assert valid_for_period?("custom", "day", - site: site, - from: ~D[2023-06-01], - to: ~D[2023-07-01] - ) - - assert valid_for_period?("custom", "day", - site: site, - to: ~D[2023-06-01], - from: ~D[2023-07-01] - ) - end - - test "for a newly created site with >12m range" do - site = build(:site, stats_start_date: Date.utc_today()) - assert valid_for_period?("all", "day", site: site) - - refute valid_for_period?("custom", "day", - site: site, - from: ~D[2012-06-01], - to: ~D[2023-07-01] - ) - - refute valid_for_period?("custom", "day", - site: site, - to: ~D[2012-06-01], - from: ~D[2023-07-01] - ) - end - - test "for a site with stats starting over 12m ago" do - site = build(:site, stats_start_date: Date.shift(Date.utc_today(), month: -13)) - refute valid_for_period?("all", "day", site: site) - - assert valid_for_period?("custom", "day", - site: site, - from: ~D[2023-06-01], - to: ~D[2023-07-01] - ) - end - end end diff --git a/test/plausible/stats/query/query_comparisons_test.exs b/test/plausible/stats/query/query_comparisons_test.exs index 67d8f9ed2f0a..c77239564e77 100644 --- a/test/plausible/stats/query/query_comparisons_test.exs +++ b/test/plausible/stats/query/query_comparisons_test.exs @@ -48,72 +48,17 @@ defmodule Plausible.Stats.QueryComparisonsTest do include: %QueryInclude{compare: :previous_period} }) - assert %Stats.QueryResult{results: results} = Stats.query(site, query) + assert %Stats.QueryResult{results: results, comparison_results: comparison_results} = + Stats.query(site, query) assert results == [ - %{ - dimensions: ["2021-01-07"], - metrics: [1], - comparison: %{ - dimensions: ["2020-12-31"], - metrics: [0], - change: [100] - } - }, - %{ - dimensions: ["2021-01-08"], - metrics: [1], - comparison: %{ - dimensions: ["2021-01-01"], - metrics: [2], - change: [-50] - } - }, - %{ - dimensions: ["2021-01-09"], - metrics: [0], - comparison: %{ - dimensions: ["2021-01-02"], - metrics: [0], - change: [0] - } - }, - %{ - dimensions: ["2021-01-10"], - metrics: [0], - comparison: %{ - dimensions: ["2021-01-03"], - metrics: [0], - change: [0] - } - }, - %{ - dimensions: ["2021-01-11"], - metrics: [0], - comparison: %{ - dimensions: ["2021-01-04"], - metrics: [0], - change: [0] - } - }, - %{ - dimensions: ["2021-01-12"], - metrics: [0], - comparison: %{ - dimensions: ["2021-01-05"], - metrics: [0], - change: [0] - } - }, - %{ - dimensions: ["2021-01-13"], - metrics: [0], - comparison: %{ - dimensions: ["2021-01-06"], - metrics: [1], - change: [-100] - } - } + %{dimensions: ["2021-01-07"], metrics: [1]}, + %{dimensions: ["2021-01-08"], metrics: [1]} + ] + + assert comparison_results == [ + %{dimensions: ["2021-01-01"], metrics: [2], change: [-50]}, + %{dimensions: ["2021-01-06"], metrics: [1], change: nil} ] end @@ -135,29 +80,21 @@ defmodule Plausible.Stats.QueryComparisonsTest do query2 = Stats.Query.set_include(query1, :compare_match_day_of_week, true) - assert %Stats.QueryResult{results: results1} = Stats.query(site, query1) - assert %Stats.QueryResult{results: results2} = Stats.query(site, query2) + assert %Stats.QueryResult{meta: meta1} = Stats.query(site, query1) + assert %Stats.QueryResult{meta: meta2} = Stats.query(site, query2) - assert results1 == results2 + assert meta1[:time_labels] == meta2[:time_labels] + assert meta1[:comparison_time_labels] == meta2[:comparison_time_labels] expected_first_date = today |> Date.shift(day: -28) |> Date.to_iso8601() expected_last_date = today |> Date.shift(day: -1) |> Date.to_iso8601() expected_comparison_first_date = today |> Date.shift(day: -56) |> Date.to_iso8601() expected_comparison_last_date = today |> Date.shift(day: -29) |> Date.to_iso8601() - assert %{ - dimensions: [actual_first_date], - comparison: %{ - dimensions: [actual_comparison_first_date] - } - } = List.first(results1) - - assert %{ - dimensions: [actual_last_date], - comparison: %{ - dimensions: [actual_comparison_last_date] - } - } = List.last(results1) + actual_first_date = List.first(meta1[:time_labels]) + actual_comparison_first_date = List.first(meta1[:comparison_time_labels]) + actual_last_date = List.last(meta1[:time_labels]) + actual_comparison_last_date = List.last(meta1[:comparison_time_labels]) assert actual_first_date == expected_first_date assert actual_last_date == expected_last_date @@ -189,7 +126,11 @@ defmodule Plausible.Stats.QueryComparisonsTest do now: ~U[2022-07-01 14:00:00Z] }) - assert %Stats.QueryResult{results: results, meta: meta} = Stats.query(site, query) + assert %Stats.QueryResult{ + results: results, + comparison_results: comparison_results, + meta: meta + } = Stats.query(site, query) time_labels = meta[:time_labels] @@ -197,32 +138,17 @@ defmodule Plausible.Stats.QueryComparisonsTest do assert "2022-04-05" = Enum.at(time_labels, 4) assert "2022-06-30" = List.last(time_labels) - assert %{ - dimensions: ["2022-04-01"], - metrics: [1], - comparison: %{ - dimensions: ["2021-04-01"], - metrics: [2] - } - } = Enum.find(results, &(&1[:dimensions] == ["2022-04-01"])) - - assert %{ - dimensions: ["2022-04-05"], - metrics: [1], - comparison: %{ - dimensions: ["2021-04-05"], - metrics: [2] - } - } = Enum.find(results, &(&1[:dimensions] == ["2022-04-05"])) - - assert %{ - dimensions: ["2022-06-30"], - metrics: [1], - comparison: %{ - dimensions: ["2021-06-30"], - metrics: [1] - } - } = Enum.find(results, &(&1[:dimensions] == ["2022-06-30"])) + assert results == [ + %{dimensions: ["2022-04-01"], metrics: [1]}, + %{dimensions: ["2022-04-05"], metrics: [1]}, + %{dimensions: ["2022-06-30"], metrics: [1]} + ] + + assert comparison_results == [ + %{dimensions: ["2021-04-01"], metrics: [2], change: [-50]}, + %{dimensions: ["2021-04-05"], metrics: [2], change: [-50]}, + %{dimensions: ["2021-06-30"], metrics: [1], change: [0]} + ] end test "dimensional comparison with low limit", %{site: site} do diff --git a/test/plausible/stats/query/query_special_metrics_test.exs b/test/plausible/stats/query/query_special_metrics_test.exs index c6a7ce48a8d7..a6c31aa3b622 100644 --- a/test/plausible/stats/query/query_special_metrics_test.exs +++ b/test/plausible/stats/query/query_special_metrics_test.exs @@ -362,27 +362,17 @@ defmodule Plausible.Stats.QuerySpecialMetricsTest do include: %QueryInclude{compare: :previous_period} }) - %Stats.QueryResult{results: results} = Stats.query(site, query) + %Stats.QueryResult{results: results, comparison_results: comparison_results} = + Stats.query(site, query) assert results == [ - %{ - dimensions: ["2021-01-03"], - metrics: [250], - comparison: %{ - dimensions: ["2021-01-01"], - metrics: [nil], - change: [nil] - } - }, - %{ - dimensions: ["2021-01-04"], - metrics: [150], - comparison: %{ - dimensions: ["2021-01-02"], - metrics: [200], - change: [-25] - } - } + %{dimensions: ["2021-01-03"], metrics: [250]}, + %{dimensions: ["2021-01-04"], metrics: [150]} + ] + + assert comparison_results == [ + %{dimensions: ["2021-01-01"], metrics: [nil], change: [nil]}, + %{dimensions: ["2021-01-02"], metrics: [200], change: [-25]} ] end end diff --git a/test/plausible/stats/query/query_test.exs b/test/plausible/stats/query/query_test.exs index 9394f5ce6dc5..4b513f4bfb74 100644 --- a/test/plausible/stats/query/query_test.exs +++ b/test/plausible/stats/query/query_test.exs @@ -126,6 +126,102 @@ defmodule Plausible.Stats.QueryTest do end end + describe "timeseries with comparisons" do + test "returns more original time range buckets than comparison buckets", + %{site: site} do + populate_stats(site, [ + # original time range + build(:pageview, user_id: 123, timestamp: ~N[2026-01-03 00:00:00]), + build(:pageview, user_id: 123, timestamp: ~N[2026-01-03 00:10:00]), + build(:pageview, timestamp: ~N[2026-01-05 00:00:00]), + # comparison time range + build(:pageview, timestamp: ~N[2025-12-16 00:00:00]) + ]) + + {:ok, query} = + QueryBuilder.build(site, %ParsedQueryParams{ + metrics: [:visitors, :pageviews], + input_date_range: {:date_range, ~D[2025-12-25], ~D[2026-01-06]}, + dimensions: ["time:week"], + include: %QueryInclude{ + compare: {:date_range, ~D[2025-12-12], ~D[2025-12-21]}, + time_labels: true, + time_label_result_indices: true + } + }) + + %Stats.QueryResult{results: results, comparison_results: comparison_results, meta: meta} = + Stats.query(site, query) + + assert results == [ + %{dimensions: ["2025-12-29"], metrics: [1, 2]}, + %{dimensions: ["2026-01-05"], metrics: [1, 1]} + ] + + assert comparison_results == [ + %{dimensions: ["2025-12-15"], metrics: [1, 1], change: [0, 100]} + ] + + assert meta[:time_labels] == ["2025-12-25", "2025-12-29", "2026-01-05"] + assert meta[:time_label_result_indices] == [nil, 0, 1] + assert meta[:comparison_time_labels] == ["2025-12-12", "2025-12-15"] + assert meta[:comparison_time_label_result_indices] == [nil, 0] + end + + test "can return more comparison time buckets than original time range buckets", + %{site: site} do + populate_stats(site, [ + # original time range + build(:pageview, user_id: 123, timestamp: ~N[2021-02-01 00:00:00]), + build(:pageview, user_id: 123, timestamp: ~N[2021-02-01 00:10:00]), + build(:pageview, timestamp: ~N[2021-02-01 00:00:00]), + # comparison time range + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-02 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-04 00:00:00]) + ]) + + {:ok, query} = + QueryBuilder.build(site, %ParsedQueryParams{ + metrics: [:visitors, :pageviews], + input_date_range: {:date_range, ~D[2021-02-01], ~D[2021-02-01]}, + dimensions: ["time:day"], + include: %QueryInclude{ + compare: {:date_range, ~D[2021-01-01], ~D[2021-01-05]}, + time_labels: true, + time_label_result_indices: true + } + }) + + %Stats.QueryResult{results: results, comparison_results: comparison_results, meta: meta} = + Stats.query(site, query) + + assert results == [ + %{dimensions: ["2021-02-01"], metrics: [2, 3]} + ] + + assert comparison_results == [ + %{dimensions: ["2021-01-01"], metrics: [2, 2], change: [0, 50]}, + %{dimensions: ["2021-01-02"], metrics: [1, 1], change: nil}, + %{dimensions: ["2021-01-04"], metrics: [1, 1], change: nil} + ] + + assert meta[:time_labels] == ["2021-02-01"] + + assert meta[:comparison_time_labels] == [ + "2021-01-01", + "2021-01-02", + "2021-01-03", + "2021-01-04", + "2021-01-05" + ] + + assert meta[:time_label_result_indices] == [0] + assert meta[:comparison_time_label_result_indices] == [0, 1, nil, 2, nil] + end + end + describe "session smearing respects query date range boundaries" do test "time:hour does not include buckets from outside the query range", %{site: site} do @@ -395,4 +491,114 @@ defmodule Plausible.Stats.QueryTest do assert [%{dimensions: ["2021-01-01 00:00:00"], metrics: [1, 50.0]}] = results end end + + describe "include.empty_metrics" do + test "if not asked for, no empty_metrics are returned under meta", %{site: site} do + {:ok, query} = + QueryBuilder.build(site, %ParsedQueryParams{ + metrics: [:visitors], + input_date_range: :all + }) + + %Stats.QueryResult{meta: meta} = Stats.query(site, query) + + assert is_nil(meta[:empty_metrics]) + end + + for {metric, expected} <- [ + {:visitors, 0}, + {:visits, 0}, + {:pageviews, 0}, + {:views_per_visit, 0.0}, + {:bounce_rate, 0.0}, + {:visit_duration, nil} + ] do + test "default: empty #{metric} is #{expected}", %{site: site} do + {:ok, query} = + QueryBuilder.build(site, %ParsedQueryParams{ + metrics: [unquote(metric)], + input_date_range: :all, + include: %QueryInclude{empty_metrics: true} + }) + + %Stats.QueryResult{meta: meta} = Stats.query(site, query) + + assert meta[:empty_metrics] == [unquote(expected)] + end + end + + for {metric, expected} <- [ + {:time_on_page, nil}, + {:scroll_depth, nil} + ] do + test "page filter: empty #{metric} is #{expected}", %{site: site} do + {:ok, query} = + QueryBuilder.build(site, %ParsedQueryParams{ + metrics: [unquote(metric)], + input_date_range: :all, + include: %QueryInclude{empty_metrics: true}, + filters: [[:is, "event:page", ["/"]]] + }) + + %Stats.QueryResult{meta: meta} = Stats.query(site, query) + + assert meta[:empty_metrics] == [unquote(expected)] + end + end + + for {metric, expected} <- [ + {:events, 0}, + {:group_conversion_rate, 0.0} + ] do + test "goal filter: empty #{metric} is #{expected}", %{site: site} do + insert(:goal, site: site, event_name: "Signup") + + {:ok, query} = + QueryBuilder.build(site, %ParsedQueryParams{ + metrics: [unquote(metric)], + input_date_range: :all, + include: %QueryInclude{empty_metrics: true}, + filters: [[:is, "event:goal", ["Signup"]]] + }) + + %Stats.QueryResult{meta: meta} = Stats.query(site, query) + + assert meta[:empty_metrics] == [unquote(expected)] + end + end + + @tag :ee_only + test "goal filter: empty revenue metrics", %{site: site} do + insert(:goal, site: site, event_name: "Purchase", currency: "EUR") + + {:ok, query} = + QueryBuilder.build(site, %ParsedQueryParams{ + metrics: [:average_revenue, :total_revenue], + input_date_range: :all, + include: %QueryInclude{empty_metrics: true}, + filters: [[:is, "event:goal", ["Purchase"]]] + }) + + %Stats.QueryResult{meta: meta} = Stats.query(site, query) + + assert meta[:empty_metrics] == [ + %{currency: :EUR, long: "€0.00", short: "€0.0", value: 0.0}, + %{currency: :EUR, long: "€0.00", short: "€0.0", value: 0.0} + ] + end + + test "is ignored when event:goal dimension used", %{site: site} do + {:ok, query} = + QueryBuilder.build(site, %ParsedQueryParams{ + metrics: [:visitors], + input_date_range: :all, + include: %QueryInclude{empty_metrics: true}, + dimensions: ["event:goal"] + }) + + %Stats.QueryResult{meta: meta} = Stats.query(site, query) + + assert is_nil(meta[:empty_metrics]) + end + end end diff --git a/test/plausible/stats/time_test.exs b/test/plausible/stats/time_test.exs index ba3dfb7a462b..49ed9d706cba 100644 --- a/test/plausible/stats/time_test.exs +++ b/test/plausible/stats/time_test.exs @@ -4,6 +4,209 @@ defmodule Plausible.Stats.TimeTest do import Plausible.Stats.Time alias Plausible.Stats.DateTimeRange + @now DateTime.utc_now(:second) + + describe "partial_time_labels/2" do + test "returns today as partial_time_label for time:day when today is still incomplete" do + now = ~U[2023-03-01 14:00:00Z] + + assert partial_time_labels(["2023-03-01"], %{ + dimensions: ["time:day"], + utc_time_range: DateTimeRange.new!(~U[2023-03-01 00:00:00Z], now), + now: now, + timezone: "UTC" + }) == ["2023-03-01"] + end + + test "time_label of today is not partial when it's 23:59:59" do + now = ~U[2023-03-01 23:59:59Z] + + assert partial_time_labels(["2023-03-01"], %{ + dimensions: ["time:day"], + utc_time_range: DateTimeRange.new!(~U[2023-03-01 00:00:00Z], now), + now: now, + timezone: "UTC" + }) == [] + end + + test "returns current hour as partial time label when it's incomplete" do + now = ~U[2023-03-01 12:30:00Z] + + assert partial_time_labels(["2023-03-01 12:00:00"], %{ + dimensions: ["time:hour"], + utc_time_range: DateTimeRange.new!(~U[2023-03-01 12:00:00Z], now), + now: now, + timezone: "UTC" + }) == ["2023-03-01 12:00:00"] + end + + test "current hour is not partial when query.now is the last second of the hour" do + now = ~U[2023-03-01 12:59:59Z] + + assert partial_time_labels(["2023-03-01 12:00:00"], %{ + dimensions: ["time:hour"], + utc_time_range: DateTimeRange.new!(~U[2023-03-01 12:00:00Z], now), + now: now, + timezone: "UTC" + }) == [] + end + + test "returns current minute as partial time label when it's incomplete" do + now = ~U[2023-03-01 12:30:30Z] + + assert partial_time_labels(["2023-03-01 12:30:00"], %{ + dimensions: ["time:minute"], + utc_time_range: DateTimeRange.new!(~U[2023-03-01 12:30:00Z], now), + now: now, + timezone: "UTC" + }) == ["2023-03-01 12:30:00"] + end + + test "current minute is not partial when query.now is the last second of the minute" do + now = ~U[2023-03-01 12:30:59Z] + + assert partial_time_labels(["2023-03-01 12:30:00"], %{ + dimensions: ["time:minute"], + utc_time_range: DateTimeRange.new!(~U[2023-03-01 12:30:00Z], now), + now: now, + timezone: "UTC" + }) == [] + end + + test "first bucket is partial when query range starts mid-bucket (e.g. last 24h)" do + # time:day: range starts at 12:30, so the first day only has half a day of data + now = ~U[2023-03-02 12:30:00Z] + + assert partial_time_labels(["2023-03-01", "2023-03-02"], %{ + dimensions: ["time:day"], + utc_time_range: DateTimeRange.new!(~U[2023-03-01 12:30:00Z], now), + now: now, + timezone: "UTC" + }) == ["2023-03-01", "2023-03-02"] + + # time:hour: range starts at 12:30, so the first hour only has 30 minutes of data + now = ~U[2023-03-01 13:30:00Z] + + assert partial_time_labels(["2023-03-01 12:00:00", "2023-03-01 13:00:00"], %{ + dimensions: ["time:hour"], + utc_time_range: DateTimeRange.new!(~U[2023-03-01 12:30:00Z], now), + now: now, + timezone: "UTC" + }) == ["2023-03-01 12:00:00", "2023-03-01 13:00:00"] + end + + test "handles timezone with non-whole-hour UTC offset (IST, UTC+05:30)" do + # 13:30 UTC = 19:00 IST (range starts exactly on the hour, so first bucket is not partial) + # 14:00 UTC = 19:30 IST, so the 19:00 IST hour is still in progress + now = ~U[2023-03-01 14:00:00Z] + + assert partial_time_labels(["2023-03-01 19:00:00"], %{ + dimensions: ["time:hour"], + utc_time_range: DateTimeRange.new!(~U[2023-03-01 13:30:00Z], now), + now: now, + timezone: "Asia/Kolkata" + }) == ["2023-03-01 19:00:00"] + + # 14:30 UTC = 20:00 IST, so the 19:00 IST hour is now complete + now = ~U[2023-03-01 14:30:00Z] + + assert partial_time_labels(["2023-03-01 19:00:00"], %{ + dimensions: ["time:hour"], + utc_time_range: DateTimeRange.new!(~U[2023-03-01 13:30:00Z], now), + now: now, + timezone: "Asia/Kolkata" + }) == [] + end + + test "handles DST transition (America/New_York, UTC-04:00 -> UTC-05:00)" do + # Clocks fall back 02:00 -> 01:00, so 01:xx occurs twice. + # 05:00 UTC = 01:00 EDT (first occurrence, UTC-4) + # 06:00 UTC = 01:00 EST (second occurrence, UTC-5) + now = ~U[2026-11-01 06:30:00Z] + + # 06:30 UTC = 01:30 EST + assert partial_time_labels(["2026-11-01 01:00:00"], %{ + dimensions: ["time:hour"], + utc_time_range: DateTimeRange.new!(~U[2026-11-01 06:00:00Z], now), + now: now, + timezone: "America/New_York" + }) == ["2026-11-01 01:00:00"] + + # 06:59:59 UTC = 01:59:59 EST + now = ~U[2026-11-01 06:59:59Z] + + assert partial_time_labels(["2026-11-01 01:00:00"], %{ + dimensions: ["time:hour"], + utc_time_range: DateTimeRange.new!(~U[2026-11-01 06:00:00Z], now), + now: now, + timezone: "America/New_York" + }) == [] + end + + test "first month bucket is partial if date range start is one second after actual month start" do + assert partial_time_labels(["2023-03-01"], %{ + dimensions: ["time:month"], + utc_time_range: + DateTimeRange.new!(~U[2023-03-01 00:00:01Z], ~U[2023-03-31 23:59:59Z]), + now: @now, + timezone: "UTC" + }) == ["2023-03-01"] + end + + test "last month bucket is partial if date range end is one second before actual month end" do + assert partial_time_labels(["2023-03-01"], %{ + dimensions: ["time:month"], + utc_time_range: + DateTimeRange.new!(~U[2023-03-01 00:00:00Z], ~U[2023-03-31 23:59:58Z]), + now: @now, + timezone: "UTC" + }) == ["2023-03-01"] + end + + test "a month bucket is not partial if date range starts and ends exactly at month start/end" do + assert partial_time_labels(["2023-03-01"], %{ + dimensions: ["time:month"], + utc_time_range: + DateTimeRange.new!(~U[2023-03-01 00:00:00Z], ~U[2023-03-31 23:59:59Z]), + now: @now, + timezone: "UTC" + }) == [] + end + + test "first week bucket is partial if date range start is one second after actual week start" do + # Week of 2023-03-06 (Mon) to 2023-03-12 (Sun) + assert partial_time_labels(["2023-03-06"], %{ + dimensions: ["time:week"], + utc_time_range: + DateTimeRange.new!(~U[2023-03-06 00:00:01Z], ~U[2023-03-12 23:59:59Z]), + now: @now, + timezone: "UTC" + }) == ["2023-03-06"] + end + + test "last week bucket is partial if date range end is one second before actual week end" do + # Week of 2023-03-06 (Mon) to 2023-03-12 (Sun) + assert partial_time_labels(["2023-03-06"], %{ + dimensions: ["time:week"], + utc_time_range: + DateTimeRange.new!(~U[2023-03-06 00:00:00Z], ~U[2023-03-12 23:59:58Z]), + now: @now, + timezone: "UTC" + }) == ["2023-03-06"] + end + + test "a week bucket is not partial if date range starts and ends exactly at week start/end" do + # Week of 2023-03-06 (Mon) to 2023-03-12 (Sun) + assert partial_time_labels(["2023-03-06"], %{ + dimensions: ["time:week"], + utc_time_range: + DateTimeRange.new!(~U[2023-03-06 00:00:00Z], ~U[2023-03-12 23:59:59Z]), + now: @now, + timezone: "UTC" + }) == [] + end + end + describe "time_labels/1" do test "with time:month dimension" do assert time_labels(%{ diff --git a/test/plausible_web/controllers/api/stats_controller/authorization_test.exs b/test/plausible_web/controllers/api/stats_controller/authorization_test.exs index 6813d74ebff4..f0c4a50414ec 100644 --- a/test/plausible_web/controllers/api/stats_controller/authorization_test.exs +++ b/test/plausible_web/controllers/api/stats_controller/authorization_test.exs @@ -32,9 +32,14 @@ defmodule PlausibleWeb.Api.StatsController.AuthorizationTest do test "returns stats for public site", %{conn: conn} do conn = init_session(conn) site = insert(:site, public: true) - conn = get(conn, "/api/stats/#{site.domain}/main-graph") - assert %{"plot" => _any} = json_response(conn, 200) + conn = + post(conn, "/api/stats/#{site.domain}/query", %{ + "date_range" => "day", + "metrics" => ["visitors"] + }) + + assert %{"results" => _} = json_response(conn, 200) end end @@ -196,16 +201,26 @@ defmodule PlausibleWeb.Api.StatsController.AuthorizationTest do test "returns stats for public site", %{conn: conn} do site = new_site(public: true) - conn = get(conn, "/api/stats/#{site.domain}/main-graph") - assert %{"plot" => _any} = json_response(conn, 200) + conn = + post(conn, "/api/stats/#{site.domain}/query", %{ + "date_range" => "day", + "metrics" => ["visitors"] + }) + + assert %{"results" => _} = json_response(conn, 200) end test "returns stats for a private site that the user owns", %{conn: conn, user: user} do site = new_site(public: false, owner: user) - conn = get(conn, "/api/stats/#{site.domain}/main-graph") - assert %{"plot" => _any} = json_response(conn, 200) + conn = + post(conn, "/api/stats/#{site.domain}/query", %{ + "date_range" => "day", + "metrics" => ["visitors"] + }) + + assert %{"results" => _} = json_response(conn, 200) end end end diff --git a/test/plausible_web/controllers/api/stats_controller/debug_metadata_test.exs b/test/plausible_web/controllers/api/stats_controller/debug_metadata_test.exs index 42e379bc639f..282d7b42ae4d 100644 --- a/test/plausible_web/controllers/api/stats_controller/debug_metadata_test.exs +++ b/test/plausible_web/controllers/api/stats_controller/debug_metadata_test.exs @@ -5,10 +5,15 @@ defmodule PlausibleWeb.Api.StatsController.DebugMetadataTest do describe "Debug metadata for logged in requests" do setup [:create_user, :log_in] - test "for main-graph", %{conn: conn, user: user} do + test "for /api/stats/:domain/query", %{conn: conn, user: user} do domain = :rand.bytes(20) |> Base.url_encode64() site = new_site(domain: domain, owner: user) - conn = get(conn, "/api/stats/#{site.domain}/main-graph") + + conn = + post(conn, "/api/stats/#{site.domain}/query", %{ + "date_range" => "day", + "metrics" => ["visitors"] + }) assert json_response(conn, 200) @@ -24,11 +29,16 @@ defmodule PlausibleWeb.Api.StatsController.DebugMetadataTest do decoded = Jason.decode!(unparsed_log_comment) assert_matches ^strict_map(%{ - "params" => ^strict_map(%{"domain" => ^site.domain}), - "phoenix_action" => "main_graph", + "params" => + ^strict_map(%{ + "domain" => ^site.domain, + "date_range" => "day", + "metrics" => ["visitors"] + }), + "phoenix_action" => "query", "phoenix_controller" => "Elixir.PlausibleWeb.Api.StatsController", - "request_method" => "GET", - "request_path" => ^"/api/stats/#{site.domain}/main-graph", + "request_method" => "POST", + "request_path" => ^"/api/stats/#{site.domain}/query", "site_domain" => ^site.domain, "site_id" => ^site.id, "team_id" => ^team_of(user).id, diff --git a/test/plausible_web/controllers/api/stats_controller/imported_test.exs b/test/plausible_web/controllers/api/stats_controller/imported_test.exs index 8db492022f22..733f88483289 100644 --- a/test/plausible_web/controllers/api/stats_controller/imported_test.exs +++ b/test/plausible_web/controllers/api/stats_controller/imported_test.exs @@ -73,18 +73,24 @@ defmodule PlausibleWeb.Api.StatsController.ImportedTest do "imported_visitors" ) - conn = - get( - conn, - "/api/stats/#{site.domain}/main-graph?period=month&date=2021-01-01&with_imported=true" - ) + params = %{ + "date_range" => "month", + "metrics" => ["visitors"], + "relative_date" => "2021-01-01", + "dimensions" => ["time:day"], + "include" => %{"imports" => true, "time_labels" => true} + } - assert %{"plot" => plot} = json_response(conn, 200) + conn = post(conn, "/api/stats/#{site.domain}/query", params) + + response = json_response(conn, 200) - assert Enum.count(plot) == 31 - assert List.first(plot) == 2 - assert List.last(plot) == 2 - assert Enum.sum(plot) == 4 + assert length(response["meta"]["time_labels"]) == 31 + + assert response["results"] == [ + %{"dimensions" => ["2021-01-01"], "metrics" => [2]}, + %{"dimensions" => ["2021-01-31"], "metrics" => [2]} + ] end test "returns data grouped by week", %{conn: conn, site: site, import_id: import_id} do @@ -121,18 +127,24 @@ defmodule PlausibleWeb.Api.StatsController.ImportedTest do "imported_visitors" ) - conn = - get( - conn, - "/api/stats/#{site.domain}/main-graph?period=month&date=2021-01-01&with_imported=true&interval=week" - ) + params = %{ + "date_range" => "month", + "metrics" => ["visitors"], + "relative_date" => "2021-01-01", + "dimensions" => ["time:week"], + "include" => %{"imports" => true, "time_labels" => true} + } - assert %{"plot" => plot} = json_response(conn, 200) + conn = post(conn, "/api/stats/#{site.domain}/query", params) + + response = json_response(conn, 200) - assert Enum.count(plot) == 5 - assert List.first(plot) == 2 - assert List.last(plot) == 2 - assert Enum.sum(plot) == 4 + assert length(response["meta"]["time_labels"]) == 5 + + assert response["results"] == [ + %{"dimensions" => ["2021-01-01"], "metrics" => [2]}, + %{"dimensions" => ["2021-01-25"], "metrics" => [2]} + ] end test "Sources are imported", %{conn: conn, site: site, import_id: import_id} do diff --git a/test/plausible_web/controllers/api/stats_controller/main_graph_test.exs b/test/plausible_web/controllers/api/stats_controller/main_graph_test.exs index 8a22db61af3b..5093a4fd5f3a 100644 --- a/test/plausible_web/controllers/api/stats_controller/main_graph_test.exs +++ b/test/plausible_web/controllers/api/stats_controller/main_graph_test.exs @@ -3,21 +3,47 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do @user_id Enum.random(1000..9999) - describe "GET /api/stats/main-graph - plot" do + defp do_query(conn, site, params, opts \\ []) do + now = Keyword.get(opts, :now) + + conn + |> Plug.Conn.put_private(:now, now) + |> post("/api/stats/#{site.domain}/query", params) + |> json_response(200) + end + + defp do_query_fail(conn, site, params) do + conn + |> post("/api/stats/#{site.domain}/query", params) + end + + describe "plot" do setup [:create_user, :log_in, :create_site, :create_legacy_site_import] test "displays pageviews for the last 30 minutes in realtime graph", %{conn: conn, site: site} do populate_stats(site, [ - build(:pageview, timestamp: relative_time(minute: -5)) + build(:pageview, timestamp: ~N[2024-04-02 03:20:46]) ]) - conn = get(conn, "/api/stats/#{site.domain}/main-graph?period=realtime&metric=pageviews") + response = + do_query( + conn, + site, + %{ + "date_range" => "realtime_30m", + "metrics" => ["pageviews"], + "dimensions" => ["time:minute"], + "include" => %{"time_labels" => true} + }, + now: ~U[2024-04-02 03:27:30Z] + ) - assert %{"plot" => plot, "labels" => labels} = json_response(conn, 200) + %{"results" => results, "meta" => meta} = response - assert labels == Enum.to_list(-30..-1) - assert Enum.count(plot) == 30 - assert Enum.any?(plot, fn pageviews -> pageviews > 0 end) + assert length(meta["time_labels"]) == 30 + assert List.first(meta["time_labels"]) == "2024-04-02 02:57:00" + assert List.last(meta["time_labels"]) == "2024-04-02 03:26:00" + assert [%{"dimensions" => ["2024-04-02 03:20:00"], "metrics" => [1]}] = results end test "displays pageviews for the last 30 minutes for a non-UTC timezone site", %{ @@ -28,16 +54,28 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do |> Plausible.Repo.update() populate_stats(site, [ - build(:pageview, timestamp: relative_time(minute: -5)) + build(:pageview, timestamp: ~N[2024-04-02 03:20:46]) ]) - conn = get(conn, "/api/stats/#{site.domain}/main-graph?period=realtime&metric=pageviews") + response = + do_query( + conn, + site, + %{ + "date_range" => "realtime_30m", + "metrics" => ["pageviews"], + "dimensions" => ["time:minute"], + "include" => %{"time_labels" => true} + }, + now: ~U[2024-04-02 03:27:30Z] + ) - assert %{"plot" => plot, "labels" => labels} = json_response(conn, 200) + %{"results" => results, "meta" => meta} = response - assert labels == Enum.to_list(-30..-1) - assert Enum.count(plot) == 30 - assert Enum.any?(plot, fn pageviews -> pageviews > 0 end) + assert length(meta["time_labels"]) == 30 + assert List.first(meta["time_labels"]) == "2024-04-02 05:57:00" + assert List.last(meta["time_labels"]) == "2024-04-02 06:26:00" + assert [%{"dimensions" => ["2024-04-02 06:20:00"], "metrics" => [1]}] = results end test "displays pageviews for a day", %{conn: conn, site: site} do @@ -46,18 +84,18 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do build(:pageview, timestamp: ~N[2021-01-01 23:00:00]) ]) - conn = - get( - conn, - "/api/stats/#{site.domain}/main-graph?period=day&date=2021-01-01&metric=pageviews" - ) - - assert %{"plot" => plot} = json_response(conn, 200) - - zeroes = List.duplicate(0, 22) - - assert Enum.count(plot) == 24 - assert plot == [1] ++ zeroes ++ [1] + response = + do_query(conn, site, %{ + "date_range" => "day", + "relative_date" => "2021-01-01", + "metrics" => ["pageviews"], + "dimensions" => ["time:hour"] + }) + + assert response["results"] == [ + %{"dimensions" => ["2021-01-01 00:00:00"], "metrics" => [1]}, + %{"dimensions" => ["2021-01-01 23:00:00"], "metrics" => [1]} + ] end test "returns empty plot with no native data and recently imported from ga in realtime graph", @@ -67,14 +105,16 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do build(:imported_visitors, date: Date.utc_today()) ]) - conn = - get( - conn, - "/api/stats/#{site.domain}/main-graph?period=realtime&with_imported=true" - ) + response = + do_query(conn, site, %{ + "date_range" => "realtime_30m", + "metrics" => ["visitors"], + "dimensions" => ["time:minute"], + "include" => %{"imports" => true, "time_labels" => true} + }) - zeroes = List.duplicate(0, 30) - assert %{"plot" => ^zeroes} = json_response(conn, 200) + assert length(response["meta"]["time_labels"]) == 30 + assert response["results"] == [] end test "imported data is not included for hourly interval", %{ @@ -88,15 +128,18 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do build(:imported_visitors, date: ~D[2021-01-31]) ]) - conn = - get( - conn, - "/api/stats/#{site.domain}/main-graph?period=day&date=2021-01-01&with_imported=true" - ) - - assert %{"plot" => plot} = json_response(conn, 200) - - assert plot == [1] ++ List.duplicate(0, 23) + response = + do_query(conn, site, %{ + "date_range" => "day", + "relative_date" => "2021-01-01", + "metrics" => ["visitors"], + "dimensions" => ["time:hour"], + "include" => %{"imports" => true} + }) + + assert response["results"] == [ + %{"dimensions" => ["2021-01-01 00:00:00"], "metrics" => [1]} + ] end test "displays hourly stats in configured timezone", %{conn: conn, user: user} do @@ -112,18 +155,18 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) ]) - conn = - get( - conn, - "/api/stats/#{site.domain}/main-graph?period=day&date=2021-01-01&metric=visitors" - ) - - assert %{"plot" => plot} = json_response(conn, 200) - - zeroes = List.duplicate(0, 22) + response = + do_query(conn, site, %{ + "date_range" => "day", + "relative_date" => "2021-01-01", + "metrics" => ["visitors"], + "dimensions" => ["time:hour"] + }) # Expecting pageview to show at 1am CET - assert plot == [0, 1] ++ zeroes + assert response["results"] == [ + %{"dimensions" => ["2021-01-01 01:00:00"], "metrics" => [1]} + ] end test "displays visitors for a month", %{conn: conn, site: site} do @@ -132,18 +175,21 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do build(:pageview, timestamp: ~N[2021-01-31 00:00:00]) ]) - conn = - get( - conn, - "/api/stats/#{site.domain}/main-graph?period=month&date=2021-01-01&metric=visitors" - ) + response = + do_query(conn, site, %{ + "date_range" => "month", + "relative_date" => "2021-01-01", + "metrics" => ["visitors"], + "include" => %{"time_labels" => true}, + "dimensions" => ["time:day"] + }) - assert %{"plot" => plot} = json_response(conn, 200) + assert length(response["meta"]["time_labels"]) == 31 - assert Enum.count(plot) == 31 - assert List.first(plot) == 1 - assert List.last(plot) == 1 - assert Enum.sum(plot) == 2 + assert response["results"] == [ + %{"dimensions" => ["2021-01-01"], "metrics" => [1]}, + %{"dimensions" => ["2021-01-31"], "metrics" => [1]} + ] end test "displays visitors for last 28d", %{conn: conn, site: site} do @@ -152,18 +198,21 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do build(:pageview, timestamp: ~N[2021-01-28 00:00:00]) ]) - conn = - get( - conn, - "/api/stats/#{site.domain}/main-graph?period=28d&date=2021-01-29&metric=visitors" - ) + response = + do_query(conn, site, %{ + "date_range" => "28d", + "relative_date" => "2021-01-29", + "metrics" => ["visitors"], + "dimensions" => ["time:day"], + "include" => %{"time_labels" => true} + }) - assert %{"plot" => plot} = json_response(conn, 200) + assert length(response["meta"]["time_labels"]) == 28 - assert Enum.count(plot) == 28 - assert List.first(plot) == 1 - assert List.last(plot) == 1 - assert Enum.sum(plot) == 2 + assert response["results"] == [ + %{"dimensions" => ["2021-01-01"], "metrics" => [1]}, + %{"dimensions" => ["2021-01-28"], "metrics" => [1]} + ] end test "displays visitors for last 91d", %{conn: conn, site: site} do @@ -172,18 +221,18 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do build(:pageview, timestamp: ~N[2021-04-16 00:00:00]) ]) - conn = - get( - conn, - "/api/stats/#{site.domain}/main-graph?period=91d&date=2021-04-17&metric=visitors" - ) - - assert %{"plot" => plot} = json_response(conn, 200) - - assert Enum.count(plot) == 91 - assert List.first(plot) == 1 - assert List.last(plot) == 1 - assert Enum.sum(plot) == 2 + response = + do_query(conn, site, %{ + "date_range" => "91d", + "relative_date" => "2021-04-17", + "metrics" => ["visitors"], + "dimensions" => ["time:day"] + }) + + assert response["results"] == [ + %{"dimensions" => ["2021-01-16"], "metrics" => [1]}, + %{"dimensions" => ["2021-04-16"], "metrics" => [1]} + ] end test "displays visitors for a month with imported data", %{conn: conn, site: site} do @@ -194,18 +243,19 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do build(:imported_visitors, date: ~D[2021-01-31]) ]) - conn = - get( - conn, - "/api/stats/#{site.domain}/main-graph?period=month&date=2021-01-01&with_imported=true" - ) - - assert %{"plot" => plot} = json_response(conn, 200) - - assert Enum.count(plot) == 31 - assert List.first(plot) == 2 - assert List.last(plot) == 2 - assert Enum.sum(plot) == 4 + response = + do_query(conn, site, %{ + "date_range" => "month", + "relative_date" => "2021-01-01", + "metrics" => ["visitors"], + "dimensions" => ["time:day"], + "include" => %{"imports" => true} + }) + + assert response["results"] == [ + %{"dimensions" => ["2021-01-01"], "metrics" => [2]}, + %{"dimensions" => ["2021-01-31"], "metrics" => [2]} + ] end test "displays visitors for a month with only imported data", %{conn: conn, site: site} do @@ -214,18 +264,19 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do build(:imported_visitors, date: ~D[2021-01-31]) ]) - conn = - get( - conn, - "/api/stats/#{site.domain}/main-graph?period=month&date=2021-01-01&with_imported=true" - ) - - assert %{"plot" => plot} = json_response(conn, 200) - - assert Enum.count(plot) == 31 - assert List.first(plot) == 1 - assert List.last(plot) == 1 - assert Enum.sum(plot) == 2 + response = + do_query(conn, site, %{ + "date_range" => "month", + "relative_date" => "2021-01-01", + "metrics" => ["visitors"], + "dimensions" => ["time:day"], + "include" => %{"imports" => true} + }) + + assert response["results"] == [ + %{"dimensions" => ["2021-01-01"], "metrics" => [1]}, + %{"dimensions" => ["2021-01-31"], "metrics" => [1]} + ] end test "displays visitors for a month with imported data and filter", %{conn: conn, site: site} do @@ -236,20 +287,20 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do build(:imported_visitors, date: ~D[2021-01-31]) ]) - filters = Jason.encode!([[:is, "event:page", ["/pageA"]]]) - - conn = - get( - conn, - "/api/stats/#{site.domain}/main-graph?period=month&date=2021-01-01&with_imported=true&filters=#{filters}" - ) - - assert %{"plot" => plot} = json_response(conn, 200) - - assert Enum.count(plot) == 31 - assert List.first(plot) == 1 - assert List.last(plot) == 1 - assert Enum.sum(plot) == 2 + response = + do_query(conn, site, %{ + "date_range" => "month", + "relative_date" => "2021-01-01", + "metrics" => ["visitors"], + "dimensions" => ["time:day"], + "filters" => [["is", "event:page", ["/pageA"]]], + "include" => %{"imports" => true} + }) + + assert response["results"] == [ + %{"dimensions" => ["2021-01-01"], "metrics" => [1]}, + %{"dimensions" => ["2021-01-31"], "metrics" => [1]} + ] end test "displays visitors for 6 months with imported data", %{conn: conn, site: site} do @@ -260,18 +311,30 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do build(:imported_visitors, date: ~D[2021-05-31]) ]) - conn = - get( - conn, - "/api/stats/#{site.domain}/main-graph?period=6mo&date=2021-06-30&with_imported=true" - ) + response = + do_query(conn, site, %{ + "date_range" => "6mo", + "relative_date" => "2021-06-30", + "metrics" => ["visitors"], + "dimensions" => ["time:month"], + "include" => %{"imports" => true, "time_labels" => true} + }) + + assert response["meta"]["time_labels"] == [ + "2020-12-01", + "2021-01-01", + "2021-02-01", + "2021-03-01", + "2021-04-01", + "2021-05-01" + ] - assert %{"plot" => plot} = json_response(conn, 200) + assert response["results"] == [ + %{"dimensions" => ["2020-12-01"], "metrics" => [2]}, + %{"dimensions" => ["2021-05-01"], "metrics" => [2]} + ] - assert Enum.count(plot) == 6 - assert List.first(plot) == 2 - assert List.last(plot) == 2 - assert Enum.sum(plot) == 4 + assert response["meta"]["time_label_result_indices"] == [0, nil, nil, nil, nil, 1] end test "displays visitors for 6 months with only imported data", %{conn: conn, site: site} do @@ -280,18 +343,19 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do build(:imported_visitors, date: ~D[2021-05-31]) ]) - conn = - get( - conn, - "/api/stats/#{site.domain}/main-graph?period=6mo&date=2021-06-30&with_imported=true" - ) - - assert %{"plot" => plot} = json_response(conn, 200) - - assert Enum.count(plot) == 6 - assert List.first(plot) == 1 - assert List.last(plot) == 1 - assert Enum.sum(plot) == 2 + response = + do_query(conn, site, %{ + "date_range" => "6mo", + "relative_date" => "2021-06-30", + "metrics" => ["visitors"], + "dimensions" => ["time:month"], + "include" => %{"imports" => true} + }) + + assert response["results"] == [ + %{"dimensions" => ["2020-12-01"], "metrics" => [1]}, + %{"dimensions" => ["2021-05-01"], "metrics" => [1]} + ] end test "displays visitors for 12 months with imported data", %{conn: conn, site: site} do @@ -302,18 +366,21 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do build(:imported_visitors, date: ~D[2021-11-30]) ]) - conn = - get( - conn, - "/api/stats/#{site.domain}/main-graph?period=12mo&date=2021-12-31&with_imported=true" - ) + response = + do_query(conn, site, %{ + "date_range" => "12mo", + "relative_date" => "2021-12-31", + "metrics" => ["visitors"], + "dimensions" => ["time:month"], + "include" => %{"imports" => true, "time_labels" => true} + }) - assert %{"plot" => plot} = json_response(conn, 200) + assert length(response["meta"]["time_labels"]) == 12 - assert Enum.count(plot) == 12 - assert List.first(plot) == 2 - assert List.last(plot) == 2 - assert Enum.sum(plot) == 4 + assert response["results"] == [ + %{"dimensions" => ["2020-12-01"], "metrics" => [2]}, + %{"dimensions" => ["2021-11-01"], "metrics" => [2]} + ] end test "displays visitors for 12 months with only imported data", %{conn: conn, site: site} do @@ -322,18 +389,37 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do build(:imported_visitors, date: ~D[2021-11-30]) ]) - conn = - get( - conn, - "/api/stats/#{site.domain}/main-graph?period=12mo&date=2021-12-31&with_imported=true" - ) + response = + do_query(conn, site, %{ + "date_range" => "12mo", + "relative_date" => "2021-12-31", + "metrics" => ["visitors"], + "dimensions" => ["time:month"], + "include" => %{"imports" => true, "time_labels" => true} + }) + + assert response["meta"]["time_labels"] == [ + "2020-12-01", + "2021-01-01", + "2021-02-01", + "2021-03-01", + "2021-04-01", + "2021-05-01", + "2021-06-01", + "2021-07-01", + "2021-08-01", + "2021-09-01", + "2021-10-01", + "2021-11-01" + ] - assert %{"plot" => plot} = json_response(conn, 200) + assert response["results"] == [ + %{"dimensions" => ["2020-12-01"], "metrics" => [1]}, + %{"dimensions" => ["2021-11-01"], "metrics" => [1]} + ] - assert Enum.count(plot) == 12 - assert List.first(plot) == 1 - assert List.last(plot) == 1 - assert Enum.sum(plot) == 2 + assert response["meta"]["time_label_result_indices"] == + [0] ++ List.duplicate(nil, 10) ++ [1] end test "displays visitors for calendar year with imported data", %{conn: conn, site: site} do @@ -344,18 +430,19 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do build(:imported_visitors, date: ~D[2021-12-31]) ]) - conn = - get( - conn, - "/api/stats/#{site.domain}/main-graph?period=year&date=2021-12-31&with_imported=true" - ) - - assert %{"plot" => plot} = json_response(conn, 200) - - assert Enum.count(plot) == 12 - assert List.first(plot) == 2 - assert List.last(plot) == 2 - assert Enum.sum(plot) == 4 + response = + do_query(conn, site, %{ + "date_range" => "year", + "relative_date" => "2021-12-31", + "metrics" => ["visitors"], + "dimensions" => ["time:month"], + "include" => %{"imports" => true} + }) + + assert response["results"] == [ + %{"dimensions" => ["2021-01-01"], "metrics" => [2]}, + %{"dimensions" => ["2021-12-01"], "metrics" => [2]} + ] end test "displays visitors for calendar year with only imported data", %{conn: conn, site: site} do @@ -364,18 +451,19 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do build(:imported_visitors, date: ~D[2021-12-31]) ]) - conn = - get( - conn, - "/api/stats/#{site.domain}/main-graph?period=year&date=2021-12-31&with_imported=true" - ) - - assert %{"plot" => plot} = json_response(conn, 200) - - assert Enum.count(plot) == 12 - assert List.first(plot) == 1 - assert List.last(plot) == 1 - assert Enum.sum(plot) == 2 + response = + do_query(conn, site, %{ + "date_range" => "year", + "relative_date" => "2021-12-31", + "metrics" => ["visitors"], + "dimensions" => ["time:month"], + "include" => %{"imports" => true} + }) + + assert response["results"] == [ + %{"dimensions" => ["2021-01-01"], "metrics" => [1]}, + %{"dimensions" => ["2021-12-01"], "metrics" => [1]} + ] end test "displays visitors for all time with just native data", %{conn: conn, site: site} do @@ -391,25 +479,59 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do build(:pageview, timestamp: ~N[2021-12-31 00:00:00]) ]) - conn = - get( + response = + do_query( conn, - "/api/stats/#{site.domain}/main-graph?period=all&with_imported=true" + site, + %{ + "date_range" => "all", + "metrics" => ["visitors"], + "dimensions" => ["time:month"], + "include" => %{"imports" => true, "time_labels" => true} + }, + now: ~U[2022-03-15 10:00:00Z] ) - assert %{"plot" => plot} = json_response(conn, 200) + assert length(response["meta"]["time_labels"]) == 27 + assert List.last(response["meta"]["time_labels"]) == "2022-03-01" + + assert response["results"] == [ + %{"dimensions" => ["2020-01-01"], "metrics" => [1]}, + %{"dimensions" => ["2021-01-01"], "metrics" => [1]}, + %{"dimensions" => ["2021-12-01"], "metrics" => [1]} + ] + end + + test "returns empty metrics under response.meta", %{conn: conn, site: site} do + response = + do_query( + conn, + site, + %{ + "date_range" => "28d", + "metrics" => ["visitors"], + "dimensions" => ["time:day"], + "include" => %{"empty_metrics" => true} + } + ) - assert List.first(plot) == 1 - assert Enum.sum(plot) == 3 + assert response["meta"]["empty_metrics"] == [0] end end - describe "GET /api/stats/main-graph - default labels" do + describe "default labels" do setup [:create_user, :log_in, :create_site] test "shows last 30 days", %{conn: conn, site: site} do - conn = get(conn, "/api/stats/#{site.domain}/main-graph?period=30d&metric=visitors") - assert %{"labels" => labels} = json_response(conn, 200) + response = + do_query(conn, site, %{ + "date_range" => "30d", + "metrics" => ["visitors"], + "dimensions" => ["time:day"], + "include" => %{"time_labels" => true} + }) + + labels = response["meta"]["time_labels"] first = Date.utc_today() |> Date.shift(day: -30) |> Date.to_iso8601() last = Date.utc_today() |> Date.shift(day: -1) |> Date.to_iso8601() @@ -418,8 +540,15 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do end test "shows last 7 days", %{conn: conn, site: site} do - conn = get(conn, "/api/stats/#{site.domain}/main-graph?period=7d&metric=visitors") - assert %{"labels" => labels} = json_response(conn, 200) + response = + do_query(conn, site, %{ + "date_range" => "7d", + "metrics" => ["visitors"], + "dimensions" => ["time:day"], + "include" => %{"time_labels" => true} + }) + + labels = response["meta"]["time_labels"] first = Date.utc_today() |> Date.shift(day: -7) |> Date.to_iso8601() last = Date.utc_today() |> Date.shift(day: -1) |> Date.to_iso8601() @@ -428,7 +557,7 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do end end - describe "GET /api/stats/main-graph - pageviews plot" do + describe "pageviews plot" do setup [:create_user, :log_in, :create_site, :create_legacy_site_import] test "displays pageviews for a month", %{conn: conn, site: site} do @@ -438,17 +567,18 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do build(:pageview, timestamp: ~N[2021-01-31 00:00:00]) ]) - conn = - get( - conn, - "/api/stats/#{site.domain}/main-graph?period=month&date=2021-01-01&metric=pageviews" - ) - - assert %{"plot" => plot} = json_response(conn, 200) - - assert Enum.count(plot) == 31 - assert List.first(plot) == 2 - assert List.last(plot) == 1 + response = + do_query(conn, site, %{ + "date_range" => "month", + "relative_date" => "2021-01-01", + "metrics" => ["pageviews"], + "dimensions" => ["time:day"] + }) + + assert response["results"] == [ + %{"dimensions" => ["2021-01-01"], "metrics" => [2]}, + %{"dimensions" => ["2021-01-31"], "metrics" => [1]} + ] end test "displays pageviews for a month with imported data", %{conn: conn, site: site} do @@ -459,18 +589,19 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do build(:imported_visitors, date: ~D[2021-01-31]) ]) - conn = - get( - conn, - "/api/stats/#{site.domain}/main-graph?period=month&date=2021-01-01&metric=pageviews&with_imported=true" - ) - - assert %{"plot" => plot} = json_response(conn, 200) - - assert Enum.count(plot) == 31 - assert List.first(plot) == 2 - assert List.last(plot) == 2 - assert Enum.sum(plot) == 4 + response = + do_query(conn, site, %{ + "date_range" => "month", + "relative_date" => "2021-01-01", + "metrics" => ["pageviews"], + "dimensions" => ["time:day"], + "include" => %{"imports" => true} + }) + + assert response["results"] == [ + %{"dimensions" => ["2021-01-01"], "metrics" => [2]}, + %{"dimensions" => ["2021-01-31"], "metrics" => [2]} + ] end test "displays pageviews for a month with only imported data", %{conn: conn, site: site} do @@ -479,22 +610,84 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do build(:imported_visitors, date: ~D[2021-01-31]) ]) - conn = - get( - conn, - "/api/stats/#{site.domain}/main-graph?period=month&date=2021-01-01&metric=pageviews&with_imported=true" - ) + response = + do_query(conn, site, %{ + "date_range" => "month", + "relative_date" => "2021-01-01", + "metrics" => ["pageviews"], + "dimensions" => ["time:day"], + "include" => %{"imports" => true} + }) + + assert response["results"] == [ + %{"dimensions" => ["2021-01-01"], "metrics" => [1]}, + %{"dimensions" => ["2021-01-31"], "metrics" => [1]} + ] + end + end - assert %{"plot" => plot} = json_response(conn, 200) + describe "views_per_visit plot" do + setup [:create_user, :log_in, :create_site, :create_legacy_site_import] - assert Enum.count(plot) == 31 - assert List.first(plot) == 1 - assert List.last(plot) == 1 - assert Enum.sum(plot) == 2 + test "views_per_visit for 28 days in weekly buckets (native data only)", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, user_id: 1, timestamp: ~N[2021-01-04 00:00:00]), + build(:pageview, user_id: 1, timestamp: ~N[2021-01-04 00:05:00]), + build(:pageview, user_id: 2, timestamp: ~N[2021-01-18 00:00:00]), + build(:pageview, user_id: 2, timestamp: ~N[2021-01-18 00:05:00]), + build(:pageview, user_id: 2, timestamp: ~N[2021-01-18 00:10:00]) + ]) + + response = + do_query(conn, site, %{ + "date_range" => "28d", + "relative_date" => "2021-01-29", + "metrics" => ["views_per_visit"], + "dimensions" => ["time:week"] + }) + + assert response["results"] == [ + %{"dimensions" => ["2021-01-04"], "metrics" => [2.0]}, + %{"dimensions" => ["2021-01-18"], "metrics" => [3.0]} + ] + end + + test "views_per_visit for a year in monthly buckets (with imported data)", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + # January 2021 - only imported + build(:imported_visitors, date: ~D[2021-01-01], visits: 6, pageviews: 7), + # March 2021 - imported + native combined + build(:imported_visitors, date: ~D[2021-03-01], visits: 1, pageviews: 4), + build(:pageview, user_id: 1, timestamp: ~N[2021-03-15 00:00:00]), + build(:pageview, user_id: 1, timestamp: ~N[2021-03-15 00:05:00]), + # September 2021 - only native + build(:pageview, user_id: 2, timestamp: ~N[2021-09-01 00:00:00]) + ]) + + response = + do_query(conn, site, %{ + "date_range" => "year", + "relative_date" => "2021-01-01", + "metrics" => ["views_per_visit"], + "dimensions" => ["time:month"], + "include" => %{"imports" => true} + }) + + assert response["results"] == [ + %{"dimensions" => ["2021-01-01"], "metrics" => [1.17]}, + %{"dimensions" => ["2021-03-01"], "metrics" => [3.0]}, + %{"dimensions" => ["2021-09-01"], "metrics" => [1.0]} + ] end end - describe "GET /api/stats/main-graph - visitors plot" do + describe "visitors plot" do setup [:create_user, :log_in, :create_site, :create_legacy_site_import] test "displays visitors per hour with short visits", %{ @@ -507,17 +700,17 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do build(:pageview, timestamp: ~N[2021-01-01 00:20:00]) ]) - conn = - get( - conn, - "/api/stats/#{site.domain}/main-graph?period=day&date=2021-01-01&metric=visitors&interval=hour" - ) - - assert %{"plot" => plot} = json_response(conn, 200) + response = + do_query(conn, site, %{ + "date_range" => "day", + "relative_date" => "2021-01-01", + "metrics" => ["visitors"], + "dimensions" => ["time:hour"] + }) - assert Enum.count(plot) == 24 - assert List.first(plot) == 2 - assert Enum.sum(plot) == 2 + assert response["results"] == [ + %{"dimensions" => ["2021-01-01 00:00:00"], "metrics" => [2]} + ] end test "displays visitors realtime with visits spanning multiple minutes", %{ @@ -525,24 +718,47 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do site: site } do populate_stats(site, [ - build(:pageview, timestamp: relative_time(minute: -35), user_id: 1), - build(:pageview, timestamp: relative_time(minute: -20), user_id: 1), - build(:pageview, timestamp: relative_time(minute: -25), user_id: 2), - build(:pageview, timestamp: relative_time(minute: -15), user_id: 2), - build(:pageview, timestamp: relative_time(minute: -5), user_id: 3), - build(:pageview, timestamp: relative_time(minute: -3), user_id: 3) + build(:pageview, timestamp: ~N[2023-09-10 15:15:00], user_id: 1), + build(:pageview, timestamp: ~N[2023-09-10 15:30:00], user_id: 1), + build(:pageview, timestamp: ~N[2023-09-10 15:25:00], user_id: 2), + build(:pageview, timestamp: ~N[2023-09-10 15:35:00], user_id: 2), + build(:pageview, timestamp: ~N[2023-09-10 15:45:00], user_id: 3), + build(:pageview, timestamp: ~N[2023-09-10 15:47:00], user_id: 3) ]) - conn = - get( - conn, - "/api/stats/#{site.domain}/main-graph?period=realtime&metric=visitors" - ) - - assert %{"plot" => plot} = json_response(conn, 200) - - expected_plot = ~w[1 1 1 1 1 2 2 2 2 2 2 1 1 1 1 1 0 0 0 0 0 0 0 0 0 1 1 1 0 0] - assert plot == Enum.map(expected_plot, &String.to_integer/1) + response = + do_query( + conn, + site, + %{ + "date_range" => "realtime_30m", + "metrics" => ["visitors"], + "dimensions" => ["time:minute"] + }, + now: ~U[2023-09-10 15:50:01Z] + ) + + assert response["results"] == [ + %{"dimensions" => ["2023-09-10 15:20:00"], "metrics" => [1]}, + %{"dimensions" => ["2023-09-10 15:21:00"], "metrics" => [1]}, + %{"dimensions" => ["2023-09-10 15:22:00"], "metrics" => [1]}, + %{"dimensions" => ["2023-09-10 15:23:00"], "metrics" => [1]}, + %{"dimensions" => ["2023-09-10 15:24:00"], "metrics" => [1]}, + %{"dimensions" => ["2023-09-10 15:25:00"], "metrics" => [2]}, + %{"dimensions" => ["2023-09-10 15:26:00"], "metrics" => [2]}, + %{"dimensions" => ["2023-09-10 15:27:00"], "metrics" => [2]}, + %{"dimensions" => ["2023-09-10 15:28:00"], "metrics" => [2]}, + %{"dimensions" => ["2023-09-10 15:29:00"], "metrics" => [2]}, + %{"dimensions" => ["2023-09-10 15:30:00"], "metrics" => [2]}, + %{"dimensions" => ["2023-09-10 15:31:00"], "metrics" => [1]}, + %{"dimensions" => ["2023-09-10 15:32:00"], "metrics" => [1]}, + %{"dimensions" => ["2023-09-10 15:33:00"], "metrics" => [1]}, + %{"dimensions" => ["2023-09-10 15:34:00"], "metrics" => [1]}, + %{"dimensions" => ["2023-09-10 15:35:00"], "metrics" => [1]}, + %{"dimensions" => ["2023-09-10 15:45:00"], "metrics" => [1]}, + %{"dimensions" => ["2023-09-10 15:46:00"], "metrics" => [1]}, + %{"dimensions" => ["2023-09-10 15:47:00"], "metrics" => [1]} + ] end test "displays visitors per hour with visits spanning multiple hours", %{ @@ -562,83 +778,101 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do build(:pageview, timestamp: ~N[2021-01-02 00:05:00], user_id: 3) ]) - conn = - get( - conn, - "/api/stats/#{site.domain}/main-graph?period=day&date=2021-01-01&metric=visitors&interval=hour" - ) - - assert %{"plot" => plot} = json_response(conn, 200) - - zeroes = List.duplicate(0, 20) - assert [2, 1, 1] ++ zeroes ++ [1] == plot + response = + do_query(conn, site, %{ + "date_range" => "day", + "relative_date" => "2021-01-01", + "metrics" => ["visitors"], + "dimensions" => ["time:hour"] + }) + + assert response["results"] == [ + %{"dimensions" => ["2021-01-01 00:00:00"], "metrics" => [2]}, + %{"dimensions" => ["2021-01-01 01:00:00"], "metrics" => [1]}, + %{"dimensions" => ["2021-01-01 02:00:00"], "metrics" => [1]}, + %{"dimensions" => ["2021-01-01 23:00:00"], "metrics" => [1]} + ] end - test "displays visitors per day with visits showed only in last time bucket", %{ - conn: conn, - site: site - } do + test "displays visitors per day with sessions being counted only in the last time bucket they were active in", + %{ + conn: conn, + site: site + } do populate_stats(site, [ build(:pageview, timestamp: ~N[2020-12-31 23:45:00], user_id: 1), build(:pageview, timestamp: ~N[2021-01-01 00:10:00], user_id: 1), - build(:pageview, timestamp: ~N[2020-01-02 23:45:00], user_id: 2), + build(:pageview, timestamp: ~N[2021-01-02 23:45:00], user_id: 2), build(:pageview, timestamp: ~N[2021-01-03 00:10:00], user_id: 2), - build(:pageview, timestamp: ~N[2020-01-03 23:45:00], user_id: 3), + build(:pageview, timestamp: ~N[2021-01-03 23:45:00], user_id: 3), build(:pageview, timestamp: ~N[2021-01-04 00:10:00], user_id: 3), - build(:pageview, timestamp: ~N[2020-01-07 23:45:00], user_id: 4), + build(:pageview, timestamp: ~N[2021-01-07 23:45:00], user_id: 4), build(:pageview, timestamp: ~N[2021-01-08 00:10:00], user_id: 4) ]) - conn = - get( - conn, - "/api/stats/#{site.domain}/main-graph?period=7d&date=2021-01-08&metric=visitors&interval=day" - ) - - assert %{"plot" => plot} = json_response(conn, 200) - assert plot == [1, 0, 1, 1, 0, 0, 0] + response = + do_query(conn, site, %{ + "date_range" => "7d", + "relative_date" => "2021-01-08", + "metrics" => ["visitors"], + "dimensions" => ["time:day"] + }) + + assert response["results"] == [ + %{"dimensions" => ["2021-01-01"], "metrics" => [1]}, + %{"dimensions" => ["2021-01-03"], "metrics" => [1]}, + %{"dimensions" => ["2021-01-04"], "metrics" => [1]}, + %{"dimensions" => ["2021-01-07"], "metrics" => [1]} + ] end - test "displays visitors per week with visits showed only in last time bucket", %{ - conn: conn, - site: site - } do + test "displays visitors per week with sessions being counted only in the last time bucket they were active in", + %{ + conn: conn, + site: site + } do populate_stats(site, [ build(:pageview, timestamp: ~N[2020-12-31 23:45:00], user_id: 1), build(:pageview, timestamp: ~N[2021-01-01 00:10:00], user_id: 1), - build(:pageview, timestamp: ~N[2020-01-03 23:45:00], user_id: 2), + build(:pageview, timestamp: ~N[2021-01-03 23:45:00], user_id: 2), build(:pageview, timestamp: ~N[2021-01-04 00:10:00], user_id: 2), build(:pageview, timestamp: ~N[2021-01-31 23:45:00], user_id: 3), build(:pageview, timestamp: ~N[2021-02-01 00:05:00], user_id: 3) ]) - conn = - get( - conn, - "/api/stats/#{site.domain}/main-graph?period=month&date=2021-01-01&metric=visitors&interval=week" - ) - - assert %{"plot" => plot} = json_response(conn, 200) - - assert plot == [1, 1, 0, 0, 1] + response = + do_query(conn, site, %{ + "date_range" => "month", + "relative_date" => "2021-01-01", + "metrics" => ["visitors"], + "dimensions" => ["time:week"] + }) + + assert response["results"] == [ + %{"dimensions" => ["2021-01-01"], "metrics" => [1]}, + %{"dimensions" => ["2021-01-04"], "metrics" => [1]}, + %{"dimensions" => ["2021-01-25"], "metrics" => [1]} + ] end end - describe "GET /api/stats/main-graph - scroll_depth plot" do + describe "scroll_depth plot" do setup [:create_user, :log_in, :create_site] test "returns 400 when scroll_depth is queried without a page filter", %{ conn: conn, site: site } do - conn = - get( - conn, - "/api/stats/#{site.domain}/main-graph?period=month&date=2021-01-01&metric=scroll_depth" - ) + response = + do_query_fail(conn, site, %{ + "date_range" => "month", + "relative_date" => "2021-01-01", + "metrics" => ["scroll_depth"], + "dimensions" => ["time:day"] + }) - assert %{"error" => error} = json_response(conn, 400) - assert error =~ "can only be queried with a page filter" + assert %{"error" => error} = json_response(response, 400) + assert error =~ "can only be queried with event:page filters or dimensions" end test "returns scroll depth per day", %{conn: conn, site: site} do @@ -660,17 +894,19 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do ) ]) - filters = Jason.encode!([[:is, "event:page", ["/"]]]) - - conn = - get( - conn, - "/api/stats/#{site.domain}/main-graph?period=7d&date=2020-01-08&metric=scroll_depth&filters=#{filters}" - ) - - assert %{"plot" => plot} = json_response(conn, 200) - - assert plot == [40, 20, nil, nil, nil, nil, nil] + response = + do_query(conn, site, %{ + "date_range" => "7d", + "relative_date" => "2020-01-08", + "metrics" => ["scroll_depth"], + "dimensions" => ["time:day"], + "filters" => [["is", "event:page", ["/"]]] + }) + + assert response["results"] == [ + %{"dimensions" => ["2020-01-01"], "metrics" => [40]}, + %{"dimensions" => ["2020-01-02"], "metrics" => [20]} + ] end test "returns scroll depth per day with imported data", %{conn: conn, site: site} do @@ -705,35 +941,41 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do build(:imported_pages, date: ~D[2020-01-03], page: "/", visitors: 100) ]) - filters = Jason.encode!([[:is, "event:page", ["/"]]]) - - conn = - get( - conn, - "/api/stats/#{site.domain}/main-graph?period=7d&date=2020-01-08&metric=scroll_depth&filters=#{filters}&with_imported=true" - ) - - assert %{"plot" => plot} = json_response(conn, 200) - - assert plot == [40, 30, 90, nil, nil, nil, nil] + response = + do_query(conn, site, %{ + "date_range" => "7d", + "relative_date" => "2020-01-08", + "metrics" => ["scroll_depth"], + "dimensions" => ["time:day"], + "filters" => [["is", "event:page", ["/"]]], + "include" => %{"imports" => true} + }) + + assert response["results"] == [ + %{"dimensions" => ["2020-01-01"], "metrics" => [40]}, + %{"dimensions" => ["2020-01-02"], "metrics" => [30]}, + %{"dimensions" => ["2020-01-03"], "metrics" => [90]} + ] end end - describe "GET /api/stats/main-graph - conversion_rate plot" do + describe "conversion_rate plot" do setup [:create_user, :log_in, :create_site] test "returns 400 when conversion rate is queried without a goal filter", %{ conn: conn, site: site } do - conn = - get( - conn, - "/api/stats/#{site.domain}/main-graph?period=month&date=2021-01-01&metric=conversion_rate" - ) + response = + do_query_fail(conn, site, %{ + "date_range" => "month", + "relative_date" => "2021-01-01", + "metrics" => ["conversion_rate"], + "dimensions" => ["time:day"] + }) - assert %{"error" => error} = json_response(conn, 400) - assert error =~ "can only be queried with a goal filter" + assert %{"error" => error} = json_response(response, 400) + assert error =~ "can only be queried with event:goal filters or dimensions" end test "displays conversion_rate for a month", %{conn: conn, site: site} do @@ -747,40 +989,25 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do build(:event, name: "Signup", timestamp: ~N[2021-01-31 00:00:00]) ]) - filters = Jason.encode!([[:is, "event:goal", ["Signup"]]]) - - conn = - get( - conn, - "/api/stats/#{site.domain}/main-graph?period=month&date=2021-01-01&metric=conversion_rate&filters=#{filters}" - ) - - assert %{"plot" => plot} = json_response(conn, 200) - assert Enum.count(plot) == 31 - - assert List.first(plot) == 33.33 - assert Enum.at(plot, 10) == 0.0 - assert List.last(plot) == 50.0 + response = + do_query(conn, site, %{ + "date_range" => "month", + "relative_date" => "2021-01-01", + "metrics" => ["group_conversion_rate"], + "dimensions" => ["time:day"], + "filters" => [["is", "event:goal", ["Signup"]]] + }) + + assert response["results"] == [ + %{"dimensions" => ["2021-01-01"], "metrics" => [33.33]}, + %{"dimensions" => ["2021-01-31"], "metrics" => [50.0]} + ] end end - describe "GET /api/stats/main-graph - events (total conversions) plot" do + describe "events (total conversions) plot" do setup [:create_user, :log_in, :create_site] - test "returns 400 when the `events` metric is queried without a goal filter", %{ - conn: conn, - site: site - } do - conn = - get( - conn, - "/api/stats/#{site.domain}/main-graph?period=month&date=2021-01-01&metric=events" - ) - - assert %{"error" => error} = json_response(conn, 400) - assert error =~ "`events` can only be queried with a goal filter" - end - test "displays total conversions for a goal", %{conn: conn, site: site} do insert(:goal, site: site, event_name: "Signup") @@ -794,20 +1021,19 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do build(:event, name: "Signup", user_id: 123, timestamp: ~N[2021-01-31 00:00:00]) ]) - filters = Jason.encode!([[:is, "event:goal", ["Signup"]]]) - - conn = - get( - conn, - "/api/stats/#{site.domain}/main-graph?period=month&date=2021-01-01&metric=events&filters=#{filters}" - ) - - assert %{"plot" => plot} = json_response(conn, 200) - assert Enum.count(plot) == 31 - - assert List.first(plot) == 2 - assert Enum.at(plot, 10) == 0.0 - assert List.last(plot) == 3 + response = + do_query(conn, site, %{ + "date_range" => "month", + "relative_date" => "2021-01-01", + "metrics" => ["events"], + "dimensions" => ["time:day"], + "filters" => [["is", "event:goal", ["Signup"]]] + }) + + assert response["results"] == [ + %{"dimensions" => ["2021-01-01"], "metrics" => [2]}, + %{"dimensions" => ["2021-01-31"], "metrics" => [3]} + ] end test "displays total conversions per hour with previous day comparison plot", %{ @@ -827,17 +1053,26 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do build(:event, name: "Signup", timestamp: ~N[2021-01-11 18:00:00]) ]) - filters = Jason.encode!([[:is, "event:goal", ["Signup"]]]) - - conn = - get( - conn, - "/api/stats/#{site.domain}/main-graph?period=day&date=2021-01-11&metric=events&filters=#{filters}&comparison=previous_period" - ) + %{"results" => results, "comparison_results" => comparison_results} = + do_query(conn, site, %{ + "date_range" => "day", + "relative_date" => "2021-01-11", + "metrics" => ["events"], + "dimensions" => ["time:hour"], + "filters" => [["is", "event:goal", ["Signup"]]], + "include" => %{"compare" => "previous_period"} + }) + + assert results == [ + %{"dimensions" => ["2021-01-11 04:00:00"], "metrics" => [1]}, + %{"dimensions" => ["2021-01-11 05:00:00"], "metrics" => [1]}, + %{"dimensions" => ["2021-01-11 18:00:00"], "metrics" => [1]} + ] - assert %{"plot" => curr, "comparison_plot" => prev} = json_response(conn, 200) - assert [0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0] = prev - assert [0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0] = curr + assert comparison_results == [ + %{"dimensions" => ["2021-01-10 05:00:00"], "metrics" => [2], "change" => [-50]}, + %{"dimensions" => ["2021-01-10 19:00:00"], "metrics" => [1], "change" => nil} + ] end test "displays conversions per month with 12mo comparison plot", %{ @@ -857,41 +1092,52 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do build(:event, name: "Signup", timestamp: ~N[2021-07-11 00:00:00]) ]) - filters = Jason.encode!([[:is, "event:goal", ["Signup"]]]) - - conn = - get( - conn, - "/api/stats/#{site.domain}/main-graph?period=12mo&date=2021-12-11&metric=events&filters=#{filters}&comparison=previous_period" - ) + %{"results" => results, "comparison_results" => comparison_results} = + do_query(conn, site, %{ + "date_range" => "12mo", + "relative_date" => "2021-12-11", + "metrics" => ["events"], + "dimensions" => ["time:month"], + "filters" => [["is", "event:goal", ["Signup"]]], + "include" => %{"compare" => "previous_period"} + }) + + assert results == [ + %{"dimensions" => ["2021-05-01"], "metrics" => [1]}, + %{"dimensions" => ["2021-06-01"], "metrics" => [1]}, + %{"dimensions" => ["2021-07-01"], "metrics" => [1]} + ] - assert %{"plot" => curr, "comparison_plot" => prev} = json_response(conn, 200) - assert [0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0] = prev - assert [0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0] = curr + assert comparison_results == [ + %{"dimensions" => ["2020-01-01"], "metrics" => [1], "change" => nil}, + %{"dimensions" => ["2020-02-01"], "metrics" => [1], "change" => nil}, + %{"dimensions" => ["2020-03-01"], "metrics" => [1], "change" => nil} + ] end end - describe "GET /api/stats/main-graph - bounce_rate plot" do + describe "bounce_rate plot" do setup [:create_user, :log_in, :create_site, :create_legacy_site_import] test "displays bounce_rate for a month", %{conn: conn, site: site} do populate_stats(site, [ - build(:pageview, timestamp: ~N[2021-01-03 00:00:00]), - build(:pageview, timestamp: ~N[2021-01-03 00:10:00]), + build(:pageview, timestamp: ~N[2021-01-03 00:00:00], user_id: 1), + build(:pageview, timestamp: ~N[2021-01-03 00:10:00], user_id: 1), build(:pageview, timestamp: ~N[2021-01-31 00:00:00]) ]) - conn = - get( - conn, - "/api/stats/#{site.domain}/main-graph?period=month&date=2021-01-01&metric=bounce_rate" - ) - - assert %{"plot" => plot} = json_response(conn, 200) - - assert Enum.count(plot) == 31 - assert List.first(plot) == 0 - assert List.last(plot) == 100 + response = + do_query(conn, site, %{ + "date_range" => "month", + "relative_date" => "2021-01-01", + "metrics" => ["bounce_rate"], + "dimensions" => ["time:day"] + }) + + assert response["results"] == [ + %{"dimensions" => ["2021-01-03"], "metrics" => [0]}, + %{"dimensions" => ["2021-01-31"], "metrics" => [100]} + ] end test "displays bounce rate for a month with imported data", %{conn: conn, site: site} do @@ -902,17 +1148,19 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do build(:imported_visitors, visits: 1, bounces: 1, date: ~D[2021-01-31]) ]) - conn = - get( - conn, - "/api/stats/#{site.domain}/main-graph?period=month&date=2021-01-01&metric=bounce_rate&with_imported=true" - ) - - assert %{"plot" => plot} = json_response(conn, 200) - - assert Enum.count(plot) == 31 - assert List.first(plot) == 50 - assert List.last(plot) == 100 + response = + do_query(conn, site, %{ + "date_range" => "month", + "relative_date" => "2021-01-01", + "metrics" => ["bounce_rate"], + "dimensions" => ["time:day"], + "include" => %{"imports" => true} + }) + + assert response["results"] == [ + %{"dimensions" => ["2021-01-01"], "metrics" => [50]}, + %{"dimensions" => ["2021-01-31"], "metrics" => [100]} + ] end test "displays bounce rate for a month with only imported data", %{conn: conn, site: site} do @@ -921,21 +1169,23 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do build(:imported_visitors, visits: 1, bounces: 1, date: ~D[2021-01-31]) ]) - conn = - get( - conn, - "/api/stats/#{site.domain}/main-graph?period=month&date=2021-01-01&metric=bounce_rate&with_imported=true" - ) - - assert %{"plot" => plot} = json_response(conn, 200) - - assert Enum.count(plot) == 31 - assert List.first(plot) == 0 - assert List.last(plot) == 100 + response = + do_query(conn, site, %{ + "date_range" => "month", + "relative_date" => "2021-01-01", + "metrics" => ["bounce_rate"], + "dimensions" => ["time:day"], + "include" => %{"imports" => true} + }) + + assert response["results"] == [ + %{"dimensions" => ["2021-01-01"], "metrics" => [0]}, + %{"dimensions" => ["2021-01-31"], "metrics" => [100]} + ] end end - describe "GET /api/stats/main-graph - visit_duration plot" do + describe "visit_duration plot" do setup [:create_user, :log_in, :create_site, :create_legacy_site_import] test "displays visit_duration for a month", %{conn: conn, site: site} do @@ -952,17 +1202,17 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do ) ]) - conn = - get( - conn, - "/api/stats/#{site.domain}/main-graph?period=month&date=2021-01-01&metric=visit_duration" - ) - - assert %{"plot" => plot} = json_response(conn, 200) + response = + do_query(conn, site, %{ + "date_range" => "month", + "relative_date" => "2021-01-01", + "metrics" => ["visit_duration"], + "dimensions" => ["time:day"] + }) - assert Enum.count(plot) == 31 - assert List.first(plot) == nil - assert List.last(plot) == 300 + assert response["results"] == [ + %{"dimensions" => ["2021-01-31"], "metrics" => [300]} + ] end test "displays visit_duration for a month with imported data", %{conn: conn, site: site} do @@ -972,16 +1222,18 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do build(:imported_visitors, visits: 1, visit_duration: 100, date: ~D[2021-01-01]) ]) - conn = - get( - conn, - "/api/stats/#{site.domain}/main-graph?period=month&date=2021-01-01&metric=visit_duration&with_imported=true" - ) - - assert %{"plot" => plot} = json_response(conn, 200) - - assert Enum.count(plot) == 31 - assert List.first(plot) == 200 + response = + do_query(conn, site, %{ + "date_range" => "month", + "relative_date" => "2021-01-01", + "metrics" => ["visit_duration"], + "dimensions" => ["time:day"], + "include" => %{"imports" => true} + }) + + assert response["results"] == [ + %{"dimensions" => ["2021-01-01"], "metrics" => [200]} + ] end test "displays visit_duration for a month with only imported data", %{conn: conn, site: site} do @@ -989,20 +1241,22 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do build(:imported_visitors, visits: 1, visit_duration: 100, date: ~D[2021-01-01]) ]) - conn = - get( - conn, - "/api/stats/#{site.domain}/main-graph?period=month&date=2021-01-01&metric=visit_duration&with_imported=true" - ) - - assert %{"plot" => plot} = json_response(conn, 200) - - assert Enum.count(plot) == 31 - assert List.first(plot) == 100 + response = + do_query(conn, site, %{ + "date_range" => "month", + "relative_date" => "2021-01-01", + "metrics" => ["visit_duration"], + "dimensions" => ["time:day"], + "include" => %{"imports" => true} + }) + + assert response["results"] == [ + %{"dimensions" => ["2021-01-01"], "metrics" => [100]} + ] end end - describe "GET /api/stats/main-graph - varying intervals" do + describe "varying intervals" do setup [:create_user, :log_in, :create_site] test "displays visitors for 6mo on a day scale", %{conn: conn, site: site} do @@ -1014,19 +1268,23 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do build(:pageview, timestamp: ~N[2021-05-31 01:00:00]) ]) - conn = - get( - conn, - "/api/stats/#{site.domain}/main-graph?period=6mo&date=2021-06-01&metric=visitors&interval=day" - ) - - assert %{"plot" => plot} = json_response(conn, 200) - - assert Enum.count(plot) == 182 - assert List.first(plot) == 1 - assert Enum.at(plot, 14) == 2 - assert Enum.at(plot, 45) == 1 - assert List.last(plot) == 1 + response = + do_query(conn, site, %{ + "date_range" => "6mo", + "relative_date" => "2021-06-01", + "metrics" => ["visitors"], + "dimensions" => ["time:day"], + "include" => %{"time_labels" => true} + }) + + assert length(response["meta"]["time_labels"]) == 182 + + assert response["results"] == [ + %{"dimensions" => ["2020-12-01"], "metrics" => [1]}, + %{"dimensions" => ["2020-12-15"], "metrics" => [2]}, + %{"dimensions" => ["2021-01-15"], "metrics" => [1]}, + %{"dimensions" => ["2021-05-31"], "metrics" => [1]} + ] end test "displays visitors for a custom period on a monthly scale", %{conn: conn, site: site} do @@ -1037,50 +1295,113 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do build(:pageview, timestamp: ~N[2021-06-01 00:00:00]) ]) - conn = - get( - conn, - "/api/stats/#{site.domain}/main-graph?period=custom&from=2021-01-01&to=2021-06-30&metric=visitors&interval=month" - ) + response = + do_query(conn, site, %{ + "date_range" => ["2021-01-01", "2021-06-30"], + "metrics" => ["visitors"], + "dimensions" => ["time:month"] + }) + + assert response["results"] == [ + %{"dimensions" => ["2021-01-01"], "metrics" => [2]}, + %{"dimensions" => ["2021-02-01"], "metrics" => [1]}, + %{"dimensions" => ["2021-06-01"], "metrics" => [1]} + ] + end - assert %{"plot" => plot} = json_response(conn, 200) + test "returns error when the interval is not valid", %{ + conn: conn, + site: site + } do + response = + do_query_fail(conn, site, %{ + "date_range" => "day", + "relative_date" => "2021-01-01", + "metrics" => ["visitors"], + "dimensions" => ["time:biweekly"] + }) - assert Enum.count(plot) == 6 - assert List.first(plot) == 2 - assert Enum.at(plot, 1) == 1 - assert List.last(plot) == 1 + assert %{"error" => error} = json_response(response, 400) + assert error =~ "Invalid dimensions" end - test "returns error when requesting an interval longer than the time period", %{ + test "time:minute dimension for a complete day, including DST transition", %{ conn: conn, site: site } do - conn = - get( - conn, - "/api/stats/#{site.domain}/main-graph?period=day&date=2021-01-01&metric=visitors&interval=month" - ) + # On Oct 26, 2025, in Tallinn, Estonia, the clocks shifted back + # from 04:00 -> 03:00, so there were 25 hours in that day. + Plausible.Site.changeset(site, %{timezone: "Europe/Tallinn"}) + |> Plausible.Repo.update!() + + populate_stats(site, [ + build(:pageview, timestamp: ~N[2025-10-26 01:01:01]), + build(:pageview, timestamp: ~N[2025-10-26 01:01:02]), + build(:pageview, timestamp: ~N[2025-10-26 10:02:30]) + ]) - assert %{ - "error" => - "Invalid combination of interval and period. Interval must be smaller than the selected period, e.g. `period=day,interval=minute`" - } == json_response(conn, 400) + response = + do_query(conn, site, %{ + "date_range" => "day", + "relative_date" => "2025-10-26", + "metrics" => ["pageviews"], + "dimensions" => ["time:minute"], + "include" => %{"time_labels" => true} + }) + + assert length(response["meta"]["time_labels"]) == 1500 + + assert response["results"] == [ + %{"dimensions" => ["2025-10-26 03:01:00"], "metrics" => [2]}, + %{"dimensions" => ["2025-10-26 12:02:00"], "metrics" => [1]} + ] end - test "returns error when the interval is not valid", %{ + test "time:minute dimension for last 24h", %{ conn: conn, site: site } do - conn = - get( + populate_stats(site, [ + build(:pageview, timestamp: ~N[2021-01-01 00:01:01]), + build(:pageview, timestamp: ~N[2021-01-01 00:01:02]), + build(:pageview, timestamp: ~N[2021-01-01 00:02:30]) + ]) + + response = + do_query( conn, - "/api/stats/#{site.domain}/main-graph?period=day&date=2021-01-01&metric=visitors&interval=biweekly" + site, + %{ + "date_range" => "24h", + "metrics" => ["pageviews"], + "dimensions" => ["time:minute"], + "include" => %{"time_labels" => true} + }, + now: ~U[2021-01-01 12:00:00Z] ) - assert %{ - "error" => - "Invalid value for interval. Accepted values are: minute, hour, day, week, month" - } == json_response(conn, 400) + assert length(response["meta"]["time_labels"]) == 1440 + + assert response["results"] == [ + %{"dimensions" => ["2021-01-01 00:01:00"], "metrics" => [2]}, + %{"dimensions" => ["2021-01-01 00:02:00"], "metrics" => [1]} + ] + end + + test "returns error when time:minute dimension is queried for a period longer than 24h", %{ + conn: conn, + site: site + } do + response = + do_query_fail(conn, site, %{ + "date_range" => ["2021-01-01", "2021-01-02"], + "metrics" => ["visitors"], + "dimensions" => ["time:minute"] + }) + + assert %{"error" => error} = json_response(response, 400) + assert error =~ "Invalid dimensions" + assert error =~ "time:minute" end test "displays visitors for a month on a weekly scale", %{conn: conn, site: site} do @@ -1090,40 +1411,45 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do build(:pageview, timestamp: ~N[2021-01-05 00:15:02]) ]) - conn = - get( - conn, - "/api/stats/#{site.domain}/main-graph?period=month&date=2021-01-01&metric=visitors&interval=week" - ) + response = + do_query(conn, site, %{ + "date_range" => "month", + "relative_date" => "2021-01-01", + "metrics" => ["visitors"], + "dimensions" => ["time:week"], + "include" => %{"time_labels" => true} + }) - assert %{"plot" => plot} = json_response(conn, 200) + assert length(response["meta"]["time_labels"]) == 5 - assert Enum.count(plot) == 5 - assert List.first(plot) == 2 - assert Enum.at(plot, 1) == 1 + assert response["results"] == [ + %{"dimensions" => ["2021-01-01"], "metrics" => [2]}, + %{"dimensions" => ["2021-01-04"], "metrics" => [1]} + ] end - test "shows imperfect week-split month on week scale with full week indicators", %{ + test "shows imperfect week-split month on week scale with partial week indicators", %{ conn: conn, site: site } do - conn = - get( - conn, - "/api/stats/#{site.domain}/main-graph?period=month&metric=visitors&interval=week&date=2021-09-01" - ) - - assert %{"labels" => labels, "full_intervals" => full_intervals} = json_response(conn, 200) - - assert labels == ["2021-09-01", "2021-09-06", "2021-09-13", "2021-09-20", "2021-09-27"] + response = + do_query(conn, site, %{ + "date_range" => "month", + "relative_date" => "2021-09-01", + "metrics" => ["visitors"], + "dimensions" => ["time:week"], + "include" => %{"time_labels" => true, "partial_time_labels" => true} + }) + + assert response["meta"]["time_labels"] == [ + "2021-09-01", + "2021-09-06", + "2021-09-13", + "2021-09-20", + "2021-09-27" + ] - assert full_intervals == %{ - "2021-09-01" => false, - "2021-09-06" => true, - "2021-09-13" => true, - "2021-09-20" => true, - "2021-09-27" => false - } + assert response["meta"]["partial_time_labels"] == ["2021-09-01", "2021-09-27"] end test "returns stats for the first week of the month when site timezone is ahead of UTC", %{ @@ -1139,39 +1465,44 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do build(:pageview, timestamp: ~N[2023-03-01 12:00:00]) ]) - conn = - get( - conn, - "/api/stats/#{site.domain}/main-graph?period=month&metric=visitors&date=2023-03-01&interval=week" - ) + response = + do_query(conn, site, %{ + "date_range" => "month", + "relative_date" => "2023-03-01", + "metrics" => ["visitors"], + "dimensions" => ["time:week"], + "include" => %{"time_labels" => true} + }) - %{"labels" => labels, "plot" => plot} = json_response(conn, 200) + assert List.first(response["meta"]["time_labels"]) == "2023-03-01" - assert List.first(plot) == 1 - assert List.first(labels) == "2023-03-01" + assert response["results"] == [ + %{"metrics" => [1], "dimensions" => ["2023-03-01"]} + ] end - test "shows half-perfect week-split month on week scale with full week indicators", %{ + test "shows half-perfect week-split month on week scale with partial week indicators", %{ conn: conn, site: site } do - conn = - get( - conn, - "/api/stats/#{site.domain}/main-graph?period=month&metric=visitors&interval=week&date=2021-10-01" - ) - - assert %{"labels" => labels, "full_intervals" => full_intervals} = json_response(conn, 200) - - assert labels == ["2021-10-01", "2021-10-04", "2021-10-11", "2021-10-18", "2021-10-25"] + response = + do_query(conn, site, %{ + "date_range" => "month", + "relative_date" => "2021-10-01", + "metrics" => ["visitors"], + "dimensions" => ["time:week"], + "include" => %{"time_labels" => true, "partial_time_labels" => true} + }) + + assert response["meta"]["time_labels"] == [ + "2021-10-01", + "2021-10-04", + "2021-10-11", + "2021-10-18", + "2021-10-25" + ] - assert full_intervals == %{ - "2021-10-01" => false, - "2021-10-04" => true, - "2021-10-11" => true, - "2021-10-18" => true, - "2021-10-25" => true - } + assert response["meta"]["partial_time_labels"] == ["2021-10-01"] end test "shows perfect week-split range on week scale with full week indicators for custom period", @@ -1179,15 +1510,15 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do conn: conn, site: site } do - conn = - get( - conn, - "/api/stats/#{site.domain}/main-graph?period=custom&metric=visitors&interval=week&from=2020-12-21&to=2021-02-07" - ) - - assert %{"labels" => labels, "full_intervals" => full_intervals} = json_response(conn, 200) - - assert labels == [ + response = + do_query(conn, site, %{ + "date_range" => ["2020-12-21", "2021-02-07"], + "metrics" => ["visitors"], + "dimensions" => ["time:week"], + "include" => %{"time_labels" => true, "partial_time_labels" => true} + }) + + assert response["meta"]["time_labels"] == [ "2020-12-21", "2020-12-28", "2021-01-04", @@ -1197,125 +1528,113 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do "2021-02-01" ] - assert full_intervals == %{ - "2020-12-21" => true, - "2020-12-28" => true, - "2021-01-04" => true, - "2021-01-11" => true, - "2021-01-18" => true, - "2021-01-25" => true, - "2021-02-01" => true - } + assert response["meta"]["partial_time_labels"] == [] end test "shows imperfect week-split for last 28d with full week indicators", %{ conn: conn, site: site } do - conn = - get( - conn, - "/api/stats/#{site.domain}/main-graph?period=28d&metric=visitors&interval=week&date=2021-10-30" - ) - - assert %{"labels" => labels, "full_intervals" => full_intervals} = json_response(conn, 200) - - assert labels == ["2021-10-02", "2021-10-04", "2021-10-11", "2021-10-18", "2021-10-25"] + response = + do_query(conn, site, %{ + "date_range" => "28d", + "relative_date" => "2021-10-30", + "metrics" => ["visitors"], + "dimensions" => ["time:week"], + "include" => %{"time_labels" => true, "partial_time_labels" => true} + }) + + assert response["meta"]["time_labels"] == [ + "2021-10-02", + "2021-10-04", + "2021-10-11", + "2021-10-18", + "2021-10-25" + ] - assert full_intervals == %{ - "2021-10-02" => false, - "2021-10-04" => true, - "2021-10-11" => true, - "2021-10-18" => true, - "2021-10-25" => false - } + assert response["meta"]["partial_time_labels"] == ["2021-10-02", "2021-10-25"] end - test "shows perfect week-split for last 28d with full week indicators", %{ + test "shows imperfect month-split for custom period with full month indicators", %{ conn: conn, site: site } do - conn = - get( - conn, - "/api/stats/#{site.domain}/main-graph?period=28d&date=2021-02-08&metric=visitors&interval=week" - ) - - assert %{"labels" => labels, "full_intervals" => full_intervals} = json_response(conn, 200) - - assert labels == ["2021-01-11", "2021-01-18", "2021-01-25", "2021-02-01"] + response = + do_query(conn, site, %{ + "date_range" => ["2021-09-06", "2021-12-13"], + "metrics" => ["visitors"], + "dimensions" => ["time:month"], + "include" => %{"time_labels" => true, "partial_time_labels" => true} + }) + + assert response["meta"]["time_labels"] == [ + "2021-09-01", + "2021-10-01", + "2021-11-01", + "2021-12-01" + ] - assert full_intervals == %{ - "2021-01-11" => true, - "2021-01-18" => true, - "2021-01-25" => true, - "2021-02-01" => true - } + assert response["meta"]["partial_time_labels"] == ["2021-09-01", "2021-12-01"] end - test "shows imperfect month-split for custom period with full month indicators", %{ + test "shows perfect month-split for last 91d with full month indicators", %{ conn: conn, site: site } do - conn = - get( - conn, - "/api/stats/#{site.domain}/main-graph?period=custom&metric=visitors&interval=month&from=2021-09-06&to=2021-12-13" - ) - - assert %{"labels" => labels, "full_intervals" => full_intervals} = json_response(conn, 200) + response = + do_query(conn, site, %{ + "date_range" => "91d", + "relative_date" => "2021-12-01", + "metrics" => ["visitors"], + "dimensions" => ["time:month"], + "include" => %{"time_labels" => true, "partial_time_labels" => true} + }) - assert labels == ["2021-09-01", "2021-10-01", "2021-11-01", "2021-12-01"] + assert response["meta"]["time_labels"] == ["2021-09-01", "2021-10-01", "2021-11-01"] - assert full_intervals == %{ - "2021-09-01" => false, - "2021-10-01" => true, - "2021-11-01" => true, - "2021-12-01" => false - } + assert response["meta"]["partial_time_labels"] == [] end - test "shows imperfect month-split for last 91d with full month indicators", %{ + test "partial_time_labels is an empty list for time:day when all days are complete", %{ conn: conn, site: site } do - conn = - get( - conn, - "/api/stats/#{site.domain}/main-graph?period=91d&metric=visitors&interval=month&date=2021-12-13" - ) - - assert %{"labels" => labels, "full_intervals" => full_intervals} = json_response(conn, 200) - - assert labels == ["2021-09-01", "2021-10-01", "2021-11-01", "2021-12-01"] + response = + do_query(conn, site, %{ + "date_range" => "28d", + "relative_date" => "2021-01-28", + "metrics" => ["visitors"], + "dimensions" => ["time:day"], + "include" => %{"time_labels" => true, "partial_time_labels" => true} + }) - assert full_intervals == %{ - "2021-09-01" => false, - "2021-10-01" => true, - "2021-11-01" => true, - "2021-12-01" => false - } + assert length(response["meta"]["time_labels"]) == 28 + assert response["meta"]["partial_time_labels"] == [] end - test "shows perfect month-split for last 91d with full month indicators", %{ + test "returns comparison_partial_time_labels", %{ conn: conn, site: site } do - conn = - get( - conn, - "/api/stats/#{site.domain}/main-graph?period=91d&metric=visitors&interval=month&date=2021-12-01" - ) - - assert %{"labels" => labels, "full_intervals" => full_intervals} = json_response(conn, 200) - - assert labels == ["2021-09-01", "2021-10-01", "2021-11-01"] + response = + do_query(conn, site, %{ + "date_range" => ["2026-02-08", "2026-02-25"], + "metrics" => ["visitors"], + "dimensions" => ["time:week"], + "include" => %{ + "time_labels" => true, + "partial_time_labels" => true, + "compare" => ["2026-01-08", "2026-01-25"] + } + }) + + assert response["meta"]["comparison_time_labels"] == [ + "2026-01-08", + "2026-01-12", + "2026-01-19" + ] - assert full_intervals == %{ - "2021-09-01" => true, - "2021-10-01" => true, - "2021-11-01" => true - } + assert response["meta"]["comparison_partial_time_labels"] == ["2026-01-08"] end test "returns stats for a day with a minute interval", %{conn: conn, site: site} do @@ -1323,21 +1642,25 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do build(:pageview, timestamp: ~N[2023-03-01 12:00:00]) ]) - conn = - get( - conn, - "/api/stats/#{site.domain}/main-graph?period=day&metric=visitors&date=2023-03-01&interval=minute" - ) + response = + do_query(conn, site, %{ + "date_range" => "day", + "relative_date" => "2023-03-01", + "metrics" => ["visitors"], + "dimensions" => ["time:minute"], + "include" => %{"time_labels" => true} + }) - %{"labels" => labels, "plot" => plot} = json_response(conn, 200) + labels = response["meta"]["time_labels"] assert length(labels) == 24 * 60 - assert List.first(labels) == "2023-03-01 00:00:00" assert Enum.at(labels, 1) == "2023-03-01 00:01:00" assert List.last(labels) == "2023-03-01 23:59:00" - assert Enum.at(plot, Enum.find_index(labels, &(&1 == "2023-03-01 12:00:00"))) == 1 + assert response["results"] == [ + %{"dimensions" => ["2023-03-01 12:00:00"], "metrics" => [1]} + ] end test "trims hourly relative date range", %{conn: conn, site: site} do @@ -1348,27 +1671,37 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do build(:pageview, timestamp: ~N[2021-01-08 23:59:00]) ]) - conn = - conn - |> Plug.Conn.put_private(:now, ~U[2021-01-08 08:05:00Z]) - |> get( - "/api/stats/#{site.domain}/main-graph?period=day&metric=visitors&date=2021-01-08&interval=hour" - ) + response = + do_query( + conn, + site, + %{ + "date_range" => "day", + "relative_date" => "2021-01-08", + "metrics" => ["visitors"], + "dimensions" => ["time:hour"], + "include" => %{"time_labels" => true} + }, + now: ~U[2021-01-08 08:05:00Z] + ) + + assert response["meta"]["time_labels"] == [ + "2021-01-08 00:00:00", + "2021-01-08 01:00:00", + "2021-01-08 02:00:00", + "2021-01-08 03:00:00", + "2021-01-08 04:00:00", + "2021-01-08 05:00:00", + "2021-01-08 06:00:00", + "2021-01-08 07:00:00", + "2021-01-08 08:00:00" + ] - assert_matches %{ - "labels" => [ - "2021-01-08 00:00:00", - "2021-01-08 01:00:00", - "2021-01-08 02:00:00", - "2021-01-08 03:00:00", - "2021-01-08 04:00:00", - "2021-01-08 05:00:00", - "2021-01-08 06:00:00", - "2021-01-08 07:00:00", - "2021-01-08 08:00:00" - ], - "plot" => [1, 0, 0, 0, 0, 0, 1, 0, 1] - } = json_response(conn, 200) + assert response["results"] == [ + %{"dimensions" => ["2021-01-08 00:00:00"], "metrics" => [1]}, + %{"dimensions" => ["2021-01-08 06:00:00"], "metrics" => [1]}, + %{"dimensions" => ["2021-01-08 08:00:00"], "metrics" => [1]} + ] end test "trims monthly relative date range", %{conn: conn, site: site} do @@ -1379,25 +1712,35 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do build(:pageview, timestamp: ~N[2021-01-31 00:00:00]) ]) - conn = - conn - |> Plug.Conn.put_private(:now, ~U[2021-01-07 12:00:00Z]) - |> get( - "/api/stats/#{site.domain}/main-graph?period=month&metric=visitors&date=2021-01-07&interval=day" + response = + do_query( + conn, + site, + %{ + "date_range" => "month", + "relative_date" => "2021-01-07", + "metrics" => ["visitors"], + "dimensions" => ["time:day"], + "include" => %{"time_labels" => true} + }, + now: ~U[2021-01-07 12:00:00Z] ) - assert_matches %{ - "labels" => [ - "2021-01-01", - "2021-01-02", - "2021-01-03", - "2021-01-04", - "2021-01-05", - "2021-01-06", - "2021-01-07" - ], - "plot" => [1, 0, 0, 0, 1, 0, 1] - } = json_response(conn, 200) + assert response["meta"]["time_labels"] == [ + "2021-01-01", + "2021-01-02", + "2021-01-03", + "2021-01-04", + "2021-01-05", + "2021-01-06", + "2021-01-07" + ] + + assert response["results"] == [ + %{"dimensions" => ["2021-01-01"], "metrics" => [1]}, + %{"dimensions" => ["2021-01-05"], "metrics" => [1]}, + %{"dimensions" => ["2021-01-07"], "metrics" => [1]} + ] end test "trims yearly relative date range", %{conn: conn, site: site} do @@ -1410,35 +1753,46 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do build(:pageview, timestamp: ~N[2021-02-09 00:00:00]) ]) - conn = - conn - |> Plug.Conn.put_private(:now, ~U[2021-02-07 12:00:00Z]) - |> get( - "/api/stats/#{site.domain}/main-graph?period=year&metric=visitors&date=2021-02-07&interval=month" + response = + do_query( + conn, + site, + %{ + "date_range" => "year", + "relative_date" => "2021-02-07", + "metrics" => ["visitors"], + "dimensions" => ["time:month"], + "include" => %{"time_labels" => true} + }, + now: ~U[2021-02-07 12:00:00Z] ) - assert_matches %{ - "labels" => [ - "2021-01-01", - "2021-02-01" - ], - "plot" => [4, 1] - } = json_response(conn, 200) + assert response["meta"]["time_labels"] == ["2021-01-01", "2021-02-01"] + + assert response["results"] == [ + %{"dimensions" => ["2021-01-01"], "metrics" => [4]}, + %{"dimensions" => ["2021-02-01"], "metrics" => [1]} + ] end end - describe "GET /api/stats/main-graph - comparisons" do + describe "comparisons" do setup [:create_user, :log_in, :create_site, :create_legacy_site_import] test "returns past month stats when period=30d and comparison=previous_period", %{ conn: conn, site: site } do - conn = - get(conn, "/api/stats/#{site.domain}/main-graph?period=30d&comparison=previous_period") + response = + do_query(conn, site, %{ + "date_range" => "30d", + "metrics" => ["visitors"], + "dimensions" => ["time:day"], + "include" => %{"compare" => "previous_period", "time_labels" => true} + }) - assert %{"labels" => labels, "comparison_labels" => comparison_labels} = - json_response(conn, 200) + labels = response["meta"]["time_labels"] + comparison_labels = response["meta"]["comparison_time_labels"] first = Date.utc_today() |> Date.shift(day: -30) |> Date.to_iso8601() last = Date.utc_today() |> Date.shift(day: -1) |> Date.to_iso8601() @@ -1469,25 +1823,39 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do build(:pageview, timestamp: ~N[2019-01-31 00:00:00]) ]) - conn = - get( - conn, - "/api/stats/#{site.domain}/main-graph?period=month&date=2020-01-01&comparison=year_over_year" - ) + response = + do_query(conn, site, %{ + "date_range" => "month", + "relative_date" => "2020-01-01", + "metrics" => ["visitors"], + "dimensions" => ["time:day"], + "include" => %{"compare" => "year_over_year", "time_labels" => true} + }) + + assert response["results"] == [ + %{"dimensions" => ["2020-01-01"], "metrics" => [1]}, + %{"dimensions" => ["2020-01-05"], "metrics" => [1]}, + %{"dimensions" => ["2020-01-30"], "metrics" => [1]}, + %{"dimensions" => ["2020-01-31"], "metrics" => [1]} + ] - assert %{"plot" => plot, "comparison_plot" => comparison_plot} = json_response(conn, 200) + assert response["comparison_results"] == [ + %{"dimensions" => ["2019-01-01"], "metrics" => [2], "change" => [-50]}, + %{"dimensions" => ["2019-01-05"], "metrics" => [2], "change" => [-50]}, + %{"dimensions" => ["2019-01-31"], "metrics" => [1], "change" => [0]} + ] - assert 1 == Enum.at(plot, 0) - assert 2 == Enum.at(comparison_plot, 0) + assert length(response["meta"]["time_labels"]) == 31 + assert length(response["meta"]["comparison_time_labels"]) == 31 - assert 1 == Enum.at(plot, 4) - assert 2 == Enum.at(comparison_plot, 4) + assert response["meta"]["time_label_result_indices"] == + [0, nil, nil, nil, 1] ++ List.duplicate(nil, 24) ++ [2, 3] - assert 1 == Enum.at(plot, 30) - assert 1 == Enum.at(comparison_plot, 30) + assert response["meta"]["comparison_time_label_result_indices"] == + [0, nil, nil, nil, 1] ++ List.duplicate(nil, 25) ++ [2] end - test "fill in gaps when custom comparison period is larger than original query", %{ + test "can return custom comparison period larger than original query", %{ conn: conn, site: site } do @@ -1497,17 +1865,27 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do build(:pageview, timestamp: ~N[2020-01-30 00:00:00]) ]) - conn = - get( - conn, - "/api/stats/#{site.domain}/main-graph?period=month&date=2020-01-01&comparison=custom&compare_from=2022-01-01&compare_to=2022-06-01" - ) - - assert %{"labels" => labels, "comparison_plot" => comparison_labels} = - json_response(conn, 200) - - assert length(labels) == length(comparison_labels) - assert "__blank__" == List.last(labels) + response = + do_query(conn, site, %{ + "date_range" => "month", + "relative_date" => "2020-01-01", + "metrics" => ["visitors"], + "dimensions" => ["time:day"], + "include" => %{ + "compare" => ["2022-01-01", "2022-06-01"], + "time_labels" => true + } + }) + + labels = response["meta"]["time_labels"] + comparison_labels = response["meta"]["comparison_time_labels"] + + assert length(labels) == 31 + assert length(comparison_labels) == 152 + assert length(response["results"]) == 3 + assert response["comparison_results"] == [] + assert List.first(comparison_labels) == "2022-01-01" + assert List.last(comparison_labels) == "2022-06-01" end test "compares imported data and native data together", %{conn: conn, site: site} do @@ -1520,13 +1898,19 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) ]) - conn = - get( - conn, - "/api/stats/#{site.domain}/main-graph?period=year&date=2021-01-01&with_imported=true&comparison=year_over_year&interval=month" - ) + response = + do_query(conn, site, %{ + "date_range" => "year", + "relative_date" => "2021-01-01", + "metrics" => ["visitors"], + "dimensions" => ["time:month"], + "include" => %{"imports" => true, "compare" => "year_over_year"} + }) + + plot = Enum.map(response["results"], fn r -> List.first(r["metrics"]) end) - assert %{"plot" => plot, "comparison_plot" => comparison_plot} = json_response(conn, 200) + comparison_plot = + Enum.map(response["comparison_results"], fn r -> List.first(r["metrics"]) end) assert 4 == Enum.sum(plot) assert 2 == Enum.sum(comparison_plot) @@ -1545,13 +1929,19 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) ]) - conn = - get( - conn, - "/api/stats/#{site.domain}/main-graph?period=year&date=2021-01-01&with_imported=false&comparison=year_over_year&interval=month" - ) + response = + do_query(conn, site, %{ + "date_range" => "year", + "relative_date" => "2021-01-01", + "metrics" => ["visitors"], + "dimensions" => ["time:month"], + "include" => %{"imports" => false, "compare" => "year_over_year"} + }) + + plot = Enum.map(response["results"], fn r -> List.first(r["metrics"]) end) - assert %{"plot" => plot, "comparison_plot" => comparison_plot} = json_response(conn, 200) + comparison_plot = + Enum.map(response["comparison_results"], fn r -> List.first(r["metrics"]) end) assert 4 == Enum.sum(plot) assert 0 == Enum.sum(comparison_plot) @@ -1568,19 +1958,23 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do build(:pageview, timestamp: ~N[2021-01-08 00:01:00]) ]) - filters = Jason.encode!([[:is, "event:goal", ["Signup"]]]) - - conn = - get( - conn, - "/api/stats/#{site.domain}/main-graph?period=7d&date=2021-01-15&comparison=previous_period&metric=conversion_rate&filters=#{filters}" - ) - - assert %{"plot" => this_week_plot, "comparison_plot" => last_week_plot} = - json_response(conn, 200) + %{"results" => results, "comparison_results" => comparison_results} = + do_query(conn, site, %{ + "date_range" => "7d", + "relative_date" => "2021-01-15", + "metrics" => ["conversion_rate"], + "dimensions" => ["time:day"], + "filters" => [["is", "event:goal", ["Signup"]]], + "include" => %{"compare" => "previous_period"} + }) + + assert results == [ + %{"dimensions" => ["2021-01-08"], "metrics" => [50.0]} + ] - assert this_week_plot == [50.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] - assert last_week_plot == [33.33, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] + assert comparison_results == [ + %{"dimensions" => ["2021-01-01"], "metrics" => [33.33], "change" => [16.7]} + ] end test "does trim hourly relative date range when comparing", %{conn: conn, site: site} do @@ -1590,52 +1984,60 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do build(:pageview, timestamp: ~N[2021-01-08 08:04:00]) ]) - conn = - conn - |> Plug.Conn.put_private(:now, ~U[2021-01-08 08:05:00Z]) - |> get( - "/api/stats/#{site.domain}/main-graph?period=day&metric=visitors&date=2021-01-08&interval=hour&comparison=previous_period" - ) + response = + do_query( + conn, + site, + %{ + "date_range" => "day", + "relative_date" => "2021-01-08", + "metrics" => ["visitors"], + "dimensions" => ["time:hour"], + "include" => %{"compare" => "previous_period", "time_labels" => true} + }, + now: ~U[2021-01-08 08:05:00Z] + ) + + assert response["meta"]["time_labels"] == [ + "2021-01-08 00:00:00", + "2021-01-08 01:00:00", + "2021-01-08 02:00:00", + "2021-01-08 03:00:00", + "2021-01-08 04:00:00", + "2021-01-08 05:00:00", + "2021-01-08 06:00:00", + "2021-01-08 07:00:00", + "2021-01-08 08:00:00" + ] - assert_matches %{ - "labels" => [ - "2021-01-08 00:00:00", - "2021-01-08 01:00:00", - "2021-01-08 02:00:00", - "2021-01-08 03:00:00", - "2021-01-08 04:00:00", - "2021-01-08 05:00:00", - "2021-01-08 06:00:00", - "2021-01-08 07:00:00", - "2021-01-08 08:00:00" - ], - "plot" => [ - 1, - 0, - 0, - 0, - 0, - 0, - 1, - 0, - 1 - ], - "comparison_plot" => [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ] - } = json_response(conn, 200) + assert response["results"] == [ + %{"dimensions" => ["2021-01-08 00:00:00"], "metrics" => [1]}, + %{"dimensions" => ["2021-01-08 06:00:00"], "metrics" => [1]}, + %{"dimensions" => ["2021-01-08 08:00:00"], "metrics" => [1]} + ] + + assert response["meta"]["comparison_time_labels"] == [ + "2021-01-07 00:00:00", + "2021-01-07 01:00:00", + "2021-01-07 02:00:00", + "2021-01-07 03:00:00", + "2021-01-07 04:00:00", + "2021-01-07 05:00:00", + "2021-01-07 06:00:00", + "2021-01-07 07:00:00", + "2021-01-07 08:00:00" + ] + + assert response["comparison_results"] == [] + + assert response["meta"]["time_label_result_indices"] == + [0, nil, nil, nil, nil, nil, 1, nil, 2] + + assert response["meta"]["comparison_time_label_result_indices"] == List.duplicate(nil, 9) end end - describe "GET /api/stats/main-graph - total_revenue plot" do + describe "total_revenue plot" do @describetag :ee_only setup [:create_user, :log_in, :create_site, :create_legacy_site_import] @@ -1669,48 +2071,44 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do ) ]) - filters = Jason.encode!([[:is, "event:goal", ["Payment"]]]) - - conn = - get( - conn, - "/api/stats/#{site.domain}/main-graph?period=month&date=2021-01-01&metric=total_revenue&filters=#{filters}" - ) - - assert %{"plot" => plot} = json_response(conn, 200) - - assert plot == [ - %{"currency" => "USD", "long" => "$13.29", "short" => "$13.3", "value" => 13.29}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$19.90", "short" => "$19.9", "value" => 19.9}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$30.31", "short" => "$30.3", "value" => 30.31} + response = + do_query(conn, site, %{ + "date_range" => "month", + "relative_date" => "2021-01-01", + "metrics" => ["total_revenue"], + "dimensions" => ["time:day"], + "filters" => [["is", "event:goal", ["Payment"]]] + }) + + assert response["results"] == [ + %{ + "dimensions" => ["2021-01-01"], + "metrics" => [ + %{ + "currency" => "USD", + "long" => "$13.29", + "short" => "$13.3", + "value" => 13.29 + } + ] + }, + %{ + "dimensions" => ["2021-01-05"], + "metrics" => [ + %{"currency" => "USD", "long" => "$19.90", "short" => "$19.9", "value" => 19.9} + ] + }, + %{ + "dimensions" => ["2021-01-31"], + "metrics" => [ + %{ + "currency" => "USD", + "long" => "$30.31", + "short" => "$30.3", + "value" => 30.31 + } + ] + } ] end @@ -1755,43 +2153,65 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do ) ]) - filters = Jason.encode!([[:is, "event:goal", ["PaymentUSD"]]]) - - conn = - get( - conn, - "/api/stats/#{site.domain}/main-graph?period=7d&date=2021-01-15&metric=total_revenue&filters=#{filters}&comparison=previous_period" - ) - - assert %{"plot" => plot, "comparison_plot" => prev} = json_response(conn, 200) - - assert plot == [ - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$10.31", "short" => "$10.3", "value" => 10.31}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$30.00", "short" => "$30.0", "value" => 30.0}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0} + %{"results" => results, "comparison_results" => comparison_results} = + do_query(conn, site, %{ + "date_range" => "7d", + "relative_date" => "2021-01-15", + "metrics" => ["total_revenue"], + "dimensions" => ["time:day"], + "filters" => [["is", "event:goal", ["PaymentUSD"]]], + "include" => %{"compare" => "previous_period"} + }) + + assert results == [ + %{ + "dimensions" => ["2021-01-10"], + "metrics" => [ + %{ + "currency" => "USD", + "long" => "$10.31", + "short" => "$10.3", + "value" => 10.31 + } + ] + }, + %{ + "dimensions" => ["2021-01-12"], + "metrics" => [ + %{"currency" => "USD", "long" => "$30.00", "short" => "$30.0", "value" => 30.0} + ] + } ] - assert prev == [ - %{"currency" => "USD", "long" => "$13.29", "short" => "$13.3", "value" => 13.29}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$19.90", "short" => "$19.9", "value" => 19.9}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0} + assert comparison_results == [ + %{ + "dimensions" => ["2021-01-01"], + "metrics" => [ + %{ + "currency" => "USD", + "long" => "$13.29", + "short" => "$13.3", + "value" => 13.29 + } + ], + "change" => nil + }, + %{ + "dimensions" => ["2021-01-05"], + "metrics" => [ + %{"currency" => "USD", "long" => "$19.90", "short" => "$19.9", "value" => 19.9} + ], + "change" => [51] + } ] end end - describe "GET /api/stats/main-graph - average_revenue plot" do + describe "average_revenue plot" do @describetag :ee_only setup [:create_user, :log_in, :create_site, :create_legacy_site_import] - test "plots total_revenue for a month", %{conn: conn, site: site} do + test "plots average_revenue for a month", %{conn: conn, site: site} do insert(:goal, site: site, event_name: "Payment", currency: "USD") populate_stats(site, [ @@ -1827,48 +2247,44 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do ) ]) - filters = Jason.encode!([[:is, "event:goal", ["Payment"]]]) - - conn = - get( - conn, - "/api/stats/#{site.domain}/main-graph?period=month&date=2021-01-01&metric=average_revenue&filters=#{filters}" - ) - - assert %{"plot" => plot} = json_response(conn, 200) - - assert plot == [ - %{"currency" => "USD", "long" => "$31.90", "short" => "$31.9", "value" => 31.895}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$19.90", "short" => "$19.9", "value" => 19.9}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$15.16", "short" => "$15.2", "value" => 15.155} + response = + do_query(conn, site, %{ + "date_range" => "month", + "relative_date" => "2021-01-01", + "metrics" => ["average_revenue"], + "dimensions" => ["time:day"], + "filters" => [["is", "event:goal", ["Payment"]]] + }) + + assert response["results"] == [ + %{ + "dimensions" => ["2021-01-01"], + "metrics" => [ + %{ + "currency" => "USD", + "long" => "$31.90", + "short" => "$31.9", + "value" => 31.895 + } + ] + }, + %{ + "dimensions" => ["2021-01-05"], + "metrics" => [ + %{"currency" => "USD", "long" => "$19.90", "short" => "$19.9", "value" => 19.9} + ] + }, + %{ + "dimensions" => ["2021-01-31"], + "metrics" => [ + %{ + "currency" => "USD", + "long" => "$15.16", + "short" => "$15.2", + "value" => 15.155 + } + ] + } ] end @@ -1913,34 +2329,56 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do ) ]) - filters = Jason.encode!([[:is, "event:goal", ["PaymentUSD"]]]) - - conn = - get( - conn, - "/api/stats/#{site.domain}/main-graph?period=7d&date=2021-01-15&metric=average_revenue&filters=#{filters}&comparison=previous_period" - ) - - assert %{"plot" => plot, "comparison_plot" => prev} = json_response(conn, 200) - - assert plot == [ - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$10.31", "short" => "$10.3", "value" => 10.31}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$15.00", "short" => "$15.0", "value" => 15.0}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0} + %{"results" => results, "comparison_results" => comparison_results} = + do_query(conn, site, %{ + "date_range" => "7d", + "relative_date" => "2021-01-15", + "metrics" => ["average_revenue"], + "dimensions" => ["time:day"], + "filters" => [["is", "event:goal", ["PaymentUSD"]]], + "include" => %{"compare" => "previous_period", "time_labels" => true} + }) + + assert results == [ + %{ + "dimensions" => ["2021-01-10"], + "metrics" => [ + %{ + "currency" => "USD", + "long" => "$10.31", + "short" => "$10.3", + "value" => 10.31 + } + ] + }, + %{ + "dimensions" => ["2021-01-12"], + "metrics" => [ + %{"currency" => "USD", "long" => "$15.00", "short" => "$15.0", "value" => 15.0} + ] + } ] - assert prev == [ - %{"currency" => "USD", "long" => "$13.29", "short" => "$13.3", "value" => 13.29}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$19.90", "short" => "$19.9", "value" => 19.9}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, - %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0} + assert comparison_results == [ + %{ + "dimensions" => ["2021-01-01"], + "metrics" => [ + %{ + "currency" => "USD", + "long" => "$13.29", + "short" => "$13.3", + "value" => 13.29 + } + ], + "change" => nil + }, + %{ + "dimensions" => ["2021-01-05"], + "metrics" => [ + %{"currency" => "USD", "long" => "$19.90", "short" => "$19.9", "value" => 19.9} + ], + "change" => [-25] + } ] end end @@ -1953,13 +2391,15 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do build(:pageview) ]) - conn = - get( - conn, - "/api/stats/#{site.domain}/main-graph?period=month&metric=pageviews" - ) + response = + do_query(conn, site, %{ + "date_range" => "month", + "metrics" => ["pageviews"], + "dimensions" => ["time:day"], + "include" => %{"time_labels" => true, "present_index" => true} + }) - assert %{"present_index" => present_index} = json_response(conn, 200) + present_index = response["meta"]["present_index"] assert present_index >= 0 end @@ -1972,15 +2412,16 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do build(:pageview) ]) - conn = - get( - conn, - "/api/stats/#{site.domain}/main-graph?period=month&date=2021-01-01&metric=pageviews" - ) - - assert %{"present_index" => present_index} = json_response(conn, 200) + response = + do_query(conn, site, %{ + "date_range" => "month", + "relative_date" => "2021-01-01", + "metrics" => ["pageviews"], + "dimensions" => ["time:day"], + "include" => %{"time_labels" => true, "present_index" => true} + }) - refute present_index + refute response["meta"]["present_index"] end for period <- ["7d", "28d", "30d", "91d"] do @@ -1988,17 +2429,76 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do today = "2021-01-01" yesterday = "2020-12-31" - conn = - get( - conn, - "/api/stats/#{site.domain}/main-graph?period=#{unquote(period)}&date=#{today}&metric=pageviews" - ) + response = + do_query(conn, site, %{ + "date_range" => unquote(period), + "relative_date" => today, + "metrics" => ["pageviews"], + "dimensions" => ["time:day"], + "include" => %{"time_labels" => true, "present_index" => true} + }) - assert %{"labels" => labels, "present_index" => present_index} = json_response(conn, 200) + labels = response["meta"]["time_labels"] + present_index = response["meta"]["present_index"] refute present_index assert List.last(labels) == yesterday end end + + test "returns present_index for time:hour interval", %{conn: conn, site: site} do + response = + do_query( + conn, + site, + %{ + "date_range" => "day", + "relative_date" => "2021-01-08", + "metrics" => ["pageviews"], + "dimensions" => ["time:hour"], + "include" => %{"time_labels" => true, "present_index" => true} + }, + now: ~U[2021-01-08 01:30:00Z] + ) + + assert response["meta"]["time_labels"] == ["2021-01-08 00:00:00", "2021-01-08 01:00:00"] + assert response["meta"]["present_index"] == 1 + end + + test "returns present_index for time:week interval", %{conn: conn, site: site} do + response = + do_query( + conn, + site, + %{ + "date_range" => ["2021-01-04", "2021-01-17"], + "metrics" => ["pageviews"], + "dimensions" => ["time:week"], + "include" => %{"time_labels" => true, "present_index" => true} + }, + now: ~U[2021-01-14 12:00:00Z] + ) + + assert response["meta"]["time_labels"] == ["2021-01-04", "2021-01-11"] + assert response["meta"]["present_index"] == 1 + end + + test "returns present_index for time:month interval", %{conn: conn, site: site} do + response = + do_query( + conn, + site, + %{ + "date_range" => ["2021-01-01", "2021-02-28"], + "metrics" => ["pageviews"], + "dimensions" => ["time:month"], + "include" => %{"time_labels" => true, "present_index" => true} + }, + now: ~U[2021-02-15 12:00:00Z] + ) + + assert response["meta"]["time_labels"] == ["2021-01-01", "2021-02-01"] + assert response["meta"]["present_index"] == 1 + end end end