diff --git a/apps/api/src/controllers/export.controller.ts b/apps/api/src/controllers/export.controller.ts index bca5a35d..79fc3048 100644 --- a/apps/api/src/controllers/export.controller.ts +++ b/apps/api/src/controllers/export.controller.ts @@ -4,7 +4,7 @@ import { z } from 'zod'; import type { GetEventListOptions } from '@openpanel/db'; import { ClientType, db, getEventList, getEventsCount } from '@openpanel/db'; -import { getChart } from '@openpanel/trpc/src/routers/chart'; +import { getChart } from '@openpanel/trpc/src/routers/chart.helpers'; import { zChartInput } from '@openpanel/validation'; async function getProjectId( diff --git a/apps/dashboard/src/components/overview/overview-top-devices.tsx b/apps/dashboard/src/components/overview/overview-top-devices.tsx index 5ac4dee7..b771096b 100644 --- a/apps/dashboard/src/components/overview/overview-top-devices.tsx +++ b/apps/dashboard/src/components/overview/overview-top-devices.tsx @@ -30,6 +30,7 @@ export default function OverviewTopDevices({ title: 'Top devices', btn: 'Devices', chart: { + limit: 10, projectId, startDate, endDate, @@ -60,6 +61,7 @@ export default function OverviewTopDevices({ title: 'Top browser', btn: 'Browser', chart: { + limit: 10, projectId, startDate, endDate, @@ -90,6 +92,7 @@ export default function OverviewTopDevices({ title: 'Top Browser Version', btn: 'Browser Version', chart: { + limit: 10, projectId, startDate, endDate, @@ -120,6 +123,7 @@ export default function OverviewTopDevices({ title: 'Top OS', btn: 'OS', chart: { + limit: 10, projectId, startDate, endDate, @@ -150,6 +154,7 @@ export default function OverviewTopDevices({ title: 'Top OS version', btn: 'OS Version', chart: { + limit: 10, projectId, startDate, endDate, diff --git a/apps/dashboard/src/components/overview/overview-top-events/overview-top-events.tsx b/apps/dashboard/src/components/overview/overview-top-events/overview-top-events.tsx index f5d1711f..63e7e3d1 100644 --- a/apps/dashboard/src/components/overview/overview-top-events/overview-top-events.tsx +++ b/apps/dashboard/src/components/overview/overview-top-events/overview-top-events.tsx @@ -31,6 +31,7 @@ export default function OverviewTopEvents({ title: 'Top events', btn: 'Your', chart: { + limit: 10, projectId, startDate, endDate, @@ -69,6 +70,7 @@ export default function OverviewTopEvents({ title: 'Top events', btn: 'All', chart: { + limit: 10, projectId, startDate, endDate, @@ -100,6 +102,7 @@ export default function OverviewTopEvents({ btn: 'Conversions', hide: conversions.length === 0, chart: { + limit: 10, projectId, startDate, endDate, diff --git a/apps/dashboard/src/components/overview/overview-top-geo.tsx b/apps/dashboard/src/components/overview/overview-top-geo.tsx index e0c82678..a811e5d8 100644 --- a/apps/dashboard/src/components/overview/overview-top-geo.tsx +++ b/apps/dashboard/src/components/overview/overview-top-geo.tsx @@ -29,6 +29,7 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) { title: 'Top countries', btn: 'Countries', chart: { + limit: 10, projectId, startDate, endDate, @@ -59,6 +60,7 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) { title: 'Top regions', btn: 'Regions', chart: { + limit: 10, projectId, startDate, endDate, @@ -89,6 +91,7 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) { title: 'Top cities', btn: 'Cities', chart: { + limit: 10, projectId, startDate, endDate, diff --git a/apps/dashboard/src/components/overview/overview-top-pages.tsx b/apps/dashboard/src/components/overview/overview-top-pages.tsx index 6e8226ae..532affad 100644 --- a/apps/dashboard/src/components/overview/overview-top-pages.tsx +++ b/apps/dashboard/src/components/overview/overview-top-pages.tsx @@ -28,6 +28,7 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) { title: 'Top pages', btn: 'Top pages', chart: { + limit: 10, projectId, startDate, endDate, @@ -58,6 +59,7 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) { title: 'Entry Pages', btn: 'Entries', chart: { + limit: 10, projectId, startDate, endDate, @@ -88,6 +90,7 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) { title: 'Exit Pages', btn: 'Exits', chart: { + limit: 10, projectId, startDate, endDate, diff --git a/apps/dashboard/src/components/overview/overview-top-sources.tsx b/apps/dashboard/src/components/overview/overview-top-sources.tsx index 03bc4cf2..ca03a5b4 100644 --- a/apps/dashboard/src/components/overview/overview-top-sources.tsx +++ b/apps/dashboard/src/components/overview/overview-top-sources.tsx @@ -32,6 +32,7 @@ export default function OverviewTopSources({ title: 'Top sources', btn: 'All', chart: { + limit: 10, projectId, startDate, endDate, @@ -62,6 +63,7 @@ export default function OverviewTopSources({ title: 'Top urls', btn: 'URLs', chart: { + limit: 10, projectId, startDate, endDate, @@ -92,6 +94,7 @@ export default function OverviewTopSources({ title: 'Top types', btn: 'Types', chart: { + limit: 10, projectId, startDate, endDate, @@ -122,6 +125,7 @@ export default function OverviewTopSources({ title: 'UTM Source', btn: 'Source', chart: { + limit: 10, projectId, startDate, endDate, @@ -152,6 +156,7 @@ export default function OverviewTopSources({ title: 'UTM Medium', btn: 'Medium', chart: { + limit: 10, projectId, startDate, endDate, @@ -182,6 +187,7 @@ export default function OverviewTopSources({ title: 'UTM Campaign', btn: 'Campaign', chart: { + limit: 10, projectId, startDate, endDate, @@ -212,6 +218,7 @@ export default function OverviewTopSources({ title: 'UTM Term', btn: 'Term', chart: { + limit: 10, projectId, startDate, endDate, @@ -242,6 +249,7 @@ export default function OverviewTopSources({ title: 'UTM Content', btn: 'Content', chart: { + limit: 10, projectId, startDate, endDate, diff --git a/apps/dashboard/src/components/report/chart/Chart.tsx b/apps/dashboard/src/components/report/chart/Chart.tsx index 33af7a40..674dfa9d 100644 --- a/apps/dashboard/src/components/report/chart/Chart.tsx +++ b/apps/dashboard/src/components/report/chart/Chart.tsx @@ -20,16 +20,16 @@ export function Chart({ events, breakdowns, chartType, - name, range, lineType, previous, formula, - unit, metric, projectId, startDate, endDate, + limit, + offset, }: ReportChartProps) { const [references] = api.reference.getChartReferences.useSuspenseQuery( { @@ -56,6 +56,8 @@ export function Chart({ previous, formula, metric, + limit, + offset, }, { keepPreviousData: true, diff --git a/apps/dashboard/src/components/report/chart/ChartProvider.tsx b/apps/dashboard/src/components/report/chart/ChartProvider.tsx index 29ae49b8..4a30cca7 100644 --- a/apps/dashboard/src/components/report/chart/ChartProvider.tsx +++ b/apps/dashboard/src/components/report/chart/ChartProvider.tsx @@ -10,8 +10,7 @@ import { useState, } from 'react'; -import type { IChartSerie } from '@openpanel/trpc/src/routers/chart'; -import type { IChartProps } from '@openpanel/validation'; +import type { IChartProps, IChartSerie } from '@openpanel/validation'; import { ChartLoading } from './ChartLoading'; import { MetricCardLoading } from './MetricCard'; diff --git a/apps/dashboard/src/components/report/chart/MetricCard.tsx b/apps/dashboard/src/components/report/chart/MetricCard.tsx index aab3e99a..dd05c9ca 100644 --- a/apps/dashboard/src/components/report/chart/MetricCard.tsx +++ b/apps/dashboard/src/components/report/chart/MetricCard.tsx @@ -44,7 +44,7 @@ export function MetricCard({ ); }; - const previous = serie.metrics.previous[metric]; + const previous = serie.metrics.previous?.[metric]; const graphColors = getDiffIndicator( previousIndicatorInverted, @@ -93,7 +93,7 @@ export function MetricCard({
{serie.event.id} - {serie.name || serie.event.displayName || serie.event.name} + {serie.name}
{/* */} @@ -125,9 +125,9 @@ export function MetricCardEmpty() { export function MetricCardLoading() { return (
-
-
-
+
+
+
); } diff --git a/apps/dashboard/src/components/report/chart/ReportBarChart.tsx b/apps/dashboard/src/components/report/chart/ReportBarChart.tsx index 306eaaa9..6ab26c90 100644 --- a/apps/dashboard/src/components/report/chart/ReportBarChart.tsx +++ b/apps/dashboard/src/components/report/chart/ReportBarChart.tsx @@ -41,7 +41,7 @@ export function ReportBarChart({ data }: ReportBarChartProps) { {...(isClickable ? { onClick: () => onClick(serie) } : {})} >
- {serie.metrics.previous[metric]?.value} + {serie.metrics.previous?.[metric]?.value}
{number.format( round((serie.metrics.sum / data.metrics.sum) * 100, 2) diff --git a/apps/dashboard/src/components/report/chart/ReportChartTooltip.tsx b/apps/dashboard/src/components/report/chart/ReportChartTooltip.tsx index d89b8fe5..1ccb3413 100644 --- a/apps/dashboard/src/components/report/chart/ReportChartTooltip.tsx +++ b/apps/dashboard/src/components/report/chart/ReportChartTooltip.tsx @@ -51,7 +51,7 @@ export function ReportChartTooltip({ ) as IRechartPayloadItem; return ( - + {index === 0 && data.date && (
{formatDate(new Date(data.date))}
@@ -64,7 +64,7 @@ export function ReportChartTooltip({ />
- {getLabel(data.label)} + {getLabel(data.name)}
{number.formatWithUnit(data.count, unit)}
diff --git a/apps/dashboard/src/components/report/chart/ReportPieChart.tsx b/apps/dashboard/src/components/report/chart/ReportPieChart.tsx index 3ba859a4..9487313c 100644 --- a/apps/dashboard/src/components/report/chart/ReportPieChart.tsx +++ b/apps/dashboard/src/components/report/chart/ReportPieChart.tsx @@ -24,7 +24,7 @@ export function ReportPieChart({ data }: ReportPieChartProps) { id: serie.id, color: getChartColor(serie.index), index: serie.index, - label: serie.name, + name: serie.name, count: serie.metrics.sum, percent: serie.metrics.sum / sum, })); @@ -88,7 +88,7 @@ const renderLabel = ({ innerRadius: number; outerRadius: number; fill: string; - payload: { label: string; percent: number }; + payload: { name: string; percent: number }; }) => { const RADIAN = Math.PI / 180; const radius = 25 + innerRadius + (outerRadius - innerRadius); @@ -97,7 +97,7 @@ const renderLabel = ({ const yProcent = cy + radiusProcent * Math.sin(-midAngle * RADIAN); const x = cx + radius * Math.cos(-midAngle * RADIAN); const y = cy + radius * Math.sin(-midAngle * RADIAN); - const label = payload.label; + const name = payload.name; const percent = round(payload.percent * 100, 1); return ( @@ -108,7 +108,7 @@ const renderLabel = ({ fill="white" textAnchor="middle" dominantBaseline="central" - fontSize={10} + fontSize={12} fontWeight={700} pointerEvents={'none'} > @@ -120,9 +120,9 @@ const renderLabel = ({ fill={fill} textAnchor={x > cx ? 'start' : 'end'} dominantBaseline="central" - fontSize={10} + fontSize={12} > - {truncate(label, 20)} + {truncate(name, 20)} ); diff --git a/apps/dashboard/src/components/report/chart/ReportTable.tsx b/apps/dashboard/src/components/report/chart/ReportTable.tsx index 64dca2dd..12dd8bd5 100644 --- a/apps/dashboard/src/components/report/chart/ReportTable.tsx +++ b/apps/dashboard/src/components/report/chart/ReportTable.tsx @@ -127,7 +127,7 @@ export function ReportTable({
{number.format(serie.metrics.sum)}
@@ -135,7 +135,7 @@ export function ReportTable({
{number.format(serie.metrics.average)}
diff --git a/apps/dashboard/src/components/report/reportSlice.ts b/apps/dashboard/src/components/report/reportSlice.ts index 9eb6a1ed..979668ee 100644 --- a/apps/dashboard/src/components/report/reportSlice.ts +++ b/apps/dashboard/src/components/report/reportSlice.ts @@ -51,6 +51,7 @@ const initialState: InitialState = { formula: undefined, unit: undefined, metric: 'sum', + limit: 500, }; export const reportSlice = createSlice({ diff --git a/apps/dashboard/src/components/report/sidebar/ReportBreakdowns.tsx b/apps/dashboard/src/components/report/sidebar/ReportBreakdowns.tsx index ac0a589e..3be40b1e 100644 --- a/apps/dashboard/src/components/report/sidebar/ReportBreakdowns.tsx +++ b/apps/dashboard/src/components/report/sidebar/ReportBreakdowns.tsx @@ -43,7 +43,7 @@ export function ReportBreakdowns() {
{selectedBreakdowns.map((item, index) => { return ( -
+
{index} { - dispatch( - addBreakdown({ - name: value, - }) - ); - }} - items={propertiesCombobox} - placeholder="Select breakdown" - /> - )} + { + dispatch( + addBreakdown({ + name: value, + }) + ); + }} + items={propertiesCombobox} + placeholder="Select breakdown" + />
); diff --git a/apps/dashboard/src/hooks/useRechartDataModel.ts b/apps/dashboard/src/hooks/useRechartDataModel.ts index 567a4598..a8dabedc 100644 --- a/apps/dashboard/src/hooks/useRechartDataModel.ts +++ b/apps/dashboard/src/hooks/useRechartDataModel.ts @@ -1,10 +1,22 @@ 'use client'; import { useMemo } from 'react'; -import type { IChartData, IChartSerieDataItem } from '@/trpc/client'; +import type { IChartData } from '@/trpc/client'; import { getChartColor } from '@/utils/theme'; -export type IRechartPayloadItem = IChartSerieDataItem & { color: string }; +export type IRechartPayloadItem = { + id: string; + name: string; + color: string; + event: { id: string; name: string }; + count: number; + date: string; + previous?: { + value: number; + diff: number | null; + state: 'positive' | 'negative' | 'neutral'; + }; +}; export function useRechartDataModel(series: IChartData['series']) { return useMemo(() => { @@ -25,6 +37,9 @@ export function useRechartDataModel(series: IChartData['series']) { acc2[`${serie.id}:count`] = item.count; acc2[`${serie.id}:payload`] = { ...item, + id: serie.id, + event: serie.event, + name: serie.name, color: getChartColor(idx), } satisfies IRechartPayloadItem; } diff --git a/packages/common/package.json b/packages/common/package.json index d682c25a..2e11fe2c 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -8,6 +8,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@openpanel/constants": "workspace:*", "date-fns": "^3.3.1", "mathjs": "^12.3.2", "ramda": "^0.29.1", @@ -34,4 +35,4 @@ ] }, "prettier": "@openpanel/prettier-config" -} +} \ No newline at end of file diff --git a/packages/common/src/fill-series.ts b/packages/common/src/fill-series.ts index 0a7c6f19..2de6e7cb 100644 --- a/packages/common/src/fill-series.ts +++ b/packages/common/src/fill-series.ts @@ -11,12 +11,13 @@ import { startOfMonth, } from 'date-fns'; +import { NOT_SET_VALUE } from '@openpanel/constants'; import type { IInterval } from '@openpanel/validation'; // Define the data structure -interface DataEntry { - label: string; - count: number | null; +export interface ISerieDataItem { + label: string | null | undefined; + count: number; date: string; } @@ -37,8 +38,8 @@ function roundDate(date: Date, interval: IInterval): Date { } // Function to complete the timeline for each label -export function completeTimeline( - data: DataEntry[], +export function completeSerie( + data: ISerieDataItem[], _startDate: string, _endDate: string, interval: IInterval @@ -50,16 +51,16 @@ export function completeTimeline( data.forEach((entry) => { const roundedDate = roundDate(parseISO(entry.date), interval); const dateKey = format(roundedDate, 'yyyy-MM-dd HH:mm:ss'); - - if (!labelsMap.has(entry.label)) { - labelsMap.set(entry.label, new Map()); + const label = entry.label || NOT_SET_VALUE; + if (!labelsMap.has(label)) { + labelsMap.set(label, new Map()); } - const labelData = labelsMap.get(entry.label); + const labelData = labelsMap.get(label); labelData?.set(dateKey, (labelData.get(dateKey) || 0) + (entry.count || 0)); }); // Complete the timeline for each label - const result: Record = {}; + const result: Record = {}; labelsMap.forEach((counts, label) => { let currentDate = roundDate(startDate, interval); result[label] = []; diff --git a/packages/common/src/math.ts b/packages/common/src/math.ts index c24eb461..25d6545e 100644 --- a/packages/common/src/math.ts +++ b/packages/common/src/math.ts @@ -14,7 +14,7 @@ export const average = (arr: (number | null)[]) => { return Number.isNaN(avg) ? 0 : avg; }; -export const sum = (arr: (number | null)[]): number => +export const sum = (arr: (number | null | undefined)[]): number => round(arr.filter(isNumber).reduce((acc, item) => acc + item, 0)); export const min = (arr: (number | null)[]): number => diff --git a/packages/db/src/services/chart.service.ts b/packages/db/src/services/chart.service.ts index 4f0635d7..cbfdec9f 100644 --- a/packages/db/src/services/chart.service.ts +++ b/packages/db/src/services/chart.service.ts @@ -59,18 +59,18 @@ export function getChartSql({ sb.where.endDate = `created_at <= '${formatClickhouseDate(endDate)}'`; } - const breakdown = breakdowns[0]!; - if (breakdown) { + breakdowns.forEach((breakdown, index) => { + const key = index === 0 ? 'label' : `label_${index}`; const value = breakdown.name.startsWith('properties.') ? `mapValues(mapExtractKeyLike(properties, ${escape( breakdown.name.replace(/^properties\./, '').replace('.*.', '.%.') )}))` : escape(breakdown.name); - sb.select.label = breakdown.name.startsWith('properties.') - ? `arrayElement(${value}, 1) as label` - : `${breakdown.name} as label`; - sb.groupBy.label = `label`; - } + sb.select[key] = breakdown.name.startsWith('properties.') + ? `arrayElement(${value}, 1) as ${key}` + : `${breakdown.name} as ${key}`; + sb.groupBy[key] = `${key}`; + }); if (event.segment === 'user') { sb.select.count = `countDistinct(profile_id) as count`; diff --git a/packages/db/src/services/organization.service.ts b/packages/db/src/services/organization.service.ts index bff28ca0..85c5519a 100644 --- a/packages/db/src/services/organization.service.ts +++ b/packages/db/src/services/organization.service.ts @@ -24,6 +24,7 @@ export function transformOrganization(org: Organization) { export async function getCurrentOrganizations() { const session = auth(); if (!session.userId) return []; + const organizations = await db.organization.findMany({ where: { members: { diff --git a/packages/trpc/src/routers/chart.helpers.ts b/packages/trpc/src/routers/chart.helpers.ts index 43bb0b62..d5e202a1 100644 --- a/packages/trpc/src/routers/chart.helpers.ts +++ b/packages/trpc/src/routers/chart.helpers.ts @@ -3,6 +3,7 @@ import { endOfMonth, endOfYear, formatISO, + startOfDay, startOfMonth, startOfYear, subDays, @@ -15,8 +16,17 @@ import * as mathjs from 'mathjs'; import { repeat, reverse } from 'ramda'; import { escape } from 'sqlstring'; -import { completeTimeline, round } from '@openpanel/common'; -import { alphabetIds, NOT_SET_VALUE } from '@openpanel/constants'; +import { + average, + completeSerie, + max, + min, + round, + slug, + sum, +} from '@openpanel/common'; +import type { ISerieDataItem } from '@openpanel/common'; +import { alphabetIds } from '@openpanel/constants'; import { chQuery, createSqlBuilder, @@ -26,27 +36,23 @@ import { getProfiles, } from '@openpanel/db'; import type { + FinalChart, IChartEvent, IChartInput, + IChartInputWithDates, IChartRange, IGetChartDataInput, IInterval, + PreviousValue, } from '@openpanel/validation'; -export type GetChartDataResult = Awaited>; -export interface ResultItem { - label: string | null; - count: number | null; - date: string; -} - function getEventLegend(event: IChartEvent) { return event.displayName ?? event.name; } export function withFormula( { formula, events }: IChartInput, - series: GetChartDataResult + series: Awaited> ) { if (!formula) { return series; @@ -145,58 +151,6 @@ const toDynamicISODateWithTZ = ( return `${date}T00:00:00Z`; }; -export async function getChartData(payload: IGetChartDataInput) { - async function getSeries() { - const result = await chQuery(getChartSql(payload)); - if (result.length === 0 && payload.breakdowns.length > 0) { - return await chQuery( - getChartSql({ - ...payload, - breakdowns: [], - }) - ); - } - return result; - } - - return getSeries() - .then((data) => - completeTimeline( - data.map((item) => { - const label = item.label?.trim() || NOT_SET_VALUE; - - return { - ...item, - count: item.count ? round(item.count) : null, - label, - }; - }), - payload.startDate, - payload.endDate, - payload.interval - ) - ) - .then((series) => { - return Object.keys(series).map((label) => { - const isBreakdown = - payload.breakdowns.length && !alphabetIds.includes(label as 'A'); - const serieLabel = isBreakdown ? label : getEventLegend(payload.event); - return { - name: serieLabel, - event: payload.event, - data: series[label]!.map((item) => ({ - ...item, - date: toDynamicISODateWithTZ( - item.date, - payload.startDate, - payload.interval - ), - })), - }; - }); - }); -} - export function getDatesFromRange(range: IChartRange) { if (range === '30min' || range === 'lastHour') { const minutes = range === '30min' ? 30 : 60; @@ -224,17 +178,7 @@ export function getDatesFromRange(range: IChartRange) { } if (range === '7d') { - const startDate = formatISO(subDays(new Date(), 7)); - const endDate = formatISO(new Date()); - - return { - startDate, - endDate, - }; - } - - if (range === '30d') { - const startDate = formatISO(subDays(new Date(), 30)); + const startDate = formatISO(startOfDay(subDays(new Date(), 7))); const endDate = formatISO(new Date()); return { @@ -285,9 +229,13 @@ export function getDatesFromRange(range: IChartRange) { }; } + // range === '30d' + const startDate = formatISO(startOfDay(subDays(new Date(), 30))); + const endDate = formatISO(new Date()); + return { - startDate: formatISO(subDays(new Date(), 30)), - endDate: formatISO(new Date()), + startDate, + endDate, }; } @@ -491,22 +439,51 @@ export async function getFunnelStep({ return getProfiles(res.map((r) => r.id)); } -export async function getSeriesFromEvents(input: IChartInput) { - const { startDate, endDate } = - input.startDate && input.endDate - ? { - startDate: input.startDate, - endDate: input.endDate, - } - : getDatesFromRange(input.range); +export async function getChartSerie(payload: IGetChartDataInput) { + async function getSeries() { + const result = await chQuery(getChartSql(payload)); + if (result.length === 0 && payload.breakdowns.length > 0) { + return await chQuery( + getChartSql({ + ...payload, + breakdowns: [], + }) + ); + } + return result; + } + return getSeries() + .then((data) => + completeSerie(data, payload.startDate, payload.endDate, payload.interval) + ) + .then((series) => { + return Object.keys(series).map((label) => { + const isBreakdown = + payload.breakdowns.length && !alphabetIds.includes(label as 'A'); + const serieLabel = isBreakdown ? label : getEventLegend(payload.event); + return { + name: serieLabel, + event: payload.event, + data: series[label]!.map((item) => ({ + ...item, + date: toDynamicISODateWithTZ( + item.date, + payload.startDate, + payload.interval + ), + })), + }; + }); + }); +} + +export async function getChartSeries(input: IChartInputWithDates) { const series = ( await Promise.all( input.events.map(async (event) => - getChartData({ + getChartSerie({ ...input, - startDate, - endDate, event, }) ) @@ -519,3 +496,188 @@ export async function getSeriesFromEvents(input: IChartInput) { return series; } } + +export async function getChart(input: IChartInput) { + const currentPeriod = getChartStartEndDate(input); + const previousPeriod = getChartPrevStartEndDate({ + range: input.range, + ...currentPeriod, + }); + + const promises = [getChartSeries({ ...input, ...currentPeriod })]; + + if (input.previous) { + promises.push( + getChartSeries({ + ...input, + ...previousPeriod, + }) + ); + } + + const result = await Promise.all(promises); + const series = result[0]!; + const previousSeries = result[1]; + const limit = input.limit || 300; + const offset = input.offset || 0; + const final: FinalChart = { + series: series + .slice(offset, limit ? offset + limit : series.length) + .map((serie) => { + const previousSerie = previousSeries?.find( + (item) => item.name === serie.name + ); + const metrics = { + sum: sum(serie.data.map((item) => item.count)), + average: round(average(serie.data.map((item) => item.count)), 2), + min: min(serie.data.map((item) => item.count)), + max: max(serie.data.map((item) => item.count)), + }; + + return { + id: slug(serie.name), + name: serie.name, + event: { + id: serie.event.id!, + name: serie.event.displayName ?? serie.event.name, + }, + metrics: { + ...metrics, + ...(input.previous + ? { + previous: { + sum: getPreviousMetric( + metrics.sum, + previousSerie + ? sum(previousSerie?.data.map((item) => item.count)) + : null + ), + average: getPreviousMetric( + metrics.average, + previousSerie + ? round( + average( + previousSerie?.data.map((item) => item.count) + ), + 2 + ) + : null + ), + min: getPreviousMetric( + metrics.sum, + previousSerie + ? min(previousSerie?.data.map((item) => item.count)) + : null + ), + max: getPreviousMetric( + metrics.sum, + previousSerie + ? max(previousSerie?.data.map((item) => item.count)) + : null + ), + }, + } + : {}), + }, + data: serie.data.map((item, index) => ({ + date: item.date, + count: item.count ?? 0, + previous: previousSerie?.data[index] + ? getPreviousMetric( + item.count ?? 0, + previousSerie?.data[index]?.count ?? null + ) + : undefined, + })), + }; + }), + metrics: { + sum: 0, + average: 0, + min: 0, + max: 0, + }, + }; + + final.metrics.sum = sum(final.series.map((item) => item.metrics.sum)); + final.metrics.average = round( + average(final.series.map((item) => item.metrics.average)), + 2 + ); + final.metrics.min = min(final.series.map((item) => item.metrics.min)); + final.metrics.max = max(final.series.map((item) => item.metrics.max)); + if (input.previous) { + final.metrics.previous = { + sum: getPreviousMetric( + final.metrics.sum, + sum(final.series.map((item) => item.metrics.previous?.sum?.value ?? 0)) + ), + average: getPreviousMetric( + final.metrics.average, + round( + average( + final.series.map( + (item) => item.metrics.previous?.average?.value ?? 0 + ) + ), + 2 + ) + ), + min: getPreviousMetric( + final.metrics.min, + min(final.series.map((item) => item.metrics.previous?.min?.value ?? 0)) + ), + max: getPreviousMetric( + final.metrics.max, + max(final.series.map((item) => item.metrics.previous?.max?.value ?? 0)) + ), + }; + } + + // Sort by sum + final.series = final.series.sort((a, b) => { + if (input.chartType === 'linear') { + const sumA = a.data.reduce((acc, item) => acc + (item.count ?? 0), 0); + const sumB = b.data.reduce((acc, item) => acc + (item.count ?? 0), 0); + return sumB - sumA; + } else { + return b.metrics[input.metric] - a.metrics[input.metric]; + } + }); + + return final; +} + +export function getPreviousMetric( + current: number, + previous: number | null +): PreviousValue { + if (previous === null) { + return undefined; + } + + const diff = round( + ((current > previous + ? current / previous + : current < previous + ? previous / current + : 0) - + 1) * + 100, + 1 + ); + + return { + diff: + Number.isNaN(diff) || !Number.isFinite(diff) || current === previous + ? null + : diff, + state: + current > previous + ? 'positive' + : current < previous + ? 'negative' + : 'neutral', + value: previous, + }; +} diff --git a/packages/trpc/src/routers/chart.ts b/packages/trpc/src/routers/chart.ts index b950bdad..006f4262 100644 --- a/packages/trpc/src/routers/chart.ts +++ b/packages/trpc/src/routers/chart.ts @@ -5,57 +5,24 @@ import { z } from 'zod'; import { average, max, min, round, slug, sum } from '@openpanel/common'; import { chQuery, createSqlBuilder, db } from '@openpanel/db'; import { zChartInput } from '@openpanel/validation'; -import type { IChartEvent, IChartInput } from '@openpanel/validation'; +import type { + FinalChart, + IChartInput, + PreviousValue, +} from '@openpanel/validation'; import { getProjectAccessCached } from '../access'; import { TRPCAccessError } from '../errors'; import { createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc'; import { + getChart, getChartPrevStartEndDate, + getChartSeries, getChartStartEndDate, getFunnelData, getFunnelStep, - getSeriesFromEvents, } from './chart.helpers'; -type PreviousValue = { - value: number; - diff: number | null; - state: 'positive' | 'negative' | 'neutral'; -} | null; - -interface Metrics { - sum: number; - average: number; - min: number; - max: number; - previous: { - sum: PreviousValue; - average: PreviousValue; - min: PreviousValue; - max: PreviousValue; - }; -} - -export interface IChartSerie { - id: string; - name: string; - event: IChartEvent; - metrics: Metrics; - data: { - date: string; - count: number; - label: string | null; - previous: PreviousValue; - }[]; -} - -export interface FinalChart { - events: IChartInput['events']; - series: IChartSerie[]; - metrics: Metrics; -} - export const chartRouter = createTRPCRouter({ events: protectedProcedure .input(z.object({ projectId: z.string() })) @@ -91,6 +58,7 @@ export const chartRouter = createTRPCRouter({ 'has_profile', 'name', 'path', + 'origin', 'referrer', 'referrer_name', 'duration', @@ -208,183 +176,3 @@ export const chartRouter = createTRPCRouter({ return getChart(input); }), }); - -export async function getChart(input: IChartInput) { - const currentPeriod = getChartStartEndDate(input); - const previousPeriod = getChartPrevStartEndDate({ - range: input.range, - ...currentPeriod, - }); - - const promises = [getSeriesFromEvents({ ...input, ...currentPeriod })]; - - if (input.previous) { - promises.push( - getSeriesFromEvents({ - ...input, - ...previousPeriod, - }) - ); - } - - const result = await Promise.all(promises); - const series = result[0]!; - const previousSeries = result[1]; - - const final: FinalChart = { - events: input.events, - series: series.map((serie) => { - const previousSerie = previousSeries?.find( - (item) => item.name === serie.name - ); - const metrics = { - sum: sum(serie.data.map((item) => item.count)), - average: round(average(serie.data.map((item) => item.count)), 2), - min: min(serie.data.map((item) => item.count)), - max: max(serie.data.map((item) => item.count)), - }; - - return { - id: slug(serie.name), // TODO: Remove this (temporary fix for the frontend - name: serie.name, - event: { - ...serie.event, - displayName: serie.event.displayName ?? serie.event.name, - }, - metrics: { - ...metrics, - previous: { - sum: getPreviousMetric( - metrics.sum, - previousSerie - ? sum(previousSerie?.data.map((item) => item.count)) - : null - ), - average: getPreviousMetric( - metrics.average, - previousSerie - ? round( - average(previousSerie?.data.map((item) => item.count)), - 2 - ) - : null - ), - min: getPreviousMetric( - metrics.sum, - previousSerie - ? min(previousSerie?.data.map((item) => item.count)) - : null - ), - max: getPreviousMetric( - metrics.sum, - previousSerie - ? max(previousSerie?.data.map((item) => item.count)) - : null - ), - }, - }, - data: serie.data.map((item, index) => ({ - date: item.date, - count: item.count ?? 0, - label: item.label, - previous: previousSerie?.data[index] - ? getPreviousMetric( - item.count ?? 0, - previousSerie?.data[index]?.count ?? null - ) - : null, - })), - }; - }), - metrics: { - sum: 0, - average: 0, - min: 0, - max: 0, - previous: { - sum: null, - average: null, - min: null, - max: null, - }, - }, - }; - - final.metrics.sum = sum(final.series.map((item) => item.metrics.sum)); - final.metrics.average = round( - average(final.series.map((item) => item.metrics.average)), - 2 - ); - final.metrics.min = min(final.series.map((item) => item.metrics.min)); - final.metrics.max = max(final.series.map((item) => item.metrics.max)); - final.metrics.previous = { - sum: getPreviousMetric( - final.metrics.sum, - sum(final.series.map((item) => item.metrics.previous.sum?.value ?? 0)) - ), - average: getPreviousMetric( - final.metrics.average, - round( - average( - final.series.map((item) => item.metrics.previous.average?.value ?? 0) - ), - 2 - ) - ), - min: getPreviousMetric( - final.metrics.min, - min(final.series.map((item) => item.metrics.previous.min?.value ?? 0)) - ), - max: getPreviousMetric( - final.metrics.max, - max(final.series.map((item) => item.metrics.previous.max?.value ?? 0)) - ), - }; - - // Sort by sum - final.series = final.series.sort((a, b) => { - if (input.chartType === 'linear') { - const sumA = a.data.reduce((acc, item) => acc + (item.count ?? 0), 0); - const sumB = b.data.reduce((acc, item) => acc + (item.count ?? 0), 0); - return sumB - sumA; - } else { - return b.metrics[input.metric] - a.metrics[input.metric]; - } - }); - - return final; -} - -export function getPreviousMetric( - current: number, - previous: number | null -): PreviousValue { - if (previous === null) { - return null; - } - - const diff = round( - ((current > previous - ? current / previous - : current < previous - ? previous / current - : 0) - - 1) * - 100, - 1 - ); - - return { - diff: - Number.isNaN(diff) || !Number.isFinite(diff) || current === previous - ? null - : diff, - state: - current > previous - ? 'positive' - : current < previous - ? 'negative' - : 'neutral', - value: previous, - }; -} diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts index ce419fc6..7d06a15f 100644 --- a/packages/validation/src/index.ts +++ b/packages/validation/src/index.ts @@ -71,6 +71,8 @@ export const zChartInput = z.object({ projectId: z.string(), startDate: z.string().nullish(), endDate: z.string().nullish(), + limit: z.number().optional(), + offset: z.number().optional(), }); export const zReportInput = zChartInput.extend({ diff --git a/packages/validation/src/types.validation.ts b/packages/validation/src/types.validation.ts index 00d8296f..0270c778 100644 --- a/packages/validation/src/types.validation.ts +++ b/packages/validation/src/types.validation.ts @@ -31,9 +31,54 @@ export type IChartType = z.infer; export type IChartMetric = z.infer; export type IChartLineType = z.infer; export type IChartRange = z.infer; +export interface IChartInputWithDates extends IChartInput { + startDate: string; + endDate: string; +} export type IGetChartDataInput = { event: IChartEvent; projectId: string; startDate: string; endDate: string; } & Omit; + +export type PreviousValue = + | { + value: number; + diff: number | null; + state: 'positive' | 'negative' | 'neutral'; + } + | undefined; + +export type Metrics = { + sum: number; + average: number; + min: number; + max: number; + previous?: { + sum: PreviousValue; + average: PreviousValue; + min: PreviousValue; + max: PreviousValue; + }; +}; + +export type IChartSerie = { + id: string; + name: string; + event: { + id: string; + name: string; + }; + metrics: Metrics; + data: { + date: string; + count: number; + previous: PreviousValue; + }[]; +}; + +export type FinalChart = { + series: IChartSerie[]; + metrics: Metrics; +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 601f6e68..53a5a936 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -799,6 +799,9 @@ importers: packages/common: dependencies: + '@openpanel/constants': + specifier: workspace:* + version: link:../constants date-fns: specifier: ^3.3.1 version: 3.3.1