web: histogram
This commit is contained in:
12
README.md
12
README.md
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "ChartType" ADD VALUE 'histogram';
|
||||||
@@ -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";
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "reports" ADD COLUMN "range" TEXT NOT NULL DEFAULT '1m';
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
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 { 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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
};
|
||||||
|
})
|
||||||
: [],
|
: [],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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'],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}),
|
}),
|
||||||
|
|||||||
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 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'
|
||||||
|
>;
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { 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({
|
||||||
|
|||||||
@@ -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
13
pnpm-lock.yaml
generated
@@ -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:
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user