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

@@ -23,8 +23,15 @@ As of today (2023-12-12) I have more then 1.2 million events in PSQL and perform
- [x] Fix tables on settings - [x] Fix tables on settings
- [x] Rename event label - [x] Rename event label
- [ ] Real time data (mostly screen_views stats) - [ ] Common web dashboard
- [ ] Active users (5min, 10min, 30min) - [x] User histogram (last 30 minutes)
- [ ] Bounce rate
- [ ] Session duration
- [ ] Views per session
- [ ] Unique users
- [ ] Total users
- [ ] Total pageviews
- [ ] Total events
- [x] Save report to a specific dashboard - [x] Save report to a specific dashboard
- [x] View events in a list - [x] View events in a list
- [x] Simple filters - [x] Simple filters
@@ -34,6 +41,7 @@ As of today (2023-12-12) I have more then 1.2 million events in PSQL and perform
- [x] Manage dashboards - [x] Manage dashboards
- [ ] Support more chart types - [ ] Support more chart types
- [x] Bar - [x] Bar
- [x] Histogram
- [ ] Pie - [ ] Pie
- [ ] Area - [ ] Area
- [ ] Support funnels - [ ] Support funnels

View File

@@ -70,6 +70,7 @@
"@mixan/prettier-config": "workspace:*", "@mixan/prettier-config": "workspace:*",
"@mixan/tsconfig": "workspace:*", "@mixan/tsconfig": "workspace:*",
"@types/bcrypt": "^5.0.0", "@types/bcrypt": "^5.0.0",
"@types/lodash.debounce": "^4.0.9",
"@types/node": "^18.16.0", "@types/node": "^18.16.0",
"@types/ramda": "^0.29.6", "@types/ramda": "^0.29.6",
"@types/react": "^18.2.20", "@types/react": "^18.2.20",

View File

@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "ChartType" ADD VALUE 'histogram';

View File

@@ -0,0 +1,8 @@
/*
Warnings:
- You are about to drop the column `range` on the `reports` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "reports" DROP COLUMN "range";

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "reports" ADD COLUMN "range" TEXT NOT NULL DEFAULT '1m';

View File

@@ -115,6 +115,7 @@ enum Interval {
enum ChartType { enum ChartType {
linear linear
bar bar
histogram
pie pie
metric metric
area area
@@ -138,7 +139,7 @@ model Report {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
name String name String
interval Interval interval Interval
range Int range String @default("1m")
chart_type ChartType chart_type ChartType
breakdowns Json breakdowns Json
events Json events Json

View File

@@ -10,16 +10,16 @@ export function ReportDateRange() {
return ( return (
<RadioGroup className="overflow-auto"> <RadioGroup className="overflow-auto">
{timeRanges.map((item) => { {Object.values(timeRanges).map((key) => {
return ( return (
<RadioGroupItem <RadioGroupItem
key={item.range} key={key}
active={item.range === range} active={key === range}
onClick={() => { onClick={() => {
dispatch(changeDateRanges(item.range)); dispatch(changeDateRanges(key));
}} }}
> >
{item.title} {key}
</RadioGroupItem> </RadioGroupItem>
); );
})} })}

View File

@@ -38,7 +38,11 @@ export function ReportInterval() {
{ {
value: 'month', value: 'month',
label: '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 { useMappings } from '@/hooks/useMappings';
import { useSelector } from '@/redux'; import { useSelector } from '@/redux';
import type { IToolTipProps } from '@/types'; import type { IToolTipProps } from '@/types';
import { alphabetIds } from '@/utils/constants';
type ReportLineChartTooltipProps = IToolTipProps<{ type ReportLineChartTooltipProps = IToolTipProps<{
color: string; color: string;
@@ -10,7 +11,7 @@ type ReportLineChartTooltipProps = IToolTipProps<{
date: Date; date: Date;
count: number; count: number;
label: string; label: string;
}; } & Record<string, any>;
}>; }>;
export function ReportLineChartTooltip({ export function ReportLineChartTooltip({
@@ -34,11 +35,13 @@ export function ReportLineChartTooltip({
const visible = sorted.slice(0, limit); const visible = sorted.slice(0, limit);
const hidden = sorted.slice(limit); const hidden = sorted.slice(limit);
const first = visible[0]!; const first = visible[0]!;
const isBarChart = first.payload.count === undefined;
return ( return (
<div className="flex flex-col gap-2 rounded-xl border bg-white p-3 text-sm shadow-xl"> <div className="flex flex-col gap-2 rounded-xl border bg-white p-3 text-sm shadow-xl">
{formatDate(new Date(first.payload.date))} {formatDate(new Date(first.payload.date))}
{visible.map((item) => { {visible.map((item, index) => {
const id = alphabetIds[index];
return ( return (
<div key={item.payload.label} className="flex gap-2"> <div key={item.payload.label} className="flex gap-2">
<div <div
@@ -47,9 +50,13 @@ export function ReportLineChartTooltip({
></div> ></div>
<div className="flex flex-col"> <div className="flex flex-col">
<div className="min-w-0 max-w-[200px] overflow-hidden text-ellipsis whitespace-nowrap font-medium"> <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>
<div>{item.payload.count}</div>
</div> </div>
</div> </div>
); );

View File

@@ -6,6 +6,7 @@ import { api } from '@/utils/api';
import { ChartAnimation, ChartAnimationContainer } from './ChartAnimation'; import { ChartAnimation, ChartAnimationContainer } from './ChartAnimation';
import { withChartProivder } from './ChartProvider'; import { withChartProivder } from './ChartProvider';
import { ReportBarChart } from './ReportBarChart'; import { ReportBarChart } from './ReportBarChart';
import { ReportHistogramChart } from './ReportHistogramChart';
import { ReportLineChart } from './ReportLineChart'; import { ReportLineChart } from './ReportLineChart';
export type ReportChartProps = IChartInput; 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') { if (chartType === 'bar') {
return <ReportBarChart data={chart.data} />; return <ReportBarChart data={chart.data} />;
} }

View File

@@ -24,7 +24,7 @@ const initialState: InitialState = {
interval: 'day', interval: 'day',
breakdowns: [], breakdowns: [],
events: [], events: [],
range: 30, range: '1m',
startDate: null, startDate: null,
endDate: null, endDate: null,
}; };
@@ -149,11 +149,11 @@ export const reportSlice = createSlice({
changeDateRanges: (state, action: PayloadAction<IChartRange>) => { changeDateRanges: (state, action: PayloadAction<IChartRange>) => {
state.dirty = true; state.dirty = true;
state.range = action.payload; state.range = action.payload;
if (action.payload === 0.3 || action.payload === 0.6) { if (action.payload === '30min' || action.payload === '1h') {
state.interval = 'minute'; state.interval = 'minute';
} else if (action.payload === 0 || action.payload === 1) { } else if (action.payload === 'today' || action.payload === '24h') {
state.interval = 'hour'; state.interval = 'hour';
} else if (action.payload <= 30) { } else if (action.payload === '7d' || action.payload === '14d') {
state.interval = 'day'; state.interval = 'day';
} else { } else {
state.interval = 'month'; state.interval = 'month';

View File

@@ -108,6 +108,10 @@ export function ReportEvents() {
value: 'user_average', value: 'user_average',
label: 'Unique users (average)', label: 'Unique users (average)',
}, },
{
value: 'one_event_per_user',
label: 'One event per user',
},
]} ]}
label="Segment" label="Segment"
> >
@@ -118,7 +122,11 @@ export function ReportEvents() {
</> </>
) : event.segment === 'user_average' ? ( ) : 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) { export function useBreakpoint<K extends string>(breakpointKey: K) {
const breakpointValue = breakpoints[breakpointKey as keyof ScreensConfig]; const breakpointValue = breakpoints[breakpointKey as keyof ScreensConfig];
const bool = useMediaQuery({ const bool = useMediaQuery({
query: `(max-width: ${breakpointValue})`, query: `(max-width: ${breakpointValue as string})`,
}); });
const capitalizedKey = const capitalizedKey =
breakpointKey[0]?.toUpperCase() + breakpointKey.substring(1); breakpointKey[0]?.toUpperCase() + breakpointKey.substring(1);

View File

@@ -2,13 +2,15 @@ import { useEffect } from 'react';
import debounce from 'lodash.debounce'; import debounce from 'lodash.debounce';
export function useDebounceFn<T>(fn: T, ms = 500): T { 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(() => { useEffect(() => {
return () => { return () => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
debouncedFn.cancel(); 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 { useOrganizationParams } from '@/hooks/useOrganizationParams';
import { db } from '@/server/db'; import { db } from '@/server/db';
import { createServerSideProps } from '@/server/getServerSideProps'; import { createServerSideProps } from '@/server/getServerSideProps';
import { getDashboardBySlug } from '@/server/services/dashboard.service';
import type { IChartRange } from '@/types'; import type { IChartRange } from '@/types';
import { api, handleError } from '@/utils/api'; import { api, handleError } from '@/utils/api';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import { timeRanges } from '@/utils/constants'; import { timeRanges } from '@/utils/constants';
import { getRangeLabel } from '@/utils/getRangeLabel';
import { ChevronRight, MoreHorizontal, Trash } from 'lucide-react'; import { ChevronRight, MoreHorizontal, Trash } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
@@ -74,16 +72,16 @@ export default function Dashboard() {
<PageTitle>{dashboard?.name}</PageTitle> <PageTitle>{dashboard?.name}</PageTitle>
<RadioGroup className="mb-8 overflow-auto"> <RadioGroup className="mb-8 overflow-auto">
{timeRanges.map((item) => { {Object.values(timeRanges).map((key) => {
return ( return (
<RadioGroupItem <RadioGroupItem
key={item.range} key={key}
active={item.range === range} active={key === range}
onClick={() => { onClick={() => {
setRange((p) => (p === item.range ? null : item.range)); setRange((p) => (p === key ? null : key));
}} }}
> >
{item.title} {key}
</RadioGroupItem> </RadioGroupItem>
); );
})} })}
@@ -91,7 +89,7 @@ export default function Dashboard() {
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{reports.map((report) => { {reports.map((report) => {
const chartRange = getRangeLabel(report.range); const chartRange = timeRanges[report.range];
return ( return (
<div <div
className="rounded-md border border-border bg-white shadow" className="rounded-md border border-border bg-white shadow"
@@ -109,7 +107,7 @@ export default function Dashboard() {
<span className={range !== null ? 'line-through' : ''}> <span className={range !== null ? 'line-through' : ''}>
{chartRange} {chartRange}
</span> </span>
{range !== null && <span>{getRangeLabel(range)}</span>} {range !== null && <span>{range}</span>}
</div> </div>
)} )}
</div> </div>

View File

@@ -10,6 +10,7 @@ export default async function handler(
) { ) {
try { try {
const counts = await db.$transaction([ const counts = await db.$transaction([
db.user.count(),
db.organization.count(), db.organization.count(),
db.project.count(), db.project.count(),
db.client.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({ const project = await db.project.create({
data: { data: {
name: 'Acme Website', name: 'Acme Website',

View File

@@ -1,12 +1,14 @@
import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc'; import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
import * as cache from '@/server/cache'; 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 { db } from '@/server/db';
import { getUniqueEvents } from '@/server/services/event.service'; import { getUniqueEvents } from '@/server/services/event.service';
import { getProjectBySlug } from '@/server/services/project.service'; import { getProjectBySlug } from '@/server/services/project.service';
import type { import type {
IChartEvent, IChartEvent,
IChartInputWithDates,
IChartRange, IChartRange,
IGetChartDataInput,
IInterval, IInterval,
} from '@/types'; } from '@/types';
import { getDaysOldDate } from '@/utils/date'; import { getDaysOldDate } from '@/utils/date';
@@ -33,7 +35,12 @@ export const chartRouter = createTRPCRouter({
() => getUniqueEvents({ projectId: project.id }) () => getUniqueEvents({ projectId: project.id })
); );
return events; return [
{
name: '*',
},
...events,
];
}), }),
properties: protectedProcedure properties: protectedProcedure
@@ -124,12 +131,21 @@ export const chartRouter = createTRPCRouter({
chart: protectedProcedure chart: protectedProcedure
.input(zChartInputWithDates.merge(z.object({ projectSlug: z.string() }))) .input(zChartInputWithDates.merge(z.object({ projectSlug: z.string() })))
.query(async ({ input: { projectSlug, events, ...input } }) => { .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 project = await getProjectBySlug(projectSlug);
const series: Awaited<ReturnType<typeof getChartData>> = []; const series: Awaited<ReturnType<typeof getChartData>> = [];
for (const event of events) { for (const event of events) {
series.push( series.push(
...(await getChartData({ ...(await getChartData({
...input, ...input,
startDate,
endDate,
event, event,
projectId: project.id, 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 { interface ResultItem {
label: string | null; label: string | null;
count: number; count: number;
date: string; 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) { function getEventLegend(event: IChartEvent) {
return event.displayName ?? `${event.name} (${event.id})`; return event.displayName ?? `${event.name} (${event.id})`;
} }
function getDatesFromRange(range: IChartRange) { function getDatesFromRange(range: IChartRange) {
if (range === 0) { if (range === 'today') {
const startDate = new Date(); const startDate = new Date();
const endDate = new Date().toISOString(); const endDate = new Date().toISOString();
startDate.setHours(0, 0, 0, 0); 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( const startDate = new Date(
Date.now() - 1000 * 60 * (range * 100) Date.now() - 1000 * 60 * (range === '30min' ? 30 : 60)
).toISOString(); ).toISOString();
const endDate = new Date().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); startDate.setUTCHours(0, 0, 0, 0);
const endDate = new Date(); const endDate = new Date();
endDate.setUTCHours(23, 59, 59, 999); endDate.setUTCHours(23, 59, 59, 999);
@@ -250,202 +254,14 @@ function getDatesFromRange(range: IChartRange) {
}; };
} }
function getChartSql({ async function getChartData(payload: IGetChartDataInput) {
event, let result = await db.$queryRawUnsafe<ResultItem[]>(getChartSql(payload));
chartType,
breakdowns,
interval,
startDate,
endDate,
projectId,
}: Omit<IGetChartDataInput, 'range'> & {
projectId: string;
}) {
const select = [];
const where = [`project_id = '${projectId}'`];
const groupBy = [];
const orderBy = [];
if (event.segment === 'event') { if (result.length === 0 && payload.breakdowns.length > 0) {
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) {
result = await db.$queryRawUnsafe<ResultItem[]>( result = await db.$queryRawUnsafe<ResultItem[]>(
getChartSql({ getChartSql({
chartType, ...payload,
event,
breakdowns: [], breakdowns: [],
interval,
startDate,
endDate,
projectId,
}) })
); );
} }
@@ -455,7 +271,7 @@ async function getChartData({
(acc, item) => { (acc, item) => {
// item.label can be null when using breakdowns on a property // item.label can be null when using breakdowns on a property
// that doesn't exist on all events // 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 (label) {
if (acc[label]) { if (acc[label]) {
acc[label]?.push(item); acc[label]?.push(item);
@@ -472,30 +288,35 @@ async function getChartData({
); );
return Object.keys(series).map((key) => { 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] ?? []; const data = series[key] ?? [];
return { return {
name: legend, name: legend,
event: { event: {
id: event.id, id: payload.event.id,
name: event.name, name: payload.event.name,
}, },
metrics: { metrics: {
total: sum(data.map((item) => item.count)), total: sum(data.map((item) => item.count)),
average: round(average(data.map((item) => item.count))), average: round(average(data.map((item) => item.count))),
}, },
data: data:
chartType === 'linear' payload.chartType === 'linear' || payload.chartType === 'histogram'
? fillEmptySpotsInTimeline(data, interval, startDate, endDate).map( ? fillEmptySpotsInTimeline(
(item) => { data,
return { payload.interval,
label: legend, payload.startDate,
count: round(item.count), payload.endDate
date: new Date(item.date).toISOString(), ).map((item) => {
}; return {
} label: legend,
) count: round(item.count),
date: new Date(item.date).toISOString(),
};
})
: [], : [],
}; };
}); });

View File

@@ -9,7 +9,7 @@ import type {
IChartInput, IChartInput,
IChartRange, IChartRange,
} from '@/types'; } from '@/types';
import { alphabetIds } from '@/utils/constants'; import { alphabetIds, timeRanges } from '@/utils/constants';
import { zChartInput } from '@/utils/validation'; import { zChartInput } from '@/utils/validation';
import type { Report as DbReport } from '@prisma/client'; import type { Report as DbReport } from '@prisma/client';
import { z } from 'zod'; import { z } from 'zod';
@@ -48,7 +48,7 @@ function transformReport(report: DbReport): IChartInput & { id: string } {
chartType: report.chart_type, chartType: report.chart_type,
interval: report.interval, interval: report.interval,
name: report.name || 'Untitled', 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 { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
import { db } from '@/server/db';
import { z } from 'zod'; import { z } from 'zod';
export const config = { export const config = {
@@ -15,7 +14,7 @@ export const uiRouter = createTRPCRouter({
url: z.string(), url: z.string(),
}) })
) )
.query(async ({ input: { url } }) => { .query(({ input: { url } }) => {
const parts = url.split('/').filter(Boolean); const parts = url.split('/').filter(Boolean);
return parts; 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 IInterval = z.infer<typeof zTimeInterval>;
export type IChartType = z.infer<typeof zChartType>; export type IChartType = z.infer<typeof zChartType>;
export type IChartData = RouterOutputs['chart']['chart']; 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'> & { export type IToolTipProps<T> = Omit<TooltipProps<number, string>, 'payload'> & {
payload?: T[]; payload?: T[];
}; };
@@ -36,3 +36,13 @@ export type IProject = Project;
export type IClientWithProject = Client & { export type IClientWithProject = Client & {
project: IProject; 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 = { export const chartTypes = {
linear: 'Linear', linear: 'Linear',
bar: 'Bar', bar: 'Bar',
histogram: 'Histogram',
pie: 'Pie', pie: 'Pie',
metric: 'Metric', metric: 'Metric',
area: 'Area', area: 'Area',
@@ -33,19 +34,19 @@ export const alphabetIds = [
'J', 'J',
] as const; ] as const;
export const timeRanges = [ export const timeRanges = {
{ range: 0.3, title: '30m' }, '30min': '30min',
{ range: 0.6, title: '1h' }, '1h': '1h',
{ range: 0, title: 'Today' }, today: 'today',
{ range: 1, title: '24h' }, '24h': '24h',
{ range: 7, title: '7d' }, '7d': '7d',
{ range: 14, title: '14d' }, '14d': '14d',
{ range: 30, title: '30d' }, '1m': '1m',
{ range: 90, title: '3mo' }, '3m': '3m',
{ range: 180, title: '6mo' }, '6m': '6m',
{ range: 365, title: '1y' }, '1y': '1y',
] as const; } as const;
export function isMinuteIntervalEnabledByRange(range: number) { export function isMinuteIntervalEnabledByRange(range: keyof typeof timeRanges) {
return range === 0.3 || range === 0.6; 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 { 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[]] { function objectToZodEnums<K extends string>(obj: Record<K, any>): [K, ...K[]] {
const [firstKey, ...otherKeys] = Object.keys(obj) as K[]; const [firstKey, ...otherKeys] = Object.keys(obj) as K[];
@@ -11,7 +11,7 @@ export const zChartEvent = z.object({
id: z.string(), id: z.string(),
name: z.string(), name: z.string(),
displayName: z.string().optional(), 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( filters: z.array(
z.object({ z.object({
id: z.string(), id: z.string(),
@@ -39,17 +39,7 @@ export const zChartInput = z.object({
interval: zTimeInterval, interval: zTimeInterval,
events: zChartEvents, events: zChartEvents,
breakdowns: zChartBreakdowns, breakdowns: zChartBreakdowns,
range: z range: z.enum(objectToZodEnums(timeRanges)),
.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)),
}); });
export const zChartInputWithDates = zChartInput.extend({ export const zChartInputWithDates = zChartInput.extend({

View File

@@ -353,7 +353,7 @@ export class Mixan {
this.logger('Mixan: Clear, send remaining events and remove profileId'); this.logger('Mixan: Clear, send remaining events and remove profileId');
this.eventBatcher.send(); this.eventBatcher.send();
this.options.removeItem('@mixan:profileId'); this.options.removeItem('@mixan:profileId');
this.options.removeItem('@mixan:session'); this.options.removeItem('@mixan:lastEventAt');
this.profileId = undefined; this.profileId = undefined;
this.setAnonymousUser(); this.setAnonymousUser();
} }

13
pnpm-lock.yaml generated
View File

@@ -257,6 +257,9 @@ importers:
'@types/bcrypt': '@types/bcrypt':
specifier: ^5.0.0 specifier: ^5.0.0
version: 5.0.1 version: 5.0.1
'@types/lodash.debounce':
specifier: ^4.0.9
version: 4.0.9
'@types/node': '@types/node':
specifier: ^18.16.0 specifier: ^18.16.0
version: 18.18.8 version: 18.18.8
@@ -2422,6 +2425,16 @@ packages:
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
dev: false dev: false
/@types/lodash.debounce@4.0.9:
resolution: {integrity: sha512-Ma5JcgTREwpLRwMM+XwBR7DaWe96nC38uCBDFKZWbNKD+osjVzdpnUSwBcqCptrp16sSOLBAUb50Car5I0TCsQ==}
dependencies:
'@types/lodash': 4.14.202
dev: true
/@types/lodash@4.14.202:
resolution: {integrity: sha512-OvlIYQK9tNneDlS0VN54LLd5uiPCBOp7gS5Z0f1mjoJYBrtStzgmJBxONW3U6OZqdtNzZPmn9BS/7WI7BFFcFQ==}
dev: true
/@types/node@18.18.8: /@types/node@18.18.8:
resolution: {integrity: sha512-OLGBaaK5V3VRBS1bAkMVP2/W9B+H8meUfl866OrMNQqt7wDgdpWPp5o6gmIc9pB+lIQHSq4ZL8ypeH1vPxcPaQ==} resolution: {integrity: sha512-OLGBaaK5V3VRBS1bAkMVP2/W9B+H8meUfl866OrMNQqt7wDgdpWPp5o6gmIc9pB+lIQHSq4ZL8ypeH1vPxcPaQ==}
dependencies: dependencies:

View File

@@ -31,14 +31,15 @@ const config = {
{ checksVoidReturn: { attributes: false } }, { checksVoidReturn: { attributes: false } },
], ],
'import/consistent-type-specifier-style': ['error', 'prefer-top-level'], 'import/consistent-type-specifier-style': ['error', 'prefer-top-level'],
"@typescript-eslint/no-empty-function": "off", '@typescript-eslint/no-empty-function': 'off',
"@typescript-eslint/no-floating-promises": "off", '@typescript-eslint/no-floating-promises': 'off',
"@typescript-eslint/no-explicit-any": "warn", '@typescript-eslint/no-explicit-any': 'warn',
"@typescript-eslint/ban-ts-comment": "off", '@typescript-eslint/ban-ts-comment': 'off',
"@typescript-eslint/no-unsafe-return": "off", '@typescript-eslint/no-unsafe-return': 'off',
"@typescript-eslint/no-unsafe-assignment": "warn", '@typescript-eslint/no-unsafe-assignment': 'warn',
"@typescript-eslint/no-unsafe-member-access": "warn", '@typescript-eslint/no-unsafe-member-access': 'warn',
"@typescript-eslint/no-unsafe-argument": "warn" '@typescript-eslint/no-unsafe-argument': 'warn',
'@typescript-eslint/prefer-nullish-coalescing': 'off',
}, },
ignorePatterns: [ ignorePatterns: [
'**/.eslintrc.cjs', '**/.eslintrc.cjs',