web: histogram

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-01-07 21:56:30 +01:00
parent 31a4e1a277
commit 39827226d8
29 changed files with 523 additions and 321 deletions

View File

@@ -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>
);
})}

View File

@@ -38,7 +38,11 @@ export function ReportInterval() {
{
value: 'month',
label: 'Month',
disabled: range < 1,
disabled:
range === 'today' ||
range === '24h' ||
range === '1h' ||
range === '30min',
},
]}
/>

View 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}
/>
)}
</>
);
}

View File

@@ -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>
);

View File

@@ -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} />;
}

View File

@@ -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';

View File

@@ -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
</>
) : (
<>

View File

@@ -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);

View File

@@ -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
}

View File

@@ -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>

View File

@@ -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',

View File

@@ -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(),
};
})
: [],
};
});

View File

@@ -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'],
};
}

View File

@@ -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;
}),

View 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()}`;
}

View 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, ', ')
: '',
};
}

View File

@@ -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'
>;

View File

@@ -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';
}

View File

@@ -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;
}

View File

@@ -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({