add funnels
This commit is contained in:
@@ -3,6 +3,7 @@
|
|||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "dotenv -e ../../.env -c -v WATCH=1 tsup",
|
"dev": "dotenv -e ../../.env -c -v WATCH=1 tsup",
|
||||||
|
"testing": "API_PORT=3333 pnpm dev",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"build": "rm -rf dist && tsup",
|
"build": "rm -rf dist && tsup",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
@@ -21,7 +22,8 @@
|
|||||||
"pino": "^8.17.2",
|
"pino": "^8.17.2",
|
||||||
"ramda": "^0.29.1",
|
"ramda": "^0.29.1",
|
||||||
"sharp": "^0.33.2",
|
"sharp": "^0.33.2",
|
||||||
"ua-parser-js": "^1.0.37"
|
"ua-parser-js": "^1.0.37",
|
||||||
|
"uuid": "^9.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@mixan/eslint-config": "workspace:*",
|
"@mixan/eslint-config": "workspace:*",
|
||||||
@@ -30,6 +32,7 @@
|
|||||||
"@mixan/types": "workspace:*",
|
"@mixan/types": "workspace:*",
|
||||||
"@types/ramda": "^0.29.6",
|
"@types/ramda": "^0.29.6",
|
||||||
"@types/ua-parser-js": "^0.7.39",
|
"@types/ua-parser-js": "^0.7.39",
|
||||||
|
"@types/uuid": "^9.0.8",
|
||||||
"@types/ws": "^8.5.10",
|
"@types/ws": "^8.5.10",
|
||||||
"eslint": "^8.48.0",
|
"eslint": "^8.48.0",
|
||||||
"prettier": "^3.0.3",
|
"prettier": "^3.0.3",
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ import { getReferrerWithQuery, parseReferrer } from '@/utils/parseReferrer';
|
|||||||
import { isUserAgentSet, parseUserAgent } from '@/utils/parseUserAgent';
|
import { isUserAgentSet, parseUserAgent } from '@/utils/parseUserAgent';
|
||||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
import { omit } from 'ramda';
|
import { omit } from 'ramda';
|
||||||
|
import { v4 as uuid } from 'uuid';
|
||||||
|
|
||||||
import { generateDeviceId, getTime, toISOString } from '@mixan/common';
|
import { generateDeviceId, getTime, toISOString } from '@mixan/common';
|
||||||
import type { IServiceCreateEventPayload } from '@mixan/db';
|
import type { IServiceCreateEventPayload } from '@mixan/db';
|
||||||
import { createBotEvent, getEvents, getSalts } from '@mixan/db';
|
import { createBotEvent, createEvent, getEvents, getSalts } from '@mixan/db';
|
||||||
import type { JobsOptions } from '@mixan/queue';
|
import type { JobsOptions } from '@mixan/queue';
|
||||||
import { eventsQueue, findJobByPrefix } from '@mixan/queue';
|
import { eventsQueue, findJobByPrefix } from '@mixan/queue';
|
||||||
import type { PostEventPayload } from '@mixan/types';
|
import type { PostEventPayload } from '@mixan/types';
|
||||||
@@ -108,6 +109,7 @@ export async function postEvent(
|
|||||||
payload: {
|
payload: {
|
||||||
name: body.name,
|
name: body.name,
|
||||||
deviceId: event?.deviceId || '',
|
deviceId: event?.deviceId || '',
|
||||||
|
sessionId: event?.sessionId || '',
|
||||||
profileId,
|
profileId,
|
||||||
projectId,
|
projectId,
|
||||||
properties: body.properties ?? {},
|
properties: body.properties ?? {},
|
||||||
@@ -145,11 +147,16 @@ export async function postEvent(
|
|||||||
return reply.status(200).send('');
|
return reply.status(200).send('');
|
||||||
}
|
}
|
||||||
|
|
||||||
const [geo, eventsJobs] = await Promise.all([
|
const [geo, eventsJobs, events] = await Promise.all([
|
||||||
parseIp(ip),
|
parseIp(ip),
|
||||||
eventsQueue.getJobs(['delayed']),
|
eventsQueue.getJobs(['delayed']),
|
||||||
|
getEvents(
|
||||||
|
`SELECT * FROM events WHERE name = 'session_start' AND profile_id = '${profileId}' AND project_id = '${projectId}' ORDER BY created_at DESC LIMIT 1`
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const sessionStartEvent = events[0];
|
||||||
|
|
||||||
// find session_end job
|
// find session_end job
|
||||||
const sessionEndJobCurrentDeviceId = findJobByPrefix(
|
const sessionEndJobCurrentDeviceId = findJobByPrefix(
|
||||||
eventsJobs,
|
eventsJobs,
|
||||||
@@ -197,6 +204,7 @@ export async function postEvent(
|
|||||||
deviceId,
|
deviceId,
|
||||||
profileId,
|
profileId,
|
||||||
projectId,
|
projectId,
|
||||||
|
sessionId: createSessionStart ? uuid() : sessionStartEvent?.sessionId ?? '',
|
||||||
properties: Object.assign({}, omit(['path', 'referrer'], body.properties), {
|
properties: Object.assign({}, omit(['path', 'referrer'], body.properties), {
|
||||||
hash,
|
hash,
|
||||||
query,
|
query,
|
||||||
@@ -246,14 +254,12 @@ export async function postEvent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (createSessionStart) {
|
if (createSessionStart) {
|
||||||
eventsQueue.add('event', {
|
// We do not need to queue session_start
|
||||||
type: 'createEvent',
|
await createEvent({
|
||||||
payload: {
|
|
||||||
...payload,
|
...payload,
|
||||||
name: 'session_start',
|
name: 'session_start',
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
createdAt: toISOString(getTime(payload.createdAt) - 10),
|
createdAt: toISOString(getTime(payload.createdAt) - 10),
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3002",
|
"dev": "next dev -p 3002",
|
||||||
|
"testing": "pnpm dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
|
|||||||
@@ -101,6 +101,21 @@ export default function Test() {
|
|||||||
Trigger event
|
Trigger event
|
||||||
</button>
|
</button>
|
||||||
<button onClick={handleLogout}>Logout</button>
|
<button onClick={handleLogout}>Logout</button>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{['a', 'b', 'c', 'd', 'f'].map((letter) => (
|
||||||
|
<button
|
||||||
|
key={letter}
|
||||||
|
onClick={() => {
|
||||||
|
trackEvent(letter, {
|
||||||
|
foo: 'bar',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{letter.toUpperCase()}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "rm -rf .next && pnpm with-env next dev",
|
"dev": "rm -rf .next && pnpm with-env next dev",
|
||||||
|
"testing": "pnpm dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
@@ -17,10 +18,10 @@
|
|||||||
"@hookform/resolvers": "^3.3.4",
|
"@hookform/resolvers": "^3.3.4",
|
||||||
"@mixan/common": "workspace:^",
|
"@mixan/common": "workspace:^",
|
||||||
"@mixan/constants": "workspace:^",
|
"@mixan/constants": "workspace:^",
|
||||||
"@mixan/validation": "workspace:^",
|
|
||||||
"@mixan/db": "workspace:^",
|
"@mixan/db": "workspace:^",
|
||||||
"@mixan/queue": "workspace:^",
|
"@mixan/queue": "workspace:^",
|
||||||
"@mixan/types": "workspace:*",
|
"@mixan/types": "workspace:*",
|
||||||
|
"@mixan/validation": "workspace:^",
|
||||||
"@radix-ui/react-alert-dialog": "^1.0.5",
|
"@radix-ui/react-alert-dialog": "^1.0.5",
|
||||||
"@radix-ui/react-aspect-ratio": "^1.0.3",
|
"@radix-ui/react-aspect-ratio": "^1.0.3",
|
||||||
"@radix-ui/react-avatar": "^1.0.4",
|
"@radix-ui/react-avatar": "^1.0.4",
|
||||||
@@ -46,6 +47,7 @@
|
|||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.1.0",
|
"clsx": "^2.1.0",
|
||||||
"cmdk": "^0.2.1",
|
"cmdk": "^0.2.1",
|
||||||
|
"embla-carousel-react": "8.0.0-rc22",
|
||||||
"hamburger-react": "^2.5.0",
|
"hamburger-react": "^2.5.0",
|
||||||
"lodash.debounce": "^4.0.8",
|
"lodash.debounce": "^4.0.8",
|
||||||
"lodash.throttle": "^4.1.1",
|
"lodash.throttle": "^4.1.1",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { WidgetHead } from '@/components/overview/overview-widget';
|
import { WidgetHead } from '@/components/overview/overview-widget';
|
||||||
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||||
import { Chart } from '@/components/report/chart';
|
import { ChartSwitch } from '@/components/report/chart';
|
||||||
import { Widget, WidgetBody } from '@/components/Widget';
|
import { Widget, WidgetBody } from '@/components/Widget';
|
||||||
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
|
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
|
||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
@@ -186,7 +186,7 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
|
|||||||
setMetric(index);
|
setMetric(index);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Chart hideID {...report} />
|
<ChartSwitch hideID {...report} />
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'transition-opacity top-0 left-0 right-0 bottom-0 absolute rounded-md w-full h-full border ring-1 border-chart-0 ring-chart-0',
|
'transition-opacity top-0 left-0 right-0 bottom-0 absolute rounded-md w-full h-full border ring-1 border-chart-0 ring-chart-0',
|
||||||
@@ -201,7 +201,7 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
|
|||||||
<div className="title">{selectedMetric.events[0]?.displayName}</div>
|
<div className="title">{selectedMetric.events[0]?.displayName}</div>
|
||||||
</WidgetHead>
|
</WidgetHead>
|
||||||
<WidgetBody>
|
<WidgetBody>
|
||||||
<Chart
|
<ChartSwitch
|
||||||
key={selectedMetric.id}
|
key={selectedMetric.id}
|
||||||
hideID
|
hideID
|
||||||
{...selectedMetric}
|
{...selectedMetric}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { ListProperties } from '@/components/events/ListProperties';
|
|||||||
import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons';
|
import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons';
|
||||||
import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer';
|
import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer';
|
||||||
import { ProfileAvatar } from '@/components/profiles/ProfileAvatar';
|
import { ProfileAvatar } from '@/components/profiles/ProfileAvatar';
|
||||||
import { Chart } from '@/components/report/chart';
|
import { ChartSwitch } from '@/components/report/chart';
|
||||||
import { GradientBackground } from '@/components/ui/gradient-background';
|
import { GradientBackground } from '@/components/ui/gradient-background';
|
||||||
import { KeyValue } from '@/components/ui/key-value';
|
import { KeyValue } from '@/components/ui/key-value';
|
||||||
import { Widget, WidgetBody, WidgetHead } from '@/components/Widget';
|
import { Widget, WidgetBody, WidgetHead } from '@/components/Widget';
|
||||||
@@ -162,7 +162,7 @@ export default async function Page({
|
|||||||
<span className="title">Events per day</span>
|
<span className="title">Events per day</span>
|
||||||
</WidgetHead>
|
</WidgetHead>
|
||||||
<WidgetBody className="flex gap-2">
|
<WidgetBody className="flex gap-2">
|
||||||
<Chart {...profileChart} />
|
<ChartSwitch {...profileChart} />
|
||||||
</WidgetBody>
|
</WidgetBody>
|
||||||
</Widget>
|
</Widget>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { StickyBelowHeader } from '@/app/(app)/[organizationId]/[projectId]/layout-sticky-below-header';
|
import { StickyBelowHeader } from '@/app/(app)/[organizationId]/[projectId]/layout-sticky-below-header';
|
||||||
import { Chart } from '@/components/report/chart';
|
import { ChartSwitch } from '@/components/report/chart';
|
||||||
import { ReportChartType } from '@/components/report/ReportChartType';
|
import { ReportChartType } from '@/components/report/ReportChartType';
|
||||||
import { ReportInterval } from '@/components/report/ReportInterval';
|
import { ReportInterval } from '@/components/report/ReportInterval';
|
||||||
import { ReportLineType } from '@/components/report/ReportLineType';
|
import { ReportLineType } from '@/components/report/ReportLineType';
|
||||||
@@ -74,7 +74,9 @@ export default function ReportEditor({
|
|||||||
</div>
|
</div>
|
||||||
</StickyBelowHeader>
|
</StickyBelowHeader>
|
||||||
<div className="flex flex-col gap-4 p-4">
|
<div className="flex flex-col gap-4 p-4">
|
||||||
{report.ready && <Chart {...report} projectId={projectId} editMode />}
|
{report.ready && (
|
||||||
|
<ChartSwitch {...report} projectId={projectId} editMode />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<SheetContent className="!max-w-lg w-full" side="left">
|
<SheetContent className="!max-w-lg w-full" side="left">
|
||||||
<ReportSidebar />
|
<ReportSidebar />
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Funnel } from '@/components/report/funnel';
|
||||||
|
|
||||||
|
import PageLayout from '../page-layout';
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: 'Funnel - Openpanel.dev',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface PageProps {
|
||||||
|
params: {
|
||||||
|
organizationId: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Page({ params: { organizationId } }: PageProps) {
|
||||||
|
return (
|
||||||
|
<PageLayout title="Funnel" organizationSlug={organizationId}>
|
||||||
|
<Funnel />
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import AnimateHeight from 'react-animate-height';
|
|||||||
|
|
||||||
import type { IChartInput } from '@mixan/validation';
|
import type { IChartInput } from '@mixan/validation';
|
||||||
|
|
||||||
import { Chart } from '../report/chart';
|
import { ChartSwitch } from '../report/chart';
|
||||||
import { Widget, WidgetBody, WidgetHead } from '../Widget';
|
import { Widget, WidgetBody, WidgetHead } from '../Widget';
|
||||||
import { useOverviewOptions } from './useOverviewOptions';
|
import { useOverviewOptions } from './useOverviewOptions';
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ export function OverviewLiveHistogram({
|
|||||||
|
|
||||||
<AnimateHeight duration={500} height={liveHistogram ? 'auto' : 0}>
|
<AnimateHeight duration={500} height={liveHistogram ? 'auto' : 0}>
|
||||||
<WidgetBody>
|
<WidgetBody>
|
||||||
<Chart {...report} />
|
<ChartSwitch {...report} />
|
||||||
</WidgetBody>
|
</WidgetBody>
|
||||||
</AnimateHeight>
|
</AnimateHeight>
|
||||||
</Widget>
|
</Widget>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Chart } from '@/components/report/chart';
|
import { ChartSwitch } from '@/components/report/chart';
|
||||||
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
|
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
|
||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
|
|
||||||
@@ -178,7 +178,7 @@ export default function OverviewTopDevices({
|
|||||||
</WidgetButtons>
|
</WidgetButtons>
|
||||||
</WidgetHead>
|
</WidgetHead>
|
||||||
<WidgetBody>
|
<WidgetBody>
|
||||||
<Chart
|
<ChartSwitch
|
||||||
hideID
|
hideID
|
||||||
{...widget.chart}
|
{...widget.chart}
|
||||||
previous={false}
|
previous={false}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Chart } from '@/components/report/chart';
|
import { ChartSwitch } from '@/components/report/chart';
|
||||||
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
|
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
|
||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ export default function OverviewTopEvents({
|
|||||||
</WidgetButtons>
|
</WidgetButtons>
|
||||||
</WidgetHead>
|
</WidgetHead>
|
||||||
<WidgetBody>
|
<WidgetBody>
|
||||||
<Chart hideID {...widget.chart} previous={false} />
|
<ChartSwitch hideID {...widget.chart} previous={false} />
|
||||||
</WidgetBody>
|
</WidgetBody>
|
||||||
</Widget>
|
</Widget>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Chart } from '@/components/report/chart';
|
import { ChartSwitch } from '@/components/report/chart';
|
||||||
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
|
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
|
||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
|
|
||||||
@@ -148,7 +148,7 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
|
|||||||
</WidgetButtons>
|
</WidgetButtons>
|
||||||
</WidgetHead>
|
</WidgetHead>
|
||||||
<WidgetBody>
|
<WidgetBody>
|
||||||
<Chart
|
<ChartSwitch
|
||||||
hideID
|
hideID
|
||||||
{...widget.chart}
|
{...widget.chart}
|
||||||
previous={false}
|
previous={false}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Chart } from '@/components/report/chart';
|
import { ChartSwitch } from '@/components/report/chart';
|
||||||
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
|
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
|
||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
|
|
||||||
@@ -120,7 +120,7 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
|
|||||||
</WidgetButtons>
|
</WidgetButtons>
|
||||||
</WidgetHead>
|
</WidgetHead>
|
||||||
<WidgetBody>
|
<WidgetBody>
|
||||||
<Chart
|
<ChartSwitch
|
||||||
hideID
|
hideID
|
||||||
{...widget.chart}
|
{...widget.chart}
|
||||||
previous={false}
|
previous={false}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Chart } from '@/components/report/chart';
|
import { ChartSwitch } from '@/components/report/chart';
|
||||||
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
|
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
|
||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
|
|
||||||
@@ -262,7 +262,7 @@ export default function OverviewTopSources({
|
|||||||
</WidgetButtons>
|
</WidgetButtons>
|
||||||
</WidgetHead>
|
</WidgetHead>
|
||||||
<WidgetBody>
|
<WidgetBody>
|
||||||
<Chart
|
<ChartSwitch
|
||||||
hideID
|
hideID
|
||||||
{...widget.chart}
|
{...widget.chart}
|
||||||
previous={false}
|
previous={false}
|
||||||
|
|||||||
92
apps/web/src/components/report/chart/Chart.tsx
Normal file
92
apps/web/src/components/report/chart/Chart.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { api } from '@/app/_trpc/client';
|
||||||
|
|
||||||
|
import type { IChartInput } from '@mixan/validation';
|
||||||
|
|
||||||
|
import { ChartEmpty } from './ChartEmpty';
|
||||||
|
import { ReportAreaChart } from './ReportAreaChart';
|
||||||
|
import { ReportBarChart } from './ReportBarChart';
|
||||||
|
import { ReportHistogramChart } from './ReportHistogramChart';
|
||||||
|
import { ReportLineChart } from './ReportLineChart';
|
||||||
|
import { ReportMapChart } from './ReportMapChart';
|
||||||
|
import { ReportMetricChart } from './ReportMetricChart';
|
||||||
|
import { ReportPieChart } from './ReportPieChart';
|
||||||
|
|
||||||
|
export type ReportChartProps = IChartInput;
|
||||||
|
|
||||||
|
export function Chart({
|
||||||
|
interval,
|
||||||
|
events,
|
||||||
|
breakdowns,
|
||||||
|
chartType,
|
||||||
|
name,
|
||||||
|
range,
|
||||||
|
lineType,
|
||||||
|
previous,
|
||||||
|
formula,
|
||||||
|
unit,
|
||||||
|
metric,
|
||||||
|
projectId,
|
||||||
|
}: ReportChartProps) {
|
||||||
|
const [data] = api.chart.chart.useSuspenseQuery(
|
||||||
|
{
|
||||||
|
// dont send lineType since it does not need to be sent
|
||||||
|
lineType: 'monotone',
|
||||||
|
interval,
|
||||||
|
chartType,
|
||||||
|
events,
|
||||||
|
breakdowns,
|
||||||
|
name,
|
||||||
|
range,
|
||||||
|
startDate: null,
|
||||||
|
endDate: null,
|
||||||
|
projectId,
|
||||||
|
previous,
|
||||||
|
formula,
|
||||||
|
unit,
|
||||||
|
metric,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keepPreviousData: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (data.series.length === 0) {
|
||||||
|
return <ChartEmpty />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chartType === 'map') {
|
||||||
|
return <ReportMapChart data={data} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chartType === 'histogram') {
|
||||||
|
return <ReportHistogramChart interval={interval} data={data} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chartType === 'bar') {
|
||||||
|
return <ReportBarChart data={data} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chartType === 'metric') {
|
||||||
|
return <ReportMetricChart data={data} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chartType === 'pie') {
|
||||||
|
return <ReportPieChart data={data} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chartType === 'linear') {
|
||||||
|
return (
|
||||||
|
<ReportLineChart lineType={lineType} interval={interval} data={data} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chartType === 'area') {
|
||||||
|
return (
|
||||||
|
<ReportAreaChart lineType={lineType} interval={interval} data={data} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <p>Unknown chart type</p>;
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { Suspense, useEffect, useRef } from 'react';
|
import React, { useEffect, useRef } from 'react';
|
||||||
import { useInViewport } from 'react-in-viewport';
|
import { useInViewport } from 'react-in-viewport';
|
||||||
|
|
||||||
import type { ReportChartProps } from '.';
|
import type { ReportChartProps } from '.';
|
||||||
import { Chart } from '.';
|
import { ChartSwitch } from '.';
|
||||||
import { ChartLoading } from './ChartLoading';
|
import { ChartLoading } from './ChartLoading';
|
||||||
import type { ChartContextType } from './ChartProvider';
|
import type { ChartContextType } from './ChartProvider';
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ export function LazyChart(props: ReportChartProps & ChartContextType) {
|
|||||||
return (
|
return (
|
||||||
<div ref={ref}>
|
<div ref={ref}>
|
||||||
{once.current || inViewport ? (
|
{once.current || inViewport ? (
|
||||||
<Chart {...props} editMode={false} />
|
<ChartSwitch {...props} editMode={false} />
|
||||||
) : (
|
) : (
|
||||||
<ChartLoading />
|
<ChartLoading />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export function MetricCard({
|
|||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div className="flex items-center gap-2 font-medium">
|
<div className="flex items-center gap-2 font-medium">
|
||||||
<ColorSquare>{serie.event.id}</ColorSquare>
|
<ColorSquare>{serie.event.id}</ColorSquare>
|
||||||
{serie.name ?? serie.event.displayName ?? serie.event.name}
|
{serie.name || serie.event.displayName || serie.event.name}
|
||||||
</div>
|
</div>
|
||||||
<PreviousDiffIndicator {...serie.metrics.previous[metric]} />
|
<PreviousDiffIndicator {...serie.metrics.previous[metric]} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,98 +1,19 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { memo, useEffect, useState } from 'react';
|
|
||||||
import type { RouterOutputs } from '@/app/_trpc/client';
|
|
||||||
import { api } from '@/app/_trpc/client';
|
|
||||||
|
|
||||||
import type { IChartInput } from '@mixan/validation';
|
import type { IChartInput } from '@mixan/validation';
|
||||||
|
|
||||||
import { ChartEmpty } from './ChartEmpty';
|
import { Funnel } from '../funnel';
|
||||||
import { ChartLoading } from './ChartLoading';
|
import { Chart } from './Chart';
|
||||||
import { withChartProivder } from './ChartProvider';
|
import { withChartProivder } from './ChartProvider';
|
||||||
import { ReportAreaChart } from './ReportAreaChart';
|
|
||||||
import { ReportBarChart } from './ReportBarChart';
|
|
||||||
import { ReportHistogramChart } from './ReportHistogramChart';
|
|
||||||
import { ReportLineChart } from './ReportLineChart';
|
|
||||||
import { ReportMapChart } from './ReportMapChart';
|
|
||||||
import { ReportMetricChart } from './ReportMetricChart';
|
|
||||||
import { ReportPieChart } from './ReportPieChart';
|
|
||||||
|
|
||||||
export type ReportChartProps = IChartInput & {
|
export type ReportChartProps = IChartInput;
|
||||||
initialData?: RouterOutputs['chart']['chart'];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Chart = withChartProivder(function Chart({
|
export const ChartSwitch = withChartProivder(function ChartSwitch(
|
||||||
interval,
|
props: ReportChartProps
|
||||||
events,
|
) {
|
||||||
breakdowns,
|
if (props.chartType === 'funnel') {
|
||||||
chartType,
|
return <Funnel {...props} />;
|
||||||
name,
|
|
||||||
range,
|
|
||||||
lineType,
|
|
||||||
previous,
|
|
||||||
formula,
|
|
||||||
unit,
|
|
||||||
metric,
|
|
||||||
projectId,
|
|
||||||
}: ReportChartProps) {
|
|
||||||
const [data] = api.chart.chart.useSuspenseQuery(
|
|
||||||
{
|
|
||||||
// dont send lineType since it does not need to be sent
|
|
||||||
lineType: 'monotone',
|
|
||||||
interval,
|
|
||||||
chartType,
|
|
||||||
events,
|
|
||||||
breakdowns,
|
|
||||||
name,
|
|
||||||
range,
|
|
||||||
startDate: null,
|
|
||||||
endDate: null,
|
|
||||||
projectId,
|
|
||||||
previous,
|
|
||||||
formula,
|
|
||||||
unit,
|
|
||||||
metric,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
keepPreviousData: true,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (data.series.length === 0) {
|
|
||||||
return <ChartEmpty />;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (chartType === 'map') {
|
return <Chart {...props} />;
|
||||||
return <ReportMapChart data={data} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chartType === 'histogram') {
|
|
||||||
return <ReportHistogramChart interval={interval} data={data} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chartType === 'bar') {
|
|
||||||
return <ReportBarChart data={data} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chartType === 'metric') {
|
|
||||||
return <ReportMetricChart data={data} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chartType === 'pie') {
|
|
||||||
return <ReportPieChart data={data} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chartType === 'linear') {
|
|
||||||
return (
|
|
||||||
<ReportLineChart lineType={lineType} interval={interval} data={data} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chartType === 'area') {
|
|
||||||
return (
|
|
||||||
<ReportAreaChart lineType={lineType} interval={interval} data={data} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <p>Unknown chart type</p>;
|
|
||||||
});
|
});
|
||||||
|
|||||||
169
apps/web/src/components/report/funnel/Funnel.tsx
Normal file
169
apps/web/src/components/report/funnel/Funnel.tsx
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { RouterOutputs } from '@/app/_trpc/client';
|
||||||
|
import {
|
||||||
|
Carousel,
|
||||||
|
CarouselContent,
|
||||||
|
CarouselItem,
|
||||||
|
CarouselNext,
|
||||||
|
CarouselPrevious,
|
||||||
|
} from '@/components/ui/carousel';
|
||||||
|
import { cn } from '@/utils/cn';
|
||||||
|
import { round } from '@/utils/math';
|
||||||
|
import { ArrowRight, ArrowRightIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
function FunnelChart({ from, to }: { from: number; to: number }) {
|
||||||
|
const fromY = 100 - from;
|
||||||
|
const toY = 100 - to;
|
||||||
|
const steps = [
|
||||||
|
`M0,${fromY}`,
|
||||||
|
'L0,100',
|
||||||
|
'L100,100',
|
||||||
|
`L100,${toY}`,
|
||||||
|
`L0,${fromY}`,
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 100 100">
|
||||||
|
<defs>
|
||||||
|
<linearGradient
|
||||||
|
id="blue"
|
||||||
|
x1="50"
|
||||||
|
y1="100"
|
||||||
|
x2="50"
|
||||||
|
y2="0"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
>
|
||||||
|
{/* bottom */}
|
||||||
|
<stop offset="0%" stop-color="#2564eb" />
|
||||||
|
{/* top */}
|
||||||
|
<stop offset="100%" stop-color="#2564eb" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient
|
||||||
|
id="red"
|
||||||
|
x1="50"
|
||||||
|
y1="100"
|
||||||
|
x2="50"
|
||||||
|
y2="0"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
>
|
||||||
|
{/* bottom */}
|
||||||
|
<stop offset="0%" stop-color="#f87171" />
|
||||||
|
{/* top */}
|
||||||
|
<stop offset="100%" stop-color="#fca5a5" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect
|
||||||
|
x="0"
|
||||||
|
y={fromY}
|
||||||
|
width="100"
|
||||||
|
height="100"
|
||||||
|
fill="url(#red)"
|
||||||
|
fillOpacity={0.2}
|
||||||
|
/>
|
||||||
|
<path d={steps.join(' ')} fill="url(#blue)" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDropoffColor(value: number) {
|
||||||
|
if (value > 80) {
|
||||||
|
return 'text-red-600';
|
||||||
|
}
|
||||||
|
if (value > 50) {
|
||||||
|
return 'text-orange-600';
|
||||||
|
}
|
||||||
|
if (value > 30) {
|
||||||
|
return 'text-yellow-600';
|
||||||
|
}
|
||||||
|
return 'text-green-600';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FunnelSteps({
|
||||||
|
steps,
|
||||||
|
totalSessions,
|
||||||
|
}: RouterOutputs['chart']['funnel']) {
|
||||||
|
return (
|
||||||
|
<Carousel className="w-full py-4" opts={{ loop: false, dragFree: true }}>
|
||||||
|
<CarouselContent>
|
||||||
|
<CarouselItem className={'flex-[0_0_0px] pl-3'} />
|
||||||
|
{steps.map((step, index, list) => {
|
||||||
|
const finalStep = index === list.length - 1;
|
||||||
|
return (
|
||||||
|
<CarouselItem
|
||||||
|
className={'flex-[0_0_320px] max-w-full p-0 px-1'}
|
||||||
|
key={step.event.id}
|
||||||
|
>
|
||||||
|
<div className="border border-border divide-y divide-border bg-white">
|
||||||
|
<div className="p-4">
|
||||||
|
<p className="text-muted-foreground">Step {index + 1}</p>
|
||||||
|
<h3 className="font-bold">
|
||||||
|
{step.event.displayName || step.event.name}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="aspect-square relative">
|
||||||
|
<FunnelChart from={step.prevPercent} to={step.percent} />
|
||||||
|
<div className="absolute top-0 left-0 right-0 p-4 flex flex-col bg-white/40">
|
||||||
|
<div className="uppercase font-medium text-muted-foreground">
|
||||||
|
Sessions
|
||||||
|
</div>
|
||||||
|
<div className="uppercase text-3xl font-bold flex items-center">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{step.before}
|
||||||
|
</span>
|
||||||
|
<ArrowRightIcon size={16} className="mx-2" />
|
||||||
|
<span>{step.current}</span>
|
||||||
|
</div>
|
||||||
|
{index !== 0 && (
|
||||||
|
<>
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
{step.current} of {totalSessions} (
|
||||||
|
{round(step.percent, 1)}%)
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{finalStep ? (
|
||||||
|
<div className={cn('p-4 flex flex-col items-center')}>
|
||||||
|
<div className="uppercase text-xs font-medium">
|
||||||
|
Conversion
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'uppercase text-3xl font-bold',
|
||||||
|
getDropoffColor(step.dropoff.percent)
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{round(step.percent, 1)}%
|
||||||
|
</div>
|
||||||
|
<div className="uppercase text-sm mt-0 font-medium text-muted-foreground">
|
||||||
|
Converted {step.current} of {totalSessions} sessions
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={cn('p-4 flex flex-col items-center')}>
|
||||||
|
<div className="uppercase text-xs font-medium">Dropoff</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'uppercase text-3xl font-bold',
|
||||||
|
getDropoffColor(step.dropoff.percent)
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{round(step.dropoff.percent, 1)}%
|
||||||
|
</div>
|
||||||
|
<div className="uppercase text-sm mt-0 font-medium text-muted-foreground">
|
||||||
|
Lost {step.dropoff.count} sessions
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CarouselItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<CarouselItem className={'flex-[0_0_0px] pl-3'} />
|
||||||
|
</CarouselContent>
|
||||||
|
<CarouselPrevious />
|
||||||
|
<CarouselNext />
|
||||||
|
</Carousel>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
apps/web/src/components/report/funnel/index.tsx
Normal file
53
apps/web/src/components/report/funnel/index.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { RouterOutputs } from '@/app/_trpc/client';
|
||||||
|
import { api } from '@/app/_trpc/client';
|
||||||
|
|
||||||
|
import type { IChartInput } from '@mixan/validation';
|
||||||
|
|
||||||
|
import { ChartEmpty } from '../chart/ChartEmpty';
|
||||||
|
import { withChartProivder } from '../chart/ChartProvider';
|
||||||
|
import { FunnelSteps } from './Funnel';
|
||||||
|
|
||||||
|
export type ReportChartProps = IChartInput & {
|
||||||
|
initialData?: RouterOutputs['chart']['funnel'];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Funnel = withChartProivder(function Chart({
|
||||||
|
events,
|
||||||
|
name,
|
||||||
|
range,
|
||||||
|
projectId,
|
||||||
|
}: ReportChartProps) {
|
||||||
|
const [data] = api.chart.funnel.useSuspenseQuery(
|
||||||
|
{
|
||||||
|
events,
|
||||||
|
name,
|
||||||
|
range,
|
||||||
|
projectId,
|
||||||
|
lineType: 'monotone',
|
||||||
|
interval: 'day',
|
||||||
|
chartType: 'funnel',
|
||||||
|
breakdowns: [],
|
||||||
|
startDate: null,
|
||||||
|
endDate: null,
|
||||||
|
previous: false,
|
||||||
|
formula: undefined,
|
||||||
|
unit: undefined,
|
||||||
|
metric: 'sum',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keepPreviousData: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (data.steps.length === 0) {
|
||||||
|
return <ChartEmpty />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="-mx-4">
|
||||||
|
<FunnelSteps {...data} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,16 +1,20 @@
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { SheetClose } from '@/components/ui/sheet';
|
import { SheetClose } from '@/components/ui/sheet';
|
||||||
|
import { useSelector } from '@/redux';
|
||||||
|
|
||||||
import { ReportBreakdowns } from './ReportBreakdowns';
|
import { ReportBreakdowns } from './ReportBreakdowns';
|
||||||
import { ReportEvents } from './ReportEvents';
|
import { ReportEvents } from './ReportEvents';
|
||||||
import { ReportForumula } from './ReportForumula';
|
import { ReportForumula } from './ReportForumula';
|
||||||
|
|
||||||
export function ReportSidebar() {
|
export function ReportSidebar() {
|
||||||
|
const { chartType } = useSelector((state) => state.report);
|
||||||
|
const showForumula = chartType !== 'funnel';
|
||||||
|
const showBreakdown = chartType !== 'funnel';
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-8 pb-12">
|
<div className="flex flex-col gap-8 pb-12">
|
||||||
<ReportEvents />
|
<ReportEvents />
|
||||||
<ReportForumula />
|
{showForumula && <ReportForumula />}
|
||||||
<ReportBreakdowns />
|
{showBreakdown && <ReportBreakdowns />}
|
||||||
<div className="absolute bottom-0 left-0 right-0 p-6 bg-gradient-to-t from-white/100 to-white/0">
|
<div className="absolute bottom-0 left-0 right-0 p-6 bg-gradient-to-t from-white/100 to-white/0">
|
||||||
<SheetClose asChild>
|
<SheetClose asChild>
|
||||||
<Button className="w-full">Done</Button>
|
<Button className="w-full">Done</Button>
|
||||||
|
|||||||
258
apps/web/src/components/ui/carousel.tsx
Normal file
258
apps/web/src/components/ui/carousel.tsx
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { cn } from '@/utils/cn';
|
||||||
|
import useEmblaCarousel from 'embla-carousel-react';
|
||||||
|
import type { UseEmblaCarouselType } from 'embla-carousel-react';
|
||||||
|
import { ArrowLeft, ArrowRight } from 'lucide-react';
|
||||||
|
|
||||||
|
type CarouselApi = UseEmblaCarouselType[1];
|
||||||
|
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
|
||||||
|
type CarouselOptions = UseCarouselParameters[0];
|
||||||
|
type CarouselPlugin = UseCarouselParameters[1];
|
||||||
|
|
||||||
|
interface CarouselProps {
|
||||||
|
opts?: CarouselOptions;
|
||||||
|
plugins?: CarouselPlugin;
|
||||||
|
orientation?: 'horizontal' | 'vertical';
|
||||||
|
setApi?: (api: CarouselApi) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type CarouselContextProps = {
|
||||||
|
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
|
||||||
|
api: ReturnType<typeof useEmblaCarousel>[1];
|
||||||
|
scrollPrev: () => void;
|
||||||
|
scrollNext: () => void;
|
||||||
|
canScrollPrev: boolean;
|
||||||
|
canScrollNext: boolean;
|
||||||
|
} & CarouselProps;
|
||||||
|
|
||||||
|
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
|
||||||
|
|
||||||
|
function useCarousel() {
|
||||||
|
const context = React.useContext(CarouselContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useCarousel must be used within a <Carousel />');
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Carousel = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement> & CarouselProps
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
orientation = 'horizontal',
|
||||||
|
opts,
|
||||||
|
setApi,
|
||||||
|
plugins,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const [carouselRef, api] = useEmblaCarousel(
|
||||||
|
{
|
||||||
|
...opts,
|
||||||
|
axis: orientation === 'horizontal' ? 'x' : 'y',
|
||||||
|
},
|
||||||
|
plugins
|
||||||
|
);
|
||||||
|
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
|
||||||
|
const [canScrollNext, setCanScrollNext] = React.useState(false);
|
||||||
|
|
||||||
|
const onSelect = React.useCallback((api: CarouselApi) => {
|
||||||
|
if (!api) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCanScrollPrev(api.canScrollPrev());
|
||||||
|
setCanScrollNext(api.canScrollNext());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const scrollPrev = React.useCallback(() => {
|
||||||
|
api?.scrollPrev();
|
||||||
|
}, [api]);
|
||||||
|
|
||||||
|
const scrollNext = React.useCallback(() => {
|
||||||
|
api?.scrollNext();
|
||||||
|
}, [api]);
|
||||||
|
|
||||||
|
const handleKeyDown = React.useCallback(
|
||||||
|
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
if (event.key === 'ArrowLeft') {
|
||||||
|
event.preventDefault();
|
||||||
|
scrollPrev();
|
||||||
|
} else if (event.key === 'ArrowRight') {
|
||||||
|
event.preventDefault();
|
||||||
|
scrollNext();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[scrollPrev, scrollNext]
|
||||||
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!api || !setApi) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setApi(api);
|
||||||
|
}, [api, setApi]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!api) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSelect(api);
|
||||||
|
api.on('reInit', onSelect);
|
||||||
|
api.on('select', onSelect);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
api?.off('select', onSelect);
|
||||||
|
};
|
||||||
|
}, [api, onSelect]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CarouselContext.Provider
|
||||||
|
value={{
|
||||||
|
carouselRef,
|
||||||
|
api: api,
|
||||||
|
opts,
|
||||||
|
orientation:
|
||||||
|
orientation || (opts?.axis === 'y' ? 'vertical' : 'horizontal'),
|
||||||
|
scrollPrev,
|
||||||
|
scrollNext,
|
||||||
|
canScrollPrev,
|
||||||
|
canScrollNext,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
onKeyDownCapture={handleKeyDown}
|
||||||
|
className={cn('relative', className)}
|
||||||
|
role="region"
|
||||||
|
aria-roledescription="carousel"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</CarouselContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Carousel.displayName = 'Carousel';
|
||||||
|
|
||||||
|
const CarouselContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const { carouselRef, orientation } = useCarousel();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={carouselRef} className="overflow-hidden">
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex',
|
||||||
|
orientation === 'horizontal' ? '' : 'flex-col',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
CarouselContent.displayName = 'CarouselContent';
|
||||||
|
|
||||||
|
const CarouselItem = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const { orientation } = useCarousel();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
role="group"
|
||||||
|
aria-roledescription="slide"
|
||||||
|
className={cn(
|
||||||
|
'min-w-0 shrink-0 grow-0 basis-full',
|
||||||
|
orientation === 'horizontal' ? 'pl-4' : 'pt-4',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
CarouselItem.displayName = 'CarouselItem';
|
||||||
|
|
||||||
|
const CarouselPrevious = React.forwardRef<
|
||||||
|
HTMLButtonElement,
|
||||||
|
React.ComponentProps<typeof Button>
|
||||||
|
>(({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
|
||||||
|
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
ref={ref}
|
||||||
|
variant={variant}
|
||||||
|
size={size}
|
||||||
|
className={cn(
|
||||||
|
'absolute h-10 w-10 rounded-full',
|
||||||
|
orientation === 'horizontal'
|
||||||
|
? 'left-6 top-1/2 -translate-y-1/2'
|
||||||
|
: '-top-12 left-1/2 -translate-x-1/2 rotate-90',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
disabled={!canScrollPrev}
|
||||||
|
onClick={scrollPrev}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Previous slide</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
CarouselPrevious.displayName = 'CarouselPrevious';
|
||||||
|
|
||||||
|
const CarouselNext = React.forwardRef<
|
||||||
|
HTMLButtonElement,
|
||||||
|
React.ComponentProps<typeof Button>
|
||||||
|
>(({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
|
||||||
|
const { orientation, scrollNext, canScrollNext } = useCarousel();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
ref={ref}
|
||||||
|
variant={variant}
|
||||||
|
size={size}
|
||||||
|
className={cn(
|
||||||
|
'absolute h-10 w-10 rounded-full',
|
||||||
|
orientation === 'horizontal'
|
||||||
|
? 'right-6 top-1/2 -translate-y-1/2'
|
||||||
|
: '-bottom-12 left-1/2 -translate-x-1/2 rotate-90',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
disabled={!canScrollNext}
|
||||||
|
onClick={scrollNext}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Next slide</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
CarouselNext.displayName = 'CarouselNext';
|
||||||
|
|
||||||
|
export {
|
||||||
|
type CarouselApi,
|
||||||
|
Carousel,
|
||||||
|
CarouselContent,
|
||||||
|
CarouselItem,
|
||||||
|
CarouselPrevious,
|
||||||
|
CarouselNext,
|
||||||
|
};
|
||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
} from '@/server/api/trpc';
|
} from '@/server/api/trpc';
|
||||||
import { getDaysOldDate } from '@/utils/date';
|
import { getDaysOldDate } from '@/utils/date';
|
||||||
import { average, max, min, round, sum } from '@/utils/math';
|
import { average, max, min, round, sum } from '@/utils/math';
|
||||||
import { flatten, map, pipe, prop, sort, uniq } from 'ramda';
|
import { flatten, map, pipe, prop, repeat, reverse, sort, uniq } from 'ramda';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { chQuery, createSqlBuilder } from '@mixan/db';
|
import { chQuery, createSqlBuilder } from '@mixan/db';
|
||||||
@@ -14,6 +14,116 @@ import type { IChartEvent, IChartInput, IChartRange } from '@mixan/validation';
|
|||||||
|
|
||||||
import { getChartData, withFormula } from './chart.helpers';
|
import { getChartData, withFormula } from './chart.helpers';
|
||||||
|
|
||||||
|
async function getFunnelData(payload: IChartInput) {
|
||||||
|
if (payload.events.length === 0) {
|
||||||
|
return {
|
||||||
|
totalSessions: 0,
|
||||||
|
steps: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const sql = `SELECT
|
||||||
|
level,
|
||||||
|
count() AS count
|
||||||
|
FROM
|
||||||
|
(
|
||||||
|
SELECT
|
||||||
|
session_id,
|
||||||
|
windowFunnel(6048000000000000,'strict_increase')(toUnixTimestamp(created_at), ${payload.events.map((event) => `name = '${event.name}'`).join(', ')}) AS level
|
||||||
|
FROM events
|
||||||
|
WHERE (created_at >= '2024-02-24') AND (created_at <= '2024-02-25')
|
||||||
|
GROUP BY session_id
|
||||||
|
)
|
||||||
|
GROUP BY level
|
||||||
|
ORDER BY level DESC;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const [funnelRes, sessionRes] = await Promise.all([
|
||||||
|
chQuery<{ level: number; count: number }>(sql),
|
||||||
|
chQuery<{ count: number }>(
|
||||||
|
`SELECT count(name) as count FROM events WHERE name = 'session_start' AND (created_at >= '2024-02-24') AND (created_at <= '2024-02-25')`
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
console.log('Funnel SQL: ', sql);
|
||||||
|
|
||||||
|
if (funnelRes[0]?.level !== payload.events.length) {
|
||||||
|
funnelRes.unshift({
|
||||||
|
level: payload.events.length,
|
||||||
|
count: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalSessions = sessionRes[0]?.count ?? 0;
|
||||||
|
const filledFunnelRes = funnelRes.reduce(
|
||||||
|
(acc, item, index) => {
|
||||||
|
const diff =
|
||||||
|
index !== 0 ? (acc[acc.length - 1]?.level ?? 0) - item.level : 1;
|
||||||
|
|
||||||
|
if (diff > 1) {
|
||||||
|
acc.push(
|
||||||
|
...reverse(
|
||||||
|
repeat({}, diff - 1).map((_, index) => ({
|
||||||
|
count: acc[acc.length - 1]?.count ?? 0,
|
||||||
|
level: item.level + index + 1,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
...acc,
|
||||||
|
{
|
||||||
|
count: item.count + (acc[acc.length - 1]?.count ?? 0),
|
||||||
|
level: item.level,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
[] as typeof funnelRes
|
||||||
|
);
|
||||||
|
|
||||||
|
const steps = reverse(filledFunnelRes)
|
||||||
|
.filter((item) => item.level !== 0)
|
||||||
|
.reduce(
|
||||||
|
(acc, item, index, list) => {
|
||||||
|
const prev = list[index - 1] ?? { count: totalSessions };
|
||||||
|
return [
|
||||||
|
...acc,
|
||||||
|
{
|
||||||
|
event: payload.events[item.level - 1]!,
|
||||||
|
before: prev.count,
|
||||||
|
current: item.count,
|
||||||
|
dropoff: {
|
||||||
|
bajs: {
|
||||||
|
prev,
|
||||||
|
item,
|
||||||
|
},
|
||||||
|
count: prev.count - item.count,
|
||||||
|
percent: 100 - (item.count / prev.count) * 100,
|
||||||
|
},
|
||||||
|
percent: (item.count / totalSessions) * 100,
|
||||||
|
prevPercent: (prev.count / totalSessions) * 100,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
[] as {
|
||||||
|
event: IChartEvent;
|
||||||
|
before: number;
|
||||||
|
current: number;
|
||||||
|
dropoff: {
|
||||||
|
count: number;
|
||||||
|
percent: number;
|
||||||
|
};
|
||||||
|
percent: number;
|
||||||
|
prevPercent: number;
|
||||||
|
}[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalSessions,
|
||||||
|
steps,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
type PreviousValue = {
|
type PreviousValue = {
|
||||||
value: number;
|
value: number;
|
||||||
diff: number | null;
|
diff: number | null;
|
||||||
@@ -144,6 +254,10 @@ export const chartRouter = createTRPCRouter({
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
funnel: publicProcedure.input(zChartInput).query(async ({ input }) => {
|
||||||
|
return getFunnelData(input);
|
||||||
|
}),
|
||||||
|
|
||||||
// TODO: Make this private
|
// TODO: Make this private
|
||||||
chart: publicProcedure.input(zChartInput).query(async ({ input }) => {
|
chart: publicProcedure.input(zChartInput).query(async ({ input }) => {
|
||||||
const current = getDatesFromRange(input.range);
|
const current = getDatesFromRange(input.range);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "dotenv -e ../../.env -c -v WATCH=1 tsup",
|
"dev": "dotenv -e ../../.env -c -v WATCH=1 tsup",
|
||||||
|
"testing": "WORKER_PORT=9999 pnpm dev",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"build": "rm -rf dist && tsup",
|
"build": "rm -rf dist && tsup",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export const chartTypes = {
|
|||||||
metric: 'Metric',
|
metric: 'Metric',
|
||||||
area: 'Area',
|
area: 'Area',
|
||||||
map: 'Map',
|
map: 'Map',
|
||||||
|
funnel: 'Funnel',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const lineTypes = {
|
export const lineTypes = {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ CREATE TABLE openpanel.events (
|
|||||||
`device_id` String,
|
`device_id` String,
|
||||||
`profile_id` String,
|
`profile_id` String,
|
||||||
`project_id` String,
|
`project_id` String,
|
||||||
|
`session_id` String,
|
||||||
`path` String,
|
`path` String,
|
||||||
`referrer` String,
|
`referrer` String,
|
||||||
`referrer_name` String,
|
`referrer_name` String,
|
||||||
@@ -56,9 +57,9 @@ ORDER BY
|
|||||||
ALTER TABLE
|
ALTER TABLE
|
||||||
events
|
events
|
||||||
ADD
|
ADD
|
||||||
COLUMN device_id String
|
COLUMN session_id String
|
||||||
AFTER
|
AFTER
|
||||||
name;
|
project_id;
|
||||||
|
|
||||||
ALTER TABLE
|
ALTER TABLE
|
||||||
events DROP COLUMN id;
|
events DROP COLUMN id;
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "ChartType" ADD VALUE 'funnel';
|
||||||
@@ -98,6 +98,7 @@ enum ChartType {
|
|||||||
metric
|
metric
|
||||||
area
|
area
|
||||||
map
|
map
|
||||||
|
funnel
|
||||||
}
|
}
|
||||||
|
|
||||||
model Dashboard {
|
model Dashboard {
|
||||||
|
|||||||
101
packages/db/scripts/test-funnel.ts
Normal file
101
packages/db/scripts/test-funnel.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { createEvent } from '../src/services/event.service';
|
||||||
|
|
||||||
|
function c(name: string, createdAt: Date, session_id: string) {
|
||||||
|
return createEvent({
|
||||||
|
name,
|
||||||
|
deviceId: '',
|
||||||
|
profileId: '',
|
||||||
|
projectId: '',
|
||||||
|
sessionId: session_id,
|
||||||
|
properties: {},
|
||||||
|
createdAt,
|
||||||
|
country: '',
|
||||||
|
city: '',
|
||||||
|
region: '',
|
||||||
|
continent: '',
|
||||||
|
os: '',
|
||||||
|
osVersion: '',
|
||||||
|
browser: '',
|
||||||
|
browserVersion: '',
|
||||||
|
device: '',
|
||||||
|
brand: '',
|
||||||
|
model: '',
|
||||||
|
duration: 0,
|
||||||
|
path: '/',
|
||||||
|
referrer: '',
|
||||||
|
referrerName: '',
|
||||||
|
referrerType: '',
|
||||||
|
profile: undefined,
|
||||||
|
meta: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
// Level 5
|
||||||
|
const s = Math.random().toString(36).substring(7);
|
||||||
|
await c('session_start', new Date('2024-02-24 10:10:00'), s);
|
||||||
|
|
||||||
|
// // Level 2
|
||||||
|
// s = 's2';
|
||||||
|
// await c('session_start', new Date('2024-02-24 10:10:00'), '');
|
||||||
|
// await c('a', new Date('2024-02-24 10:10:00'), s);
|
||||||
|
// await c('b', new Date('2024-02-24 10:10:02'), s);
|
||||||
|
|
||||||
|
// // Level 5
|
||||||
|
// s = 's3';
|
||||||
|
// await c('session_start', new Date('2024-02-24 10:10:00'), '');
|
||||||
|
// await c('a', new Date('2024-02-24 10:10:00'), s);
|
||||||
|
// await c('b', new Date('2024-02-24 10:10:02'), s);
|
||||||
|
// await c('c', new Date('2024-02-24 10:10:03'), s);
|
||||||
|
// await c('d', new Date('2024-02-24 10:10:04'), s);
|
||||||
|
// await c('f', new Date('2024-02-24 10:10:10'), s);
|
||||||
|
|
||||||
|
// // Level 4
|
||||||
|
// s = 's4';
|
||||||
|
// await c('session_start', new Date('2024-02-24 10:10:00'), '');
|
||||||
|
// await c('a', new Date('2024-02-24 10:10:00'), s);
|
||||||
|
// await c('b', new Date('2024-02-24 10:10:02'), s);
|
||||||
|
// await c('c', new Date('2024-02-24 10:10:03'), s);
|
||||||
|
// await c('d', new Date('2024-02-24 10:10:04'), s);
|
||||||
|
|
||||||
|
// // Level 3
|
||||||
|
// s = 's5';
|
||||||
|
// await c('session_start', new Date('2024-02-24 10:10:00'), '');
|
||||||
|
// await c('a', new Date('2024-02-24 10:10:00'), s);
|
||||||
|
// await c('b', new Date('2024-02-24 10:10:02'), s);
|
||||||
|
// await c('c', new Date('2024-02-24 10:10:03'), s);
|
||||||
|
|
||||||
|
// // Level 3
|
||||||
|
// s = 's6';
|
||||||
|
// await c('session_start', new Date('2024-02-24 10:10:00'), '');
|
||||||
|
// await c('a', new Date('2024-02-24 10:10:00'), s);
|
||||||
|
// await c('b', new Date('2024-02-24 10:10:02'), s);
|
||||||
|
// await c('c', new Date('2024-02-24 10:10:03'), s);
|
||||||
|
|
||||||
|
// // Level 2
|
||||||
|
// s = 's7';
|
||||||
|
// await c('session_start', new Date('2024-02-24 10:10:00'), '');
|
||||||
|
// await c('a', new Date('2024-02-24 10:10:00'), s);
|
||||||
|
// await c('b', new Date('2024-02-24 10:10:02'), s);
|
||||||
|
|
||||||
|
// // Level 5
|
||||||
|
// s = 's8';
|
||||||
|
// await c('session_start', new Date('2024-02-24 10:10:00'), '');
|
||||||
|
// await c('a', new Date('2024-02-24 10:10:00'), s);
|
||||||
|
// await c('b', new Date('2024-02-24 10:10:02'), s);
|
||||||
|
// await c('c', new Date('2024-02-24 10:10:03'), s);
|
||||||
|
// await c('d', new Date('2024-02-24 10:10:04'), s);
|
||||||
|
// await c('f', new Date('2024-02-24 10:10:10'), s);
|
||||||
|
|
||||||
|
// // Level 4
|
||||||
|
// s = 's9';
|
||||||
|
// await c('session_start', new Date('2024-02-24 10:10:00'), '');
|
||||||
|
// await c('a', new Date('2024-02-24 10:10:00'), s);
|
||||||
|
// await c('b', new Date('2024-02-24 10:10:02'), s);
|
||||||
|
// await c('c', new Date('2024-02-24 10:10:03'), s);
|
||||||
|
// await c('d', new Date('2024-02-24 10:10:04'), s);
|
||||||
|
|
||||||
|
process.exit();
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -24,6 +24,7 @@ export interface IClickhouseEvent {
|
|||||||
device_id: string;
|
device_id: string;
|
||||||
profile_id: string;
|
profile_id: string;
|
||||||
project_id: string;
|
project_id: string;
|
||||||
|
session_id: string;
|
||||||
path: string;
|
path: string;
|
||||||
referrer: string;
|
referrer: string;
|
||||||
referrer_name: string;
|
referrer_name: string;
|
||||||
@@ -56,6 +57,7 @@ export function transformEvent(
|
|||||||
deviceId: event.device_id,
|
deviceId: event.device_id,
|
||||||
profileId: event.profile_id,
|
profileId: event.profile_id,
|
||||||
projectId: event.project_id,
|
projectId: event.project_id,
|
||||||
|
sessionId: event.session_id,
|
||||||
properties: event.properties,
|
properties: event.properties,
|
||||||
createdAt: convertClickhouseDateToJs(event.created_at),
|
createdAt: convertClickhouseDateToJs(event.created_at),
|
||||||
country: event.country,
|
country: event.country,
|
||||||
@@ -84,6 +86,7 @@ export interface IServiceCreateEventPayload {
|
|||||||
deviceId: string;
|
deviceId: string;
|
||||||
profileId: string;
|
profileId: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
sessionId: string;
|
||||||
properties: Record<string, unknown> & {
|
properties: Record<string, unknown> & {
|
||||||
hash?: string;
|
hash?: string;
|
||||||
query?: Record<string, unknown>;
|
query?: Record<string, unknown>;
|
||||||
@@ -162,7 +165,7 @@ export async function createEvent(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const exists = await getProfileById(payload.profileId);
|
const exists = await getProfileById(payload.profileId);
|
||||||
if (!exists) {
|
if (!exists && payload.profileId !== '') {
|
||||||
const { firstName, lastName } = randomSplitName();
|
const { firstName, lastName } = randomSplitName();
|
||||||
await upsertProfile({
|
await upsertProfile({
|
||||||
id: payload.profileId,
|
id: payload.profileId,
|
||||||
@@ -198,6 +201,7 @@ export async function createEvent(
|
|||||||
device_id: payload.deviceId,
|
device_id: payload.deviceId,
|
||||||
profile_id: payload.profileId,
|
profile_id: payload.profileId,
|
||||||
project_id: payload.projectId,
|
project_id: payload.projectId,
|
||||||
|
session_id: payload.sessionId,
|
||||||
properties: toDots(omit(['_path'], payload.properties)),
|
properties: toDots(omit(['_path'], payload.properties)),
|
||||||
path: payload.path ?? '',
|
path: payload.path ?? '',
|
||||||
created_at: formatClickhouseDate(payload.createdAt),
|
created_at: formatClickhouseDate(payload.createdAt),
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import { createSqlBuilder } from '../sql-builder';
|
|||||||
import { getEventFiltersWhereClause } from './chart.service';
|
import { getEventFiltersWhereClause } from './chart.service';
|
||||||
|
|
||||||
export async function getProfileById(id: string) {
|
export async function getProfileById(id: string) {
|
||||||
|
if (id === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const [profile] = await chQuery<IClickhouseProfile>(
|
const [profile] = await chQuery<IClickhouseProfile>(
|
||||||
`SELECT * FROM profiles WHERE id = '${id}' ORDER BY created_at DESC LIMIT 1`
|
`SELECT * FROM profiles WHERE id = '${id}' ORDER BY created_at DESC LIMIT 1`
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export function createSqlBuilder() {
|
|||||||
|
|
||||||
const sb: SqlBuilderObject = {
|
const sb: SqlBuilderObject = {
|
||||||
where: {},
|
where: {},
|
||||||
from: 'openpanel.events',
|
from: 'events',
|
||||||
select: {},
|
select: {},
|
||||||
groupBy: {},
|
groupBy: {},
|
||||||
orderBy: {},
|
orderBy: {},
|
||||||
|
|||||||
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
@@ -201,6 +201,9 @@ importers:
|
|||||||
ua-parser-js:
|
ua-parser-js:
|
||||||
specifier: ^1.0.37
|
specifier: ^1.0.37
|
||||||
version: 1.0.37
|
version: 1.0.37
|
||||||
|
uuid:
|
||||||
|
specifier: ^9.0.1
|
||||||
|
version: 9.0.1
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@mixan/eslint-config':
|
'@mixan/eslint-config':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
@@ -220,6 +223,9 @@ importers:
|
|||||||
'@types/ua-parser-js':
|
'@types/ua-parser-js':
|
||||||
specifier: ^0.7.39
|
specifier: ^0.7.39
|
||||||
version: 0.7.39
|
version: 0.7.39
|
||||||
|
'@types/uuid':
|
||||||
|
specifier: ^9.0.8
|
||||||
|
version: 9.0.8
|
||||||
'@types/ws':
|
'@types/ws':
|
||||||
specifier: ^8.5.10
|
specifier: ^8.5.10
|
||||||
version: 8.5.10
|
version: 8.5.10
|
||||||
@@ -407,6 +413,9 @@ importers:
|
|||||||
cmdk:
|
cmdk:
|
||||||
specifier: ^0.2.1
|
specifier: ^0.2.1
|
||||||
version: 0.2.1(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0)
|
version: 0.2.1(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0)
|
||||||
|
embla-carousel-react:
|
||||||
|
specifier: 8.0.0-rc22
|
||||||
|
version: 8.0.0-rc22(react@18.2.0)
|
||||||
hamburger-react:
|
hamburger-react:
|
||||||
specifier: ^2.5.0
|
specifier: ^2.5.0
|
||||||
version: 2.5.0(react@18.2.0)
|
version: 2.5.0(react@18.2.0)
|
||||||
|
|||||||
Reference in New Issue
Block a user