web: histogram
This commit is contained in:
@@ -10,16 +10,16 @@ export function ReportDateRange() {
|
||||
|
||||
return (
|
||||
<RadioGroup className="overflow-auto">
|
||||
{timeRanges.map((item) => {
|
||||
{Object.values(timeRanges).map((key) => {
|
||||
return (
|
||||
<RadioGroupItem
|
||||
key={item.range}
|
||||
active={item.range === range}
|
||||
key={key}
|
||||
active={key === range}
|
||||
onClick={() => {
|
||||
dispatch(changeDateRanges(item.range));
|
||||
dispatch(changeDateRanges(key));
|
||||
}}
|
||||
>
|
||||
{item.title}
|
||||
{key}
|
||||
</RadioGroupItem>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -38,7 +38,11 @@ export function ReportInterval() {
|
||||
{
|
||||
value: 'month',
|
||||
label: 'Month',
|
||||
disabled: range < 1,
|
||||
disabled:
|
||||
range === 'today' ||
|
||||
range === '24h' ||
|
||||
range === '1h' ||
|
||||
range === '30min',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
107
apps/web/src/components/report/chart/ReportHistogramChart.tsx
Normal file
107
apps/web/src/components/report/chart/ReportHistogramChart.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { AutoSizer } from '@/components/AutoSizer';
|
||||
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
|
||||
import type { IChartData, IInterval } from '@/types';
|
||||
import { alphabetIds } from '@/utils/constants';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
import { Bar, BarChart, CartesianGrid, Tooltip, XAxis, YAxis } from 'recharts';
|
||||
|
||||
import { useChartContext } from './ChartProvider';
|
||||
import { ReportLineChartTooltip } from './ReportLineChartTooltip';
|
||||
import { ReportTable } from './ReportTable';
|
||||
|
||||
interface ReportHistogramChartProps {
|
||||
data: IChartData;
|
||||
interval: IInterval;
|
||||
}
|
||||
|
||||
export function ReportHistogramChart({
|
||||
interval,
|
||||
data,
|
||||
}: ReportHistogramChartProps) {
|
||||
const { editMode } = useChartContext();
|
||||
const [visibleSeries, setVisibleSeries] = useState<string[]>([]);
|
||||
const formatDate = useFormatDateInterval(interval);
|
||||
|
||||
const ref = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!ref.current && data) {
|
||||
const max = 20;
|
||||
|
||||
setVisibleSeries(
|
||||
data?.series?.slice(0, max).map((serie) => serie.name) ?? []
|
||||
);
|
||||
// ref.current = true;
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const rel = data.series[0]?.data.map(({ date }) => {
|
||||
return {
|
||||
date,
|
||||
...data.series.reduce((acc, serie, idx) => {
|
||||
return {
|
||||
...acc,
|
||||
...serie.data.reduce(
|
||||
(acc2, item) => {
|
||||
const id = alphabetIds[idx];
|
||||
if (item.date === date) {
|
||||
acc2[`${id}:count`] = item.count;
|
||||
acc2[`${id}:label`] = item.label;
|
||||
}
|
||||
return acc2;
|
||||
},
|
||||
{} as Record<string, any>
|
||||
),
|
||||
};
|
||||
}, {}),
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="max-sm:-mx-3">
|
||||
<AutoSizer disableHeight>
|
||||
{({ width }) => (
|
||||
<BarChart
|
||||
width={width}
|
||||
height={Math.min(Math.max(width * 0.5, 250), 400)}
|
||||
data={rel}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<Tooltip content={<ReportLineChartTooltip />} />
|
||||
<XAxis
|
||||
fontSize={12}
|
||||
dataKey="date"
|
||||
tickFormatter={formatDate}
|
||||
tickLine={false}
|
||||
/>
|
||||
{data.series.map((serie, index) => {
|
||||
const id = alphabetIds[index];
|
||||
return (
|
||||
<>
|
||||
<YAxis dataKey={`${id}:count`} fontSize={12}></YAxis>
|
||||
<Bar
|
||||
stackId={id}
|
||||
key={serie.name}
|
||||
isAnimationActive={false}
|
||||
name={serie.name}
|
||||
dataKey={`${id}:count`}
|
||||
fill={getChartColor(index)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</BarChart>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</div>
|
||||
{editMode && (
|
||||
<ReportTable
|
||||
data={data}
|
||||
visibleSeries={visibleSeries}
|
||||
setVisibleSeries={setVisibleSeries}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
|
||||
import { useMappings } from '@/hooks/useMappings';
|
||||
import { useSelector } from '@/redux';
|
||||
import type { IToolTipProps } from '@/types';
|
||||
import { alphabetIds } from '@/utils/constants';
|
||||
|
||||
type ReportLineChartTooltipProps = IToolTipProps<{
|
||||
color: string;
|
||||
@@ -10,7 +11,7 @@ type ReportLineChartTooltipProps = IToolTipProps<{
|
||||
date: Date;
|
||||
count: number;
|
||||
label: string;
|
||||
};
|
||||
} & Record<string, any>;
|
||||
}>;
|
||||
|
||||
export function ReportLineChartTooltip({
|
||||
@@ -34,11 +35,13 @@ export function ReportLineChartTooltip({
|
||||
const visible = sorted.slice(0, limit);
|
||||
const hidden = sorted.slice(limit);
|
||||
const first = visible[0]!;
|
||||
const isBarChart = first.payload.count === undefined;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-xl border bg-white p-3 text-sm shadow-xl">
|
||||
{formatDate(new Date(first.payload.date))}
|
||||
{visible.map((item) => {
|
||||
{visible.map((item, index) => {
|
||||
const id = alphabetIds[index];
|
||||
return (
|
||||
<div key={item.payload.label} className="flex gap-2">
|
||||
<div
|
||||
@@ -47,9 +50,13 @@ export function ReportLineChartTooltip({
|
||||
></div>
|
||||
<div className="flex flex-col">
|
||||
<div className="min-w-0 max-w-[200px] overflow-hidden text-ellipsis whitespace-nowrap font-medium">
|
||||
{getLabel(item.payload.label)}
|
||||
{isBarChart
|
||||
? item.payload[`${id}:label`]
|
||||
: getLabel(item.payload.label)}
|
||||
</div>
|
||||
<div>
|
||||
{isBarChart ? item.payload[`${id}:count`] : item.payload.count}
|
||||
</div>
|
||||
<div>{item.payload.count}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { api } from '@/utils/api';
|
||||
import { ChartAnimation, ChartAnimationContainer } from './ChartAnimation';
|
||||
import { withChartProivder } from './ChartProvider';
|
||||
import { ReportBarChart } from './ReportBarChart';
|
||||
import { ReportHistogramChart } from './ReportHistogramChart';
|
||||
import { ReportLineChart } from './ReportLineChart';
|
||||
|
||||
export type ReportChartProps = IChartInput;
|
||||
@@ -88,6 +89,10 @@ export const Chart = memo(
|
||||
);
|
||||
}
|
||||
|
||||
if (chartType === 'histogram') {
|
||||
return <ReportHistogramChart interval={interval} data={chart.data} />;
|
||||
}
|
||||
|
||||
if (chartType === 'bar') {
|
||||
return <ReportBarChart data={chart.data} />;
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ const initialState: InitialState = {
|
||||
interval: 'day',
|
||||
breakdowns: [],
|
||||
events: [],
|
||||
range: 30,
|
||||
range: '1m',
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
};
|
||||
@@ -149,11 +149,11 @@ export const reportSlice = createSlice({
|
||||
changeDateRanges: (state, action: PayloadAction<IChartRange>) => {
|
||||
state.dirty = true;
|
||||
state.range = action.payload;
|
||||
if (action.payload === 0.3 || action.payload === 0.6) {
|
||||
if (action.payload === '30min' || action.payload === '1h') {
|
||||
state.interval = 'minute';
|
||||
} else if (action.payload === 0 || action.payload === 1) {
|
||||
} else if (action.payload === 'today' || action.payload === '24h') {
|
||||
state.interval = 'hour';
|
||||
} else if (action.payload <= 30) {
|
||||
} else if (action.payload === '7d' || action.payload === '14d') {
|
||||
state.interval = 'day';
|
||||
} else {
|
||||
state.interval = 'month';
|
||||
|
||||
@@ -108,6 +108,10 @@ export function ReportEvents() {
|
||||
value: 'user_average',
|
||||
label: 'Unique users (average)',
|
||||
},
|
||||
{
|
||||
value: 'one_event_per_user',
|
||||
label: 'One event per user',
|
||||
},
|
||||
]}
|
||||
label="Segment"
|
||||
>
|
||||
@@ -118,7 +122,11 @@ export function ReportEvents() {
|
||||
</>
|
||||
) : event.segment === 'user_average' ? (
|
||||
<>
|
||||
<Users size={12} /> Average per user
|
||||
<Users size={12} /> Unique users (average)
|
||||
</>
|
||||
) : event.segment === 'one_event_per_user' ? (
|
||||
<>
|
||||
<Users size={12} /> One event per user
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -13,7 +13,7 @@ const breakpoints = theme?.screens ?? {
|
||||
export function useBreakpoint<K extends string>(breakpointKey: K) {
|
||||
const breakpointValue = breakpoints[breakpointKey as keyof ScreensConfig];
|
||||
const bool = useMediaQuery({
|
||||
query: `(max-width: ${breakpointValue})`,
|
||||
query: `(max-width: ${breakpointValue as string})`,
|
||||
});
|
||||
const capitalizedKey =
|
||||
breakpointKey[0]?.toUpperCase() + breakpointKey.substring(1);
|
||||
|
||||
@@ -2,13 +2,15 @@ import { useEffect } from 'react';
|
||||
import debounce from 'lodash.debounce';
|
||||
|
||||
export function useDebounceFn<T>(fn: T, ms = 500): T {
|
||||
const debouncedFn = debounce(fn, ms);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-call
|
||||
const debouncedFn = debounce(fn as any, ms);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
||||
debouncedFn.cancel();
|
||||
};
|
||||
});
|
||||
|
||||
return debouncedFn;
|
||||
return debouncedFn as T
|
||||
}
|
||||
|
||||
@@ -15,12 +15,10 @@ import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { useOrganizationParams } from '@/hooks/useOrganizationParams';
|
||||
import { db } from '@/server/db';
|
||||
import { createServerSideProps } from '@/server/getServerSideProps';
|
||||
import { getDashboardBySlug } from '@/server/services/dashboard.service';
|
||||
import type { IChartRange } from '@/types';
|
||||
import { api, handleError } from '@/utils/api';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { timeRanges } from '@/utils/constants';
|
||||
import { getRangeLabel } from '@/utils/getRangeLabel';
|
||||
import { ChevronRight, MoreHorizontal, Trash } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
@@ -74,16 +72,16 @@ export default function Dashboard() {
|
||||
<PageTitle>{dashboard?.name}</PageTitle>
|
||||
|
||||
<RadioGroup className="mb-8 overflow-auto">
|
||||
{timeRanges.map((item) => {
|
||||
{Object.values(timeRanges).map((key) => {
|
||||
return (
|
||||
<RadioGroupItem
|
||||
key={item.range}
|
||||
active={item.range === range}
|
||||
key={key}
|
||||
active={key === range}
|
||||
onClick={() => {
|
||||
setRange((p) => (p === item.range ? null : item.range));
|
||||
setRange((p) => (p === key ? null : key));
|
||||
}}
|
||||
>
|
||||
{item.title}
|
||||
{key}
|
||||
</RadioGroupItem>
|
||||
);
|
||||
})}
|
||||
@@ -91,7 +89,7 @@ export default function Dashboard() {
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{reports.map((report) => {
|
||||
const chartRange = getRangeLabel(report.range);
|
||||
const chartRange = timeRanges[report.range];
|
||||
return (
|
||||
<div
|
||||
className="rounded-md border border-border bg-white shadow"
|
||||
@@ -109,7 +107,7 @@ export default function Dashboard() {
|
||||
<span className={range !== null ? 'line-through' : ''}>
|
||||
{chartRange}
|
||||
</span>
|
||||
{range !== null && <span>{getRangeLabel(range)}</span>}
|
||||
{range !== null && <span>{range}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ export default async function handler(
|
||||
) {
|
||||
try {
|
||||
const counts = await db.$transaction([
|
||||
db.user.count(),
|
||||
db.organization.count(),
|
||||
db.project.count(),
|
||||
db.client.count(),
|
||||
@@ -25,6 +26,15 @@ export default async function handler(
|
||||
},
|
||||
});
|
||||
|
||||
const user = await db.user.create({
|
||||
data: {
|
||||
name: 'Carl',
|
||||
password: await hashPassword('password'),
|
||||
email: 'lindesvard@gmail.com',
|
||||
organization_id: organization.id,
|
||||
},
|
||||
});
|
||||
|
||||
const project = await db.project.create({
|
||||
data: {
|
||||
name: 'Acme Website',
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
|
||||
import * as cache from '@/server/cache';
|
||||
import { getChartSql } from '@/server/chart-sql/getChartSql';
|
||||
import { isJsonPath, selectJsonPath } from '@/server/chart-sql/helpers';
|
||||
import { db } from '@/server/db';
|
||||
import { getUniqueEvents } from '@/server/services/event.service';
|
||||
import { getProjectBySlug } from '@/server/services/project.service';
|
||||
import type {
|
||||
IChartEvent,
|
||||
IChartInputWithDates,
|
||||
IChartRange,
|
||||
IGetChartDataInput,
|
||||
IInterval,
|
||||
} from '@/types';
|
||||
import { getDaysOldDate } from '@/utils/date';
|
||||
@@ -33,7 +35,12 @@ export const chartRouter = createTRPCRouter({
|
||||
() => getUniqueEvents({ projectId: project.id })
|
||||
);
|
||||
|
||||
return events;
|
||||
return [
|
||||
{
|
||||
name: '*',
|
||||
},
|
||||
...events,
|
||||
];
|
||||
}),
|
||||
|
||||
properties: protectedProcedure
|
||||
@@ -124,12 +131,21 @@ export const chartRouter = createTRPCRouter({
|
||||
chart: protectedProcedure
|
||||
.input(zChartInputWithDates.merge(z.object({ projectSlug: z.string() })))
|
||||
.query(async ({ input: { projectSlug, events, ...input } }) => {
|
||||
const { startDate, endDate } =
|
||||
input.startDate && input.endDate
|
||||
? {
|
||||
startDate: input.startDate,
|
||||
endDate: input.endDate,
|
||||
}
|
||||
: getDatesFromRange(input.range);
|
||||
const project = await getProjectBySlug(projectSlug);
|
||||
const series: Awaited<ReturnType<typeof getChartData>> = [];
|
||||
for (const event of events) {
|
||||
series.push(
|
||||
...(await getChartData({
|
||||
...input,
|
||||
startDate,
|
||||
endDate,
|
||||
event,
|
||||
projectId: project.id,
|
||||
}))
|
||||
@@ -176,48 +192,18 @@ export const chartRouter = createTRPCRouter({
|
||||
}),
|
||||
});
|
||||
|
||||
function selectJsonPath(property: string) {
|
||||
const jsonPath = property
|
||||
.replace(/^properties\./, '')
|
||||
.replace(/\.\*\./g, '.**.');
|
||||
return `jsonb_path_query(properties, '$.${jsonPath}')`;
|
||||
}
|
||||
|
||||
function isJsonPath(property: string) {
|
||||
return property.startsWith('properties');
|
||||
}
|
||||
|
||||
interface ResultItem {
|
||||
label: string | null;
|
||||
count: number;
|
||||
date: string;
|
||||
}
|
||||
|
||||
function propertyNameToSql(name: string) {
|
||||
if (name.includes('.')) {
|
||||
const str = name
|
||||
.split('.')
|
||||
.map((item, index) => (index === 0 ? item : `'${item}'`))
|
||||
.join('->');
|
||||
const findLastOf = '->';
|
||||
const lastArrow = str.lastIndexOf(findLastOf);
|
||||
if (lastArrow === -1) {
|
||||
return str;
|
||||
}
|
||||
const first = str.slice(0, lastArrow);
|
||||
const last = str.slice(lastArrow + findLastOf.length);
|
||||
return `${first}->>${last}`;
|
||||
}
|
||||
|
||||
return name;
|
||||
}
|
||||
|
||||
function getEventLegend(event: IChartEvent) {
|
||||
return event.displayName ?? `${event.name} (${event.id})`;
|
||||
}
|
||||
|
||||
function getDatesFromRange(range: IChartRange) {
|
||||
if (range === 0) {
|
||||
if (range === 'today') {
|
||||
const startDate = new Date();
|
||||
const endDate = new Date().toISOString();
|
||||
startDate.setHours(0, 0, 0, 0);
|
||||
@@ -228,9 +214,9 @@ function getDatesFromRange(range: IChartRange) {
|
||||
};
|
||||
}
|
||||
|
||||
if (isFloat(range)) {
|
||||
if (range === '30min' || range === '1h') {
|
||||
const startDate = new Date(
|
||||
Date.now() - 1000 * 60 * (range * 100)
|
||||
Date.now() - 1000 * 60 * (range === '30min' ? 30 : 60)
|
||||
).toISOString();
|
||||
const endDate = new Date().toISOString();
|
||||
|
||||
@@ -240,7 +226,25 @@ function getDatesFromRange(range: IChartRange) {
|
||||
};
|
||||
}
|
||||
|
||||
const startDate = getDaysOldDate(range);
|
||||
let days = 1;
|
||||
|
||||
if (range === '24h') {
|
||||
days = 1;
|
||||
} else if (range === '7d') {
|
||||
days = 7;
|
||||
} else if (range === '14d') {
|
||||
days = 14;
|
||||
} else if (range === '1m') {
|
||||
days = 30;
|
||||
} else if (range === '3m') {
|
||||
days = 90;
|
||||
} else if (range === '6m') {
|
||||
days = 180;
|
||||
} else if (range === '1y') {
|
||||
days = 365;
|
||||
}
|
||||
|
||||
const startDate = getDaysOldDate(days);
|
||||
startDate.setUTCHours(0, 0, 0, 0);
|
||||
const endDate = new Date();
|
||||
endDate.setUTCHours(23, 59, 59, 999);
|
||||
@@ -250,202 +254,14 @@ function getDatesFromRange(range: IChartRange) {
|
||||
};
|
||||
}
|
||||
|
||||
function getChartSql({
|
||||
event,
|
||||
chartType,
|
||||
breakdowns,
|
||||
interval,
|
||||
startDate,
|
||||
endDate,
|
||||
projectId,
|
||||
}: Omit<IGetChartDataInput, 'range'> & {
|
||||
projectId: string;
|
||||
}) {
|
||||
const select = [];
|
||||
const where = [`project_id = '${projectId}'`];
|
||||
const groupBy = [];
|
||||
const orderBy = [];
|
||||
async function getChartData(payload: IGetChartDataInput) {
|
||||
let result = await db.$queryRawUnsafe<ResultItem[]>(getChartSql(payload));
|
||||
|
||||
if (event.segment === 'event') {
|
||||
select.push(`count(*)::int as count`);
|
||||
} else if (event.segment === 'user_average') {
|
||||
select.push(`COUNT(*)::float / COUNT(DISTINCT profile_id)::float as count`);
|
||||
} else {
|
||||
select.push(`count(DISTINCT profile_id)::int as count`);
|
||||
}
|
||||
|
||||
switch (chartType) {
|
||||
case 'bar': {
|
||||
orderBy.push('count DESC');
|
||||
break;
|
||||
}
|
||||
case 'linear': {
|
||||
select.push(`date_trunc('${interval}', "createdAt") as date`);
|
||||
groupBy.push('date');
|
||||
orderBy.push('date');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (event) {
|
||||
const { name, filters } = event;
|
||||
where.push(`name = '${name}'`);
|
||||
if (filters.length > 0) {
|
||||
filters.forEach((filter) => {
|
||||
const { name, value, operator } = filter;
|
||||
switch (operator) {
|
||||
case 'contains': {
|
||||
if (name.includes('.*.') || name.endsWith('[*]')) {
|
||||
// TODO: Make sure this works
|
||||
// where.push(
|
||||
// `properties @? '$.${name
|
||||
// .replace(/^properties\./, '')
|
||||
// .replace(/\.\*\./g, '[*].')} ? (@ like_regex "${value[0]}")'`
|
||||
// );
|
||||
} else {
|
||||
where.push(
|
||||
`(${value
|
||||
.map(
|
||||
(val) =>
|
||||
`${propertyNameToSql(name)} like '%${String(val).replace(
|
||||
/'/g,
|
||||
"''"
|
||||
)}%'`
|
||||
)
|
||||
.join(' OR ')})`
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'is': {
|
||||
if (name.includes('.*.') || name.endsWith('[*]')) {
|
||||
where.push(
|
||||
`properties @? '$.${name
|
||||
.replace(/^properties\./, '')
|
||||
.replace(/\.\*\./g, '[*].')} ? (${value
|
||||
.map((val) => `@ == "${val}"`)
|
||||
.join(' || ')})'`
|
||||
);
|
||||
} else {
|
||||
where.push(
|
||||
`${propertyNameToSql(name)} in (${value
|
||||
.map((val) => `'${val}'`)
|
||||
.join(', ')})`
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'isNot': {
|
||||
if (name.includes('.*.') || name.endsWith('[*]')) {
|
||||
where.push(
|
||||
`properties @? '$.${name
|
||||
.replace(/^properties\./, '')
|
||||
.replace(/\.\*\./g, '[*].')} ? (${value
|
||||
.map((val) => `@ != "${val}"`)
|
||||
.join(' && ')})'`
|
||||
);
|
||||
} else if (name.includes('.')) {
|
||||
where.push(
|
||||
`${propertyNameToSql(name)} not in (${value
|
||||
.map((val) => `'${val}'`)
|
||||
.join(', ')})`
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (breakdowns.length) {
|
||||
const breakdown = breakdowns[0];
|
||||
if (breakdown) {
|
||||
if (isJsonPath(breakdown.name)) {
|
||||
select.push(`${selectJsonPath(breakdown.name)} as label`);
|
||||
} else {
|
||||
select.push(`${breakdown.name} as label`);
|
||||
}
|
||||
groupBy.push(`label`);
|
||||
}
|
||||
} else {
|
||||
if (event.name) {
|
||||
select.push(`'${event.name}' as label`);
|
||||
}
|
||||
}
|
||||
|
||||
if (startDate) {
|
||||
where.push(`"createdAt" >= '${startDate}'`);
|
||||
}
|
||||
|
||||
if (endDate) {
|
||||
where.push(`"createdAt" <= '${endDate}'`);
|
||||
}
|
||||
|
||||
const sql = [
|
||||
`SELECT ${select.join(', ')}`,
|
||||
`FROM events`,
|
||||
`WHERE ${where.join(' AND ')}`,
|
||||
];
|
||||
|
||||
if (groupBy.length) {
|
||||
sql.push(`GROUP BY ${groupBy.join(', ')}`);
|
||||
}
|
||||
if (orderBy.length) {
|
||||
sql.push(`ORDER BY ${orderBy.join(', ')}`);
|
||||
}
|
||||
|
||||
console.log('SQL ->', sql.join('\n'));
|
||||
|
||||
return sql.join('\n');
|
||||
}
|
||||
|
||||
type IGetChartDataInput = {
|
||||
event: IChartEvent;
|
||||
} & Omit<IChartInputWithDates, 'events' | 'name'>;
|
||||
|
||||
async function getChartData({
|
||||
chartType,
|
||||
event,
|
||||
breakdowns,
|
||||
interval,
|
||||
range,
|
||||
startDate: _startDate,
|
||||
endDate: _endDate,
|
||||
projectId,
|
||||
}: IGetChartDataInput & {
|
||||
projectId: string;
|
||||
}) {
|
||||
const { startDate, endDate } =
|
||||
_startDate && _endDate
|
||||
? {
|
||||
startDate: _startDate,
|
||||
endDate: _endDate,
|
||||
}
|
||||
: getDatesFromRange(range);
|
||||
|
||||
const sql = getChartSql({
|
||||
chartType,
|
||||
event,
|
||||
breakdowns,
|
||||
interval,
|
||||
startDate,
|
||||
endDate,
|
||||
projectId,
|
||||
});
|
||||
|
||||
let result = await db.$queryRawUnsafe<ResultItem[]>(sql);
|
||||
|
||||
if (result.length === 0 && breakdowns.length > 0) {
|
||||
if (result.length === 0 && payload.breakdowns.length > 0) {
|
||||
result = await db.$queryRawUnsafe<ResultItem[]>(
|
||||
getChartSql({
|
||||
chartType,
|
||||
event,
|
||||
...payload,
|
||||
breakdowns: [],
|
||||
interval,
|
||||
startDate,
|
||||
endDate,
|
||||
projectId,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -455,7 +271,7 @@ async function getChartData({
|
||||
(acc, item) => {
|
||||
// item.label can be null when using breakdowns on a property
|
||||
// that doesn't exist on all events
|
||||
const label = item.label?.trim() ?? event.id;
|
||||
const label = item.label?.trim() ?? payload.event.id;
|
||||
if (label) {
|
||||
if (acc[label]) {
|
||||
acc[label]?.push(item);
|
||||
@@ -472,30 +288,35 @@ async function getChartData({
|
||||
);
|
||||
|
||||
return Object.keys(series).map((key) => {
|
||||
const legend = breakdowns.length ? key : getEventLegend(event);
|
||||
const legend = payload.breakdowns.length
|
||||
? key
|
||||
: getEventLegend(payload.event);
|
||||
const data = series[key] ?? [];
|
||||
|
||||
return {
|
||||
name: legend,
|
||||
event: {
|
||||
id: event.id,
|
||||
name: event.name,
|
||||
id: payload.event.id,
|
||||
name: payload.event.name,
|
||||
},
|
||||
metrics: {
|
||||
total: sum(data.map((item) => item.count)),
|
||||
average: round(average(data.map((item) => item.count))),
|
||||
},
|
||||
data:
|
||||
chartType === 'linear'
|
||||
? fillEmptySpotsInTimeline(data, interval, startDate, endDate).map(
|
||||
(item) => {
|
||||
return {
|
||||
label: legend,
|
||||
count: round(item.count),
|
||||
date: new Date(item.date).toISOString(),
|
||||
};
|
||||
}
|
||||
)
|
||||
payload.chartType === 'linear' || payload.chartType === 'histogram'
|
||||
? fillEmptySpotsInTimeline(
|
||||
data,
|
||||
payload.interval,
|
||||
payload.startDate,
|
||||
payload.endDate
|
||||
).map((item) => {
|
||||
return {
|
||||
label: legend,
|
||||
count: round(item.count),
|
||||
date: new Date(item.date).toISOString(),
|
||||
};
|
||||
})
|
||||
: [],
|
||||
};
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ import type {
|
||||
IChartInput,
|
||||
IChartRange,
|
||||
} from '@/types';
|
||||
import { alphabetIds } from '@/utils/constants';
|
||||
import { alphabetIds, timeRanges } from '@/utils/constants';
|
||||
import { zChartInput } from '@/utils/validation';
|
||||
import type { Report as DbReport } from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
@@ -48,7 +48,7 @@ function transformReport(report: DbReport): IChartInput & { id: string } {
|
||||
chartType: report.chart_type,
|
||||
interval: report.interval,
|
||||
name: report.name || 'Untitled',
|
||||
range: (report.range as IChartRange) ?? 30,
|
||||
range: report.range as IChartRange ?? timeRanges['1m'],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
|
||||
import { db } from '@/server/db';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const config = {
|
||||
@@ -15,7 +14,7 @@ export const uiRouter = createTRPCRouter({
|
||||
url: z.string(),
|
||||
})
|
||||
)
|
||||
.query(async ({ input: { url } }) => {
|
||||
.query(({ input: { url } }) => {
|
||||
const parts = url.split('/').filter(Boolean);
|
||||
return parts;
|
||||
}),
|
||||
|
||||
71
apps/web/src/server/chart-sql/getChartSql.ts
Normal file
71
apps/web/src/server/chart-sql/getChartSql.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { IGetChartDataInput } from '@/types';
|
||||
|
||||
import {
|
||||
createSqlBuilder,
|
||||
getWhereClause,
|
||||
isJsonPath,
|
||||
selectJsonPath,
|
||||
} from './helpers';
|
||||
|
||||
export function getChartSql({
|
||||
event,
|
||||
breakdowns,
|
||||
interval,
|
||||
startDate,
|
||||
endDate,
|
||||
projectId,
|
||||
}: IGetChartDataInput) {
|
||||
const { sb, join, getWhere, getFrom, getSelect, getOrderBy, getGroupBy } =
|
||||
createSqlBuilder();
|
||||
|
||||
sb.where.projectId = `project_id = '${projectId}'`;
|
||||
if (event.name !== '*') {
|
||||
sb.where.eventName = `name = '${event.name}'`;
|
||||
}
|
||||
sb.where.eventFilter = join(getWhereClause(event.filters), ' AND ');
|
||||
|
||||
sb.select.count = `count(*)::int as count`;
|
||||
sb.select.date = `date_trunc('${interval}', "createdAt") as date`;
|
||||
sb.groupBy.date = 'date';
|
||||
sb.orderBy.date = 'date ASC';
|
||||
|
||||
if (startDate) {
|
||||
sb.where.startDate = `"createdAt" >= '${startDate}'`;
|
||||
}
|
||||
|
||||
if (endDate) {
|
||||
sb.where.endDate = `"createdAt" <= '${endDate}'`;
|
||||
}
|
||||
|
||||
const breakdown = breakdowns[0]!;
|
||||
if (breakdown) {
|
||||
if (isJsonPath(breakdown.name)) {
|
||||
sb.select.label = `${selectJsonPath(breakdown.name)} as label`;
|
||||
} else {
|
||||
sb.select.label = `${breakdown.name} as label`;
|
||||
}
|
||||
sb.groupBy.label = `label`;
|
||||
}
|
||||
|
||||
if (event.segment === 'user') {
|
||||
sb.select.count = `count(DISTINCT profile_id)::int as count`;
|
||||
}
|
||||
|
||||
if (event.segment === 'user_average') {
|
||||
sb.select.count = `COUNT(*)::float / COUNT(DISTINCT profile_id)::float as count`;
|
||||
}
|
||||
|
||||
if (event.segment === 'one_event_per_user') {
|
||||
sb.from = `(
|
||||
SELECT DISTINCT on (profile_id) * from events WHERE ${join(
|
||||
sb.where,
|
||||
' AND '
|
||||
)}
|
||||
ORDER BY profile_id, "createdAt" DESC
|
||||
) as subQuery`;
|
||||
|
||||
return `${getSelect()} ${getFrom()} ${getGroupBy()} ${getOrderBy()}`;
|
||||
}
|
||||
|
||||
return `${getSelect()} ${getFrom()} ${getWhere()} ${getGroupBy()} ${getOrderBy()}`;
|
||||
}
|
||||
140
apps/web/src/server/chart-sql/helpers.ts
Normal file
140
apps/web/src/server/chart-sql/helpers.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import type { IChartEventFilter } from '@/types';
|
||||
|
||||
export function getWhereClause(filters: IChartEventFilter[]) {
|
||||
const where: string[] = [];
|
||||
if (filters.length > 0) {
|
||||
filters.forEach((filter) => {
|
||||
const { name, value, operator } = filter;
|
||||
switch (operator) {
|
||||
case 'contains': {
|
||||
if (name.includes('.*.') || name.endsWith('[*]')) {
|
||||
// TODO: Make sure this works
|
||||
// where.push(
|
||||
// `properties @? '$.${name
|
||||
// .replace(/^properties\./, '')
|
||||
// .replace(/\.\*\./g, '[*].')} ? (@ like_regex "${value[0]}")'`
|
||||
// );
|
||||
} else {
|
||||
where.push(
|
||||
`(${value
|
||||
.map(
|
||||
(val) =>
|
||||
`${propertyNameToSql(name)} like '%${String(val).replace(
|
||||
/'/g,
|
||||
"''"
|
||||
)}%'`
|
||||
)
|
||||
.join(' OR ')})`
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'is': {
|
||||
if (name.includes('.*.') || name.endsWith('[*]')) {
|
||||
where.push(
|
||||
`properties @? '$.${name
|
||||
.replace(/^properties\./, '')
|
||||
.replace(/\.\*\./g, '[*].')} ? (${value
|
||||
.map((val) => `@ == "${val}"`)
|
||||
.join(' || ')})'`
|
||||
);
|
||||
} else {
|
||||
where.push(
|
||||
`${propertyNameToSql(name)} in (${value
|
||||
.map((val) => `'${val}'`)
|
||||
.join(', ')})`
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'isNot': {
|
||||
if (name.includes('.*.') || name.endsWith('[*]')) {
|
||||
where.push(
|
||||
`properties @? '$.${name
|
||||
.replace(/^properties\./, '')
|
||||
.replace(/\.\*\./g, '[*].')} ? (${value
|
||||
.map((val) => `@ != "${val}"`)
|
||||
.join(' && ')})'`
|
||||
);
|
||||
} else if (name.includes('.')) {
|
||||
where.push(
|
||||
`${propertyNameToSql(name)} not in (${value
|
||||
.map((val) => `'${val}'`)
|
||||
.join(', ')})`
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return where;
|
||||
}
|
||||
|
||||
export function selectJsonPath(property: string) {
|
||||
const jsonPath = property
|
||||
.replace(/^properties\./, '')
|
||||
.replace(/\.\*\./g, '.**.');
|
||||
return `jsonb_path_query(properties, '$.${jsonPath}')`;
|
||||
}
|
||||
|
||||
export function isJsonPath(property: string) {
|
||||
return property.startsWith('properties');
|
||||
}
|
||||
|
||||
export function propertyNameToSql(name: string) {
|
||||
if (name.includes('.')) {
|
||||
const str = name
|
||||
.split('.')
|
||||
.map((item, index) => (index === 0 ? item : `'${item}'`))
|
||||
.join('->');
|
||||
const findLastOf = '->';
|
||||
const lastArrow = str.lastIndexOf(findLastOf);
|
||||
if (lastArrow === -1) {
|
||||
return str;
|
||||
}
|
||||
const first = str.slice(0, lastArrow);
|
||||
const last = str.slice(lastArrow + findLastOf.length);
|
||||
return `${first}->>${last}`;
|
||||
}
|
||||
|
||||
return name;
|
||||
}
|
||||
|
||||
export function createSqlBuilder() {
|
||||
const join = (obj: Record<string, string> | string[], joiner: string) =>
|
||||
Object.values(obj).filter(Boolean).join(joiner);
|
||||
|
||||
const sb: {
|
||||
where: Record<string, string>;
|
||||
select: Record<string, string>;
|
||||
groupBy: Record<string, string>;
|
||||
orderBy: Record<string, string>;
|
||||
from: string;
|
||||
} = {
|
||||
where: {},
|
||||
from: 'events',
|
||||
select: {},
|
||||
groupBy: {},
|
||||
orderBy: {},
|
||||
};
|
||||
|
||||
return {
|
||||
sb,
|
||||
join,
|
||||
getWhere: () =>
|
||||
Object.keys(sb.where).length ? 'WHERE ' + join(sb.where, ' AND ') : '',
|
||||
getFrom: () => `FROM ${sb.from}`,
|
||||
getSelect: () =>
|
||||
'SELECT ' + (Object.keys(sb.select).length ? join(sb.select, ', ') : '*'),
|
||||
getGroupBy: () =>
|
||||
Object.keys(sb.groupBy).length
|
||||
? 'GROUP BY ' + join(sb.groupBy, ', ')
|
||||
: '',
|
||||
getOrderBy: () =>
|
||||
Object.keys(sb.orderBy).length
|
||||
? 'ORDER BY ' + join(sb.orderBy, ', ')
|
||||
: '',
|
||||
};
|
||||
}
|
||||
@@ -27,7 +27,7 @@ export type IChartBreakdown = z.infer<typeof zChartBreakdown>;
|
||||
export type IInterval = z.infer<typeof zTimeInterval>;
|
||||
export type IChartType = z.infer<typeof zChartType>;
|
||||
export type IChartData = RouterOutputs['chart']['chart'];
|
||||
export type IChartRange = (typeof timeRanges)[number]['range'];
|
||||
export type IChartRange = keyof typeof timeRanges;
|
||||
export type IToolTipProps<T> = Omit<TooltipProps<number, string>, 'payload'> & {
|
||||
payload?: T[];
|
||||
};
|
||||
@@ -36,3 +36,13 @@ export type IProject = Project;
|
||||
export type IClientWithProject = Client & {
|
||||
project: IProject;
|
||||
};
|
||||
|
||||
export type IGetChartDataInput = {
|
||||
event: IChartEvent;
|
||||
projectId: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
} & Omit<
|
||||
IChartInputWithDates,
|
||||
'events' | 'name' | 'startDate' | 'endDate' | 'range'
|
||||
>;
|
||||
|
||||
@@ -8,6 +8,7 @@ export const operators = {
|
||||
export const chartTypes = {
|
||||
linear: 'Linear',
|
||||
bar: 'Bar',
|
||||
histogram: 'Histogram',
|
||||
pie: 'Pie',
|
||||
metric: 'Metric',
|
||||
area: 'Area',
|
||||
@@ -33,19 +34,19 @@ export const alphabetIds = [
|
||||
'J',
|
||||
] as const;
|
||||
|
||||
export const timeRanges = [
|
||||
{ range: 0.3, title: '30m' },
|
||||
{ range: 0.6, title: '1h' },
|
||||
{ range: 0, title: 'Today' },
|
||||
{ range: 1, title: '24h' },
|
||||
{ range: 7, title: '7d' },
|
||||
{ range: 14, title: '14d' },
|
||||
{ range: 30, title: '30d' },
|
||||
{ range: 90, title: '3mo' },
|
||||
{ range: 180, title: '6mo' },
|
||||
{ range: 365, title: '1y' },
|
||||
] as const;
|
||||
export const timeRanges = {
|
||||
'30min': '30min',
|
||||
'1h': '1h',
|
||||
today: 'today',
|
||||
'24h': '24h',
|
||||
'7d': '7d',
|
||||
'14d': '14d',
|
||||
'1m': '1m',
|
||||
'3m': '3m',
|
||||
'6m': '6m',
|
||||
'1y': '1y',
|
||||
} as const;
|
||||
|
||||
export function isMinuteIntervalEnabledByRange(range: number) {
|
||||
return range === 0.3 || range === 0.6;
|
||||
export function isMinuteIntervalEnabledByRange(range: keyof typeof timeRanges) {
|
||||
return range === '30min' || range === '1h';
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import type { IChartRange } from '@/types';
|
||||
|
||||
import { timeRanges } from './constants';
|
||||
|
||||
export function getRangeLabel(range: IChartRange) {
|
||||
return timeRanges.find((item) => item.range === range)?.title ?? null;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { chartTypes, intervals, operators } from './constants';
|
||||
import { chartTypes, intervals, operators, timeRanges } from './constants';
|
||||
|
||||
function objectToZodEnums<K extends string>(obj: Record<K, any>): [K, ...K[]] {
|
||||
const [firstKey, ...otherKeys] = Object.keys(obj) as K[];
|
||||
@@ -11,7 +11,7 @@ export const zChartEvent = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
displayName: z.string().optional(),
|
||||
segment: z.enum(['event', 'user', 'user_average']),
|
||||
segment: z.enum(['event', 'user', 'user_average', 'one_event_per_user']),
|
||||
filters: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
@@ -39,17 +39,7 @@ export const zChartInput = z.object({
|
||||
interval: zTimeInterval,
|
||||
events: zChartEvents,
|
||||
breakdowns: zChartBreakdowns,
|
||||
range: z
|
||||
.literal(0)
|
||||
.or(z.literal(0.3))
|
||||
.or(z.literal(0.6))
|
||||
.or(z.literal(1))
|
||||
.or(z.literal(7))
|
||||
.or(z.literal(14))
|
||||
.or(z.literal(30))
|
||||
.or(z.literal(90))
|
||||
.or(z.literal(180))
|
||||
.or(z.literal(365)),
|
||||
range: z.enum(objectToZodEnums(timeRanges)),
|
||||
});
|
||||
|
||||
export const zChartInputWithDates = zChartInput.extend({
|
||||
|
||||
Reference in New Issue
Block a user