This commit is contained in:
Carl-Gerhard Lindesvärd
2024-02-04 13:23:21 +01:00
parent 30af9cab2f
commit ccd1a1456f
135 changed files with 5588 additions and 1758 deletions

4
.gitignore vendored
View File

@@ -2,7 +2,9 @@
packages/sdk/profileId.txt
packages/sdk/test.ts
dump.sql
dump-*.sql
dump-*
.sql
clickhouse
# Logs

View File

@@ -15,5 +15,8 @@
"typescript.preferences.autoImportFileExcludePatterns": [
"next/router.d.ts",
"next/dist/client/router.d.ts"
]
],
"[sql]": {
"editor.defaultFormatter": "adpyke.vscode-sql-formatter"
}
}

15
ROADMAP.md Normal file
View File

@@ -0,0 +1,15 @@
# Roadmap
## Simple todos
- [ ] add session_id on events table, link this id on create
- [ ] add overview page containing
- [x] User histogram (last 30 minutes)
- [ ] Bounce rate
- [ ] Session duration
- [ ] Views per session
- [ ] Unique users
- [ ] Total users
- [ ] Total pageviews
- [ ] Total events
- [ ]

43
apps/sdk-api/package.json Normal file
View File

@@ -0,0 +1,43 @@
{
"name": "@mixan/sdk-api",
"version": "0.0.1",
"scripts": {
"dev": "dotenv -e ../../.env -c -v WATCH=1 tsup",
"start": "node dist/index.js",
"build": "rm -rf dist && tsup",
"lint": "eslint .",
"format": "prettier --check \"**/*.{mjs,ts,md,json}\"",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@fastify/cors": "^9.0.0",
"@mixan/common": "workspace:*",
"@mixan/db": "workspace:*",
"@mixan/queue": "workspace:*",
"fastify": "^4.25.2",
"pino": "^8.17.2",
"ramda": "^0.29.1",
"request-ip": "^3.3.0",
"ua-parser-js": "^1.0.37"
},
"devDependencies": {
"@mixan/eslint-config": "workspace:*",
"@mixan/prettier-config": "workspace:*",
"@mixan/tsconfig": "workspace:*",
"@mixan/types": "workspace:*",
"@types/ramda": "^0.29.6",
"@types/request-ip": "^0.0.41",
"@types/ua-parser-js": "^0.7.39",
"eslint": "^8.48.0",
"prettier": "^3.0.3",
"tsup": "^7.2.0",
"typescript": "^5.2.2"
},
"eslintConfig": {
"root": true,
"extends": [
"@mixan/eslint-config/base"
]
},
"prettier": "@mixan/prettier-config"
}

View File

@@ -0,0 +1,226 @@
import { omit, prop, uniqBy } from 'ramda';
import { generateProfileId, getTime, toISOString } from '@mixan/common';
import type { Event, IServiceCreateEventPayload } from '@mixan/db';
import {
createEvent as createClickhouseEvent,
db,
formatClickhouseDate,
getSalts,
} from '@mixan/db';
import { parseIp } from '../src/utils/parseIp';
import { parseUserAgent } from '../src/utils/parseUserAgent';
const clean = omit([
'ip',
'os',
'ua',
'url',
'hash',
'host',
'path',
'device',
'screen',
'hostname',
'language',
'referrer',
'timezone',
]);
async function main() {
const events = await db.event.findMany({
where: {
project_id: '4e2798cb-e255-4e9d-960d-c9ad095aabd7',
name: 'screen_view',
createdAt: {
gte: new Date('2024-01-01'),
lt: new Date('2024-02-04'),
},
},
orderBy: {
createdAt: 'asc',
},
});
const grouped: Record<string, Event[]> = {};
let index = 0;
for (const event of events.slice()) {
// console.log(index, event.name, event.createdAt.toISOString());
index++;
const properties = event.properties as Record<string, any>;
if (properties.ua?.includes('bot')) {
// console.log('IGNORE', event.id);
continue;
}
if (!event.profile_id) {
// console.log('IGNORE', event.id);
continue;
}
const hej = grouped[event.profile_id];
if (hej) {
hej.push(event);
} else {
grouped[event.profile_id] = [event];
}
}
console.log('Total users', Object.keys(grouped).length);
let uidx = -1;
for (const profile_id of Object.keys(grouped)) {
uidx++;
console.log(`User index ${uidx}`);
const events = uniqBy(prop('createdAt'), grouped[profile_id] || []);
if (events) {
let lastSessionStart = null;
let screenViews = 0;
let totalDuration = 0;
console.log('new user...');
let eidx = -1;
for (const event of events) {
eidx++;
const prevEvent = events[eidx - 1];
const prevEventAt = prevEvent?.createdAt;
const nextEvent = events[eidx + 1];
const properties = event.properties as Record<string, any>;
const projectId = event.project_id;
const path = properties.path!;
const ip = properties.ip!;
const origin = 'https://mixan.kiddo.se';
const ua = properties.ua!;
const uaInfo = parseUserAgent(ua);
const salts = await getSalts();
const [profileId, geo] = await Promise.all([
generateProfileId({
salt: salts.current,
origin,
ip,
ua,
}),
parseIp(ip),
]);
const isNextEventNewSession =
nextEvent &&
nextEvent.createdAt.getTime() - event.createdAt.getTime() >
1000 * 60 * 30;
const payload: IServiceCreateEventPayload = {
name: event.name,
profileId,
projectId,
properties: clean(properties),
createdAt: event.createdAt.toISOString(),
country: geo.country,
city: geo.city,
region: geo.region,
continent: geo.continent,
os: uaInfo.os,
osVersion: uaInfo.osVersion,
browser: uaInfo.browser,
browserVersion: uaInfo.browserVersion,
device: uaInfo.device,
brand: uaInfo.brand,
model: uaInfo.model,
duration:
nextEvent && !isNextEventNewSession
? nextEvent.createdAt.getTime() - event.createdAt.getTime()
: 0,
path,
referrer: properties?.referrer?.host ?? '', // TODO
referrerName: properties?.referrer?.host ?? '', // TODO
};
if (!prevEventAt) {
lastSessionStart = await createSessionStart(payload);
} else if (
event.createdAt.getTime() - prevEventAt.getTime() >
1000 * 60 * 30
) {
if (eidx > 0 && prevEventAt && lastSessionStart) {
await createSessionEnd(prevEventAt, lastSessionStart, {
screenViews,
totalDuration,
});
totalDuration = 0;
screenViews = 0;
lastSessionStart = await createSessionStart(payload);
}
}
screenViews++;
totalDuration += payload.duration;
await createEvent(payload);
} // for each user event
const prevEventAt = events[events.length - 1]?.createdAt;
if (prevEventAt && lastSessionStart) {
await createSessionEnd(prevEventAt, lastSessionStart, {
screenViews,
totalDuration,
});
}
}
}
}
async function createEvent(event: IServiceCreateEventPayload) {
console.log(
`Create ${event.name} - ${event.path} - ${formatClickhouseDate(
event.createdAt
)} - ${event.duration / 1000} sec`
);
await createClickhouseEvent(event);
}
async function createSessionStart(event: IServiceCreateEventPayload) {
const session: IServiceCreateEventPayload = {
...event,
duration: 0,
name: 'session_start',
createdAt: toISOString(getTime(event.createdAt) - 10),
};
await createEvent(session);
return session;
}
async function createSessionEnd(
prevEventAt: Date,
sessionStart: IServiceCreateEventPayload,
options: {
screenViews: number;
totalDuration: number;
}
) {
const properties: Record<string, unknown> = {};
if (options.screenViews === 1) {
properties._bounce = true;
} else {
properties._bounce = false;
}
const session: IServiceCreateEventPayload = {
...sessionStart,
properties: {
...properties,
...sessionStart.properties,
},
duration: options.totalDuration,
name: 'session_end',
createdAt: toISOString(prevEventAt.getTime() + 10),
};
await createEvent(session);
return session;
}
main();

View File

@@ -0,0 +1,176 @@
import { parseIp } from '@/utils/parseIp';
import { parseUserAgent } from '@/utils/parseUserAgent';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { getClientIp } from 'request-ip';
import { generateProfileId, getTime, toISOString } from '@mixan/common';
import type { IServiceCreateEventPayload } from '@mixan/db';
import { getSalts } from '@mixan/db';
import type { JobsOptions } from '@mixan/queue';
import { eventsQueue, findJobByPrefix } from '@mixan/queue';
export interface PostEventPayload {
profileId?: string;
name: string;
timestamp: string;
properties: Record<string, unknown>;
referrer: string | undefined;
path: string;
}
const SESSION_TIMEOUT = 1000 * 30 * 1;
const SESSION_END_TIMEOUT = SESSION_TIMEOUT + 1000;
export async function postEvent(
request: FastifyRequest<{
Body: PostEventPayload;
}>,
reply: FastifyReply
) {
let profileId: string | null = null;
const projectId = request.projectId;
const body = request.body;
const path = body.path;
const ip = getClientIp(request)!;
const origin = request.headers.origin!;
const ua = request.headers['user-agent']!;
const uaInfo = parseUserAgent(ua);
const salts = await getSalts();
const [currentProfileId, previousProfileId, geo, eventsJobs] =
await Promise.all([
generateProfileId({
salt: salts.current,
origin,
ip,
ua,
}),
generateProfileId({
salt: salts.previous,
origin,
ip,
ua,
}),
parseIp(ip),
eventsQueue.getJobs(['delayed']),
]);
// find session_end job
const sessionEndJobCurrentProfileId = findJobByPrefix(
eventsJobs,
`sessionEnd:${projectId}:${currentProfileId}:`
);
const sessionEndJobPreviousProfileId = findJobByPrefix(
eventsJobs,
`sessionEnd:${projectId}:${previousProfileId}:`
);
const createSessionStart =
!sessionEndJobCurrentProfileId && !sessionEndJobPreviousProfileId;
if (sessionEndJobCurrentProfileId && !sessionEndJobPreviousProfileId) {
console.log('found session current');
profileId = currentProfileId;
const diff = Date.now() - sessionEndJobCurrentProfileId.timestamp;
sessionEndJobCurrentProfileId.changeDelay(diff + SESSION_END_TIMEOUT);
} else if (!sessionEndJobCurrentProfileId && sessionEndJobPreviousProfileId) {
console.log('found session previous');
profileId = previousProfileId;
const diff = Date.now() - sessionEndJobPreviousProfileId.timestamp;
sessionEndJobPreviousProfileId.changeDelay(diff + SESSION_END_TIMEOUT);
} else {
console.log('new session with current');
profileId = currentProfileId;
// Queue session end
eventsQueue.add(
'event',
{
type: 'createSessionEnd',
payload: {
profileId,
},
},
{
delay: SESSION_END_TIMEOUT,
jobId: `sessionEnd:${projectId}:${profileId}:${Date.now()}`,
}
);
}
const payload: IServiceCreateEventPayload = {
name: body.name,
profileId,
projectId,
properties: body.properties,
createdAt: body.timestamp,
country: geo.country,
city: geo.city,
region: geo.region,
continent: geo.continent,
os: uaInfo.os,
osVersion: uaInfo.osVersion,
browser: uaInfo.browser,
browserVersion: uaInfo.browserVersion,
device: uaInfo.device,
brand: uaInfo.brand,
model: uaInfo.model,
duration: 0,
path,
referrer: body.referrer, // TODO
referrerName: body.referrer, // TODO
};
console.log(payload);
const job = findJobByPrefix(eventsJobs, `event:${projectId}:${profileId}:`);
if (job?.isDelayed && job.data.type === 'createEvent') {
const prevEvent = job.data.payload;
const duration = getTime(payload.createdAt) - getTime(prevEvent.createdAt);
// Set path from prev screen_view event if current event is not a screen_view
if (payload.name != 'screen_view') {
payload.path = prevEvent.path;
}
if (payload.name === 'screen_view') {
await job.updateData({
type: 'createEvent',
payload: {
...prevEvent,
duration,
},
});
job.promote();
}
}
if (createSessionStart) {
eventsQueue.add('event', {
type: 'createEvent',
payload: {
...payload,
name: 'session_start',
createdAt: toISOString(getTime(payload.createdAt) - 10),
},
});
}
const options: JobsOptions = {};
if (payload.name === 'screen_view') {
options.delay = SESSION_TIMEOUT;
options.jobId = `event:${projectId}:${profileId}:${Date.now()}`;
}
// Queue current event
eventsQueue.add(
'event',
{
type: 'createEvent',
payload,
},
options
);
reply.status(202).send(profileId);
}

78
apps/sdk-api/src/index.ts Normal file
View File

@@ -0,0 +1,78 @@
import cors from '@fastify/cors';
import Fastify from 'fastify';
import pino from 'pino';
import eventRouter from './routes/event.router';
import { validateSdkRequest } from './utils/auth';
declare module 'fastify' {
interface FastifyRequest {
projectId: string;
}
}
const port = parseInt(process.env.API_PORT || '3030', 10);
const startServer = async () => {
try {
const fastify = Fastify({
logger: pino({ level: 'info' }),
});
fastify.register(cors, {
origin: '*',
});
fastify.decorateRequest('projectId', '');
fastify.addHook('preHandler', (req, reply, done) => {
validateSdkRequest(req.headers)
.then((projectId) => {
req.projectId = projectId;
done();
})
.catch((e) => {
console.log(e);
reply.status(401).send();
});
});
fastify.register(eventRouter, { prefix: '/api/event' });
fastify.setErrorHandler((error, request, reply) => {
fastify.log.error(error);
});
fastify.get('/', (request, reply) => {
reply.send({ name: 'fastify-typescript' });
});
// fastify.get('/health-check', async (request, reply) => {
// try {
// await utils.healthCheck()
// reply.status(200).send()
// } catch (e) {
// reply.status(500).send()
// }
// })
if (process.env.NODE_ENV === 'production') {
for (const signal of ['SIGINT', 'SIGTERM']) {
process.on(signal, () =>
fastify.close().then((err) => {
console.log(`close application on ${signal}`);
process.exit(err ? 1 : 0);
})
);
}
}
await fastify.listen({ port });
} catch (e) {
console.error(e);
}
};
process.on('unhandledRejection', (e) => {
console.error(e);
process.exit(1);
});
startServer();

View File

@@ -0,0 +1,18 @@
import * as controller from '@/controllers/event.controller';
import type { FastifyPluginCallback } from 'fastify';
const eventRouter: FastifyPluginCallback = (fastify, opts, done) => {
fastify.route({
method: 'POST',
url: '/',
handler: controller.postEvent,
});
fastify.route({
method: 'GET',
url: '/',
handler: controller.postEvent,
});
done();
};
export default eventRouter;

View File

@@ -0,0 +1,43 @@
import type { RawRequestDefaultExpression } from 'fastify';
import { verifyPassword } from '@mixan/common';
import { db } from '@mixan/db';
export async function validateSdkRequest(
headers: RawRequestDefaultExpression['headers']
): Promise<string> {
const clientId = headers['mixan-client-id'] as string;
const clientSecret = headers['mixan-client-secret'] as string;
const origin = headers.origin;
if (!clientId) {
throw new Error('Misisng client id');
}
const client = await db.client.findUnique({
where: {
id: clientId,
},
});
if (!client) {
throw new Error('Invalid client id');
}
if (client.secret) {
if (!(await verifyPassword(clientSecret || '', client.secret))) {
throw new Error('Invalid client secret');
}
} else if (client.cors !== '*') {
const domainAllowed = client.cors.split(',').find((domain) => {
if (domain === origin) {
return true;
}
});
if (!domainAllowed) {
throw new Error('Invalid cors settings');
}
}
return client.project_id;
}

View File

@@ -0,0 +1,25 @@
export async function parseIp(ip: string) {
try {
const geo = await fetch(`http://localhost:8080/${ip}`);
const res = (await geo.json()) as {
country: string | undefined;
city: string | undefined;
stateprov: string | undefined;
continent: string | undefined;
};
return {
country: res.country,
city: res.city,
region: res.stateprov,
continent: res.continent,
};
} catch (e) {
return {
country: undefined,
city: undefined,
region: undefined,
continent: undefined,
};
}
}

View File

@@ -0,0 +1,29 @@
import { UAParser } from 'ua-parser-js';
export function parseUserAgent(ua: string) {
const res = new UAParser(ua).getResult();
return {
os: res.os.name,
osVersion: res.os.version,
browser: res.browser.name,
browserVersion: res.browser.version,
device: res.device.type ?? getDevice(ua),
brand: res.device.vendor,
model: res.device.model,
};
}
export function getDevice(ua: string) {
const t1 =
/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test(
ua
);
const t2 =
/1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw-(n|u)|c55\/|capi|ccwa|cdm-|cell|chtm|cldc|cmd-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc-s|devi|dica|dmob|do(c|p)o|ds(12|-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(-|_)|g1 u|g560|gene|gf-5|g-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd-(m|p|t)|hei-|hi(pt|ta)|hp( i|ip)|hs-c|ht(c(-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i-(20|go|ma)|i230|iac( |-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|-[a-w])|libw|lynx|m1-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|-([1-8]|c))|phil|pire|pl(ay|uc)|pn-2|po(ck|rt|se)|prox|psio|pt-g|qa-a|qc(07|12|21|32|60|-[2-7]|i-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h-|oo|p-)|sdk\/|se(c(-|0|1)|47|mc|nd|ri)|sgh-|shar|sie(-|m)|sk-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h-|v-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl-|tdg-|tel(i|m)|tim-|t-mo|to(pl|sh)|ts(70|m-|m3|m5)|tx-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas-|your|zeto|zte-/i.test(
ua.slice(0, 4)
);
if (t1 || t2) {
return 'mobile';
}
return 'desktop';
}

View File

@@ -0,0 +1,12 @@
{
"extends": "@mixan/tsconfig/base.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["."],
"exclude": ["node_modules"]
}

View File

@@ -0,0 +1,18 @@
import { defineConfig } from 'tsup';
import type { Options } from 'tsup';
const options: Options = {
clean: true,
entry: ['src/index.ts'],
noExternal: [/^@mixan\/.*$/u, /^@\/.*$/u],
sourcemap: true,
splitting: false,
};
if (process.env.WATCH) {
options.watch = ['src/**/*', '../../packages/**/*'];
options.onSuccess = 'node dist/index.js';
options.minify = false;
}
export default defineConfig(options);

View File

@@ -12,7 +12,9 @@
"with-env": "dotenv -e ../../.env -c --"
},
"dependencies": {
"@clickhouse/client": "^0.2.9",
"@hookform/resolvers": "^3.3.2",
"@mixan/common": "workspace:^",
"@mixan/db": "workspace:^",
"@mixan/queue": "workspace:^",
"@mixan/types": "workspace:*",
@@ -44,6 +46,7 @@
"lodash.debounce": "^4.0.8",
"lottie-react": "^2.4.0",
"lucide-react": "^0.286.0",
"mathjs": "^12.3.0",
"mitt": "^3.0.1",
"next": "~14.0.4",
"next-auth": "^4.23.0",
@@ -58,9 +61,11 @@
"react-in-viewport": "1.0.0-alpha.30",
"react-redux": "^8.1.3",
"react-responsive": "^9.0.2",
"react-svg-worldmap": "2.0.0-alpha.16",
"react-syntax-highlighter": "^15.5.0",
"react-virtualized-auto-sizer": "^1.0.20",
"recharts": "^2.8.0",
"request-ip": "^3.3.0",
"slugify": "^1.6.6",
"superjson": "^1.13.1",
"tailwind-merge": "^1.14.0",
@@ -79,6 +84,7 @@
"@types/react": "^18.2.20",
"@types/react-dom": "^18.2.7",
"@types/react-syntax-highlighter": "^15.5.9",
"@types/request-ip": "^0.0.41",
"@typescript-eslint/eslint-plugin": "^6.6.0",
"@typescript-eslint/parser": "^6.6.0",
"autoprefixer": "^10.4.14",

View File

@@ -1,8 +1,9 @@
'use client';
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { StickyBelowHeader } from '@/app/(app)/layout-sticky-below-header';
import { LazyChart } from '@/components/report/chart/LazyChart';
import { ReportRange } from '@/components/report/ReportRange';
import { Button } from '@/components/ui/button';
import { Combobox } from '@/components/ui/combobox';
import {
@@ -19,7 +20,7 @@ import { cn } from '@/utils/cn';
import { getDefaultIntervalByRange, timeRanges } from '@/utils/constants';
import { ChevronRight, MoreHorizontal, PlusIcon, Trash } from 'lucide-react';
import Link from 'next/link';
import { useParams, useRouter } from 'next/navigation';
import { useRouter } from 'next/navigation';
interface ListReportsProps {
reports: Awaited<ReturnType<typeof getReportsByDashboardId>>;
@@ -33,17 +34,10 @@ export function ListReports({ reports }: ListReportsProps) {
return (
<>
<StickyBelowHeader className="p-4 items-center justify-between flex">
<Combobox
className="min-w-0"
<ReportRange
placeholder="Override range"
value={range}
onChange={(value) => {
setRange((p) => (p === value ? null : value));
}}
items={Object.values(timeRanges).map((key) => ({
label: key,
value: key,
}))}
onChange={(value) => setRange((p) => (p === value ? null : value))}
/>
<Button
icon={PlusIcon}

View File

@@ -4,7 +4,7 @@ import { Button } from '@/components/ui/button';
import { pushModal } from '@/modals';
import { PlusIcon } from 'lucide-react';
import { StickyBelowHeader } from '../../layout-sticky-below-header';
import { StickyBelowHeader } from '../../../layout-sticky-below-header';
interface HeaderDashboardsProps {
projectId: string;

View File

@@ -53,7 +53,7 @@ export function ListDashboards({ dashboards }: ListDashboardsProps) {
<Card key={item.id} hover>
<div>
<Link
href={`/${organizationId}/${projectId}/${item.id}`}
href={`/${organizationId}/${projectId}/dashboards/${item.id}`}
className="block p-4 flex flex-col"
>
<span className="font-medium">{item.name}</span>

View File

@@ -0,0 +1,25 @@
import PageLayout from '@/app/(app)/page-layout';
import { getDashboardsByProjectId } from '@/server/services/dashboard.service';
import { HeaderDashboards } from './header-dashboards';
import { ListDashboards } from './list-dashboards';
interface PageProps {
params: {
organizationId: string;
projectId: string;
};
}
export default async function Page({
params: { organizationId, projectId },
}: PageProps) {
const dashboards = await getDashboardsByProjectId(projectId);
return (
<PageLayout title="Dashboards" organizationId={organizationId}>
<HeaderDashboards projectId={projectId} />
<ListDashboards dashboards={dashboards} />
</PageLayout>
);
}

View File

@@ -6,11 +6,9 @@ import { ListProperties } from '@/components/events/ListProperties';
import { ExpandableListItem } from '@/components/general/ExpandableListItem';
import { ProfileAvatar } from '@/components/profiles/ProfileAvatar';
import { useAppParams } from '@/hooks/useAppParams';
import { cn } from '@/utils/cn';
import { formatDateTime } from '@/utils/date';
import { getProfileName } from '@/utils/getters';
import { round } from '@/utils/math';
import { Activity, BotIcon, MonitorPlay } from 'lucide-react';
import Link from 'next/link';
import { EventIcon } from './event-icon';
@@ -48,7 +46,7 @@ export function EventListItem({
switch (name) {
case 'screen_view': {
const route = (properties?.route || properties?.path) as string;
const route = (properties?.route || properties?.path)!;
if (route) {
bullets.push(route);
}

View File

@@ -0,0 +1,272 @@
'use client';
import { OverviewFilters } from '@/components/overview/overview-filters';
import { OverviewFiltersButtons } from '@/components/overview/overview-filters-buttons';
import OverviewTopDevices from '@/components/overview/overview-top-devices';
import OverviewTopEvents from '@/components/overview/overview-top-events';
import OverviewTopGeo from '@/components/overview/overview-top-geo';
import OverviewTopPages from '@/components/overview/overview-top-pages';
import OverviewTopSources from '@/components/overview/overview-top-sources';
import { WidgetHead } from '@/components/overview/overview-widget';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { Chart } from '@/components/report/chart';
import { ReportRange } from '@/components/report/ReportRange';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
import { Widget, WidgetBody } from '@/components/Widget';
import type { IChartInput } from '@/types';
import { cn } from '@/utils/cn';
import { Eye, FilterIcon, Globe2Icon, LockIcon, X } from 'lucide-react';
import Link from 'next/link';
import { StickyBelowHeader } from '../../layout-sticky-below-header';
export default function OverviewMetrics() {
const { previous, range, setRange, interval, metric, setMetric, filters } =
useOverviewOptions();
const reports = [
{
id: 'Unique visitors',
projectId: '', // TODO: Remove
events: [
{
segment: 'user',
filters,
id: 'A',
name: 'session_start',
displayName: 'Unique visitors',
},
],
breakdowns: [],
chartType: 'metric',
lineType: 'monotone',
interval,
name: 'Unique visitors',
range,
previous,
metric: 'sum',
},
{
id: 'Total sessions',
projectId: '', // TODO: Remove
events: [
{
segment: 'event',
filters,
id: 'A',
name: 'session_start',
displayName: 'Total sessions',
},
],
breakdowns: [],
chartType: 'metric',
lineType: 'monotone',
interval,
name: 'Total sessions',
range,
previous,
metric: 'sum',
},
{
id: 'Total pageviews',
projectId: '', // TODO: Remove
events: [
{
segment: 'event',
filters,
id: 'A',
name: 'screen_view',
displayName: 'Total pageviews',
},
],
breakdowns: [],
chartType: 'metric',
lineType: 'monotone',
interval,
name: 'Total pageviews',
range,
previous,
metric: 'sum',
},
{
id: 'Views per session',
projectId: '', // TODO: Remove
events: [
{
segment: 'user_average',
filters,
id: 'A',
name: 'screen_view',
displayName: 'Views per session',
},
],
breakdowns: [],
chartType: 'metric',
lineType: 'monotone',
interval,
name: 'Views per session',
range,
previous,
metric: 'average',
},
{
id: 'Bounce rate',
projectId: '', // TODO: Remove
events: [
{
segment: 'event',
filters: [
{
id: '1',
name: 'properties._bounce',
operator: 'is',
value: ['true'],
},
...filters,
],
id: 'A',
name: 'session_end',
displayName: 'Bounce rate',
},
{
segment: 'event',
filters: filters,
id: 'B',
name: 'session_end',
},
],
breakdowns: [],
chartType: 'metric',
lineType: 'monotone',
interval,
name: 'Bounce rate',
range,
previous,
previousIndicatorInverted: true,
formula: 'A/B*100',
metric: 'average',
unit: '%',
},
{
id: 'Visit duration',
projectId: '', // TODO: Remove
events: [
{
segment: 'property_average',
filters: [
{
name: 'duration',
operator: 'isNot',
value: ['0'],
id: 'A',
},
...filters,
],
id: 'A',
property: 'duration',
name: 'screen_view',
displayName: 'Visit duration',
},
],
breakdowns: [],
chartType: 'metric',
lineType: 'monotone',
interval,
name: 'Visit duration',
range,
previous,
formula: 'A/1000/60',
metric: 'average',
unit: 'min',
},
] satisfies (IChartInput & { id: string })[];
const selectedMetric = reports[metric]!;
return (
<Sheet>
<StickyBelowHeader className="p-4 flex gap-2 justify-between">
<ReportRange
size="sm"
value={range}
onChange={(value) => setRange(value)}
/>
<div className="flex-wrap flex gap-2">
<OverviewFiltersButtons />
<SheetTrigger asChild>
<Button size="sm" variant="cta" icon={FilterIcon}>
Filters
</Button>
</SheetTrigger>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" icon={Globe2Icon}>
Public
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuGroup>
<DropdownMenuItem asChild>
<Link href="http://localhost:3000/share/project/4e2798cb-e255-4e9d-960d-c9ad095aabd7">
<Eye size={16} className="mr-2" />
View
</Link>
</DropdownMenuItem>
<DropdownMenuItem onClick={(event) => {}}>
<LockIcon size={16} className="mr-2" />
Make private
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</StickyBelowHeader>
<div className="p-4 grid gap-4 grid-cols-6">
{reports.map((report, index) => (
<button
key={index}
className="relative col-span-6 md:col-span-3 lg:col-span-2 group"
onClick={() => {
setMetric(index);
}}
>
<Chart hideID {...report} />
{/* add active border */}
<div
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',
metric === index ? 'opacity-100' : 'opacity-0'
)}
/>
</button>
))}
<Widget className="col-span-6">
<WidgetHead>
<div className="title">{selectedMetric.events[0]?.displayName}</div>
</WidgetHead>
<WidgetBody>
<Chart hideID {...selectedMetric} chartType="linear" />
</WidgetBody>
</Widget>
<OverviewTopSources />
<OverviewTopPages />
<OverviewTopDevices />
<OverviewTopGeo />
<OverviewTopEvents />
</div>
<SheetContent className="!max-w-lg w-full" side="left">
<OverviewFilters />
</SheetContent>
</Sheet>
);
}

View File

@@ -1,8 +1,6 @@
import PageLayout from '@/app/(app)/page-layout';
import { getDashboardsByProjectId } from '@/server/services/dashboard.service';
import { HeaderDashboards } from './header-dashboards';
import { ListDashboards } from './list-dashboards';
import OverviewMetrics from './overview-metrics';
interface PageProps {
params: {
@@ -11,15 +9,10 @@ interface PageProps {
};
}
export default async function Page({
params: { organizationId, projectId },
}: PageProps) {
const dashboards = await getDashboardsByProjectId(projectId);
export default function Page({ params: { organizationId } }: PageProps) {
return (
<PageLayout title="Dashboards" organizationId={organizationId}>
<HeaderDashboards projectId={projectId} />
<ListDashboards dashboards={dashboards} />
<PageLayout title="Overview" organizationId={organizationId}>
<OverviewMetrics />
</PageLayout>
);
}

View File

@@ -1,12 +1,12 @@
'use client';
import { useEffect, useRef } from 'react';
import { api } from '@/app/_trpc/client';
import { useEffect } from 'react';
import { StickyBelowHeader } from '@/app/(app)/layout-sticky-below-header';
import { Chart } from '@/components/report/chart';
import { ReportChartType } from '@/components/report/ReportChartType';
import { ReportInterval } from '@/components/report/ReportInterval';
import { ReportLineType } from '@/components/report/ReportLineType';
import { ReportRange } from '@/components/report/ReportRange';
import { ReportSaveButton } from '@/components/report/ReportSaveButton';
import {
changeDateRanges,
@@ -58,17 +58,12 @@ export default function ReportEditor({
</SheetTrigger>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 col-span-4">
<ReportChartType className="min-w-0 flex-1" />
<Combobox
<ReportRange
className="min-w-0 flex-1"
placeholder="Range"
value={report.range}
onChange={(value) => {
dispatch(changeDateRanges(value));
}}
items={Object.values(timeRanges).map((key) => ({
label: key,
value: key,
}))}
/>
<ReportInterval className="min-w-0 flex-1" />
<ReportLineType className="min-w-0 flex-1" />

View File

@@ -1,4 +1,4 @@
import { getFirstProjectByOrganizationId } from '@/server/services/project.service';
import { getProjectWithMostEvents } from '@/server/services/project.service';
import { redirect } from 'next/navigation';
interface PageProps {
@@ -8,10 +8,11 @@ interface PageProps {
}
export default async function Page({ params: { organizationId } }: PageProps) {
const project = await getFirstProjectByOrganizationId(organizationId);
const project = await getProjectWithMostEvents(organizationId);
if (project) {
return redirect(`/${organizationId}/${project.id}`);
}
return <p>List projects maybe?</p>;
return null;
}

View File

@@ -2,14 +2,17 @@
import { useAppParams } from '@/hooks/useAppParams';
import type { IServiceRecentDashboards } from '@/server/services/dashboard.service';
import { cn } from '@/utils/cn';
import {
BuildingIcon,
CogIcon,
DotIcon,
GanttChartIcon,
KeySquareIcon,
LayoutPanelTopIcon,
UserIcon,
UsersIcon,
WallpaperIcon,
WarehouseIcon,
} from 'lucide-react';
import type { LucideProps } from 'lucide-react';
@@ -20,18 +23,32 @@ function LinkWithIcon({
href,
icon: Icon,
label,
active: overrideActive,
}: {
href: string;
icon: React.ElementType<LucideProps>;
label: React.ReactNode;
active?: boolean;
}) {
const pathname = usePathname();
const active = overrideActive || href === pathname;
return (
<Link
className="flex gap-2 items-center px-3 py-3 transition-colors hover:bg-slate-100 leading-none rounded-lg"
className={cn(
'text-slate-600 flex gap-2 items-center px-3 py-3 transition-colors hover:text-white hover:bg-blue-700 leading-none rounded-lg',
active && 'bg-blue-600 text-white'
)}
href={href}
>
<Icon size={20} />
{label}
<div className="flex-1">{label}</div>
<DotIcon
size={20}
className={cn(
'transition-opacity',
active ? 'opacity-100' : 'opacity-0'
)}
/>
</Link>
);
}
@@ -53,10 +70,15 @@ export default function LayoutMenu({
return (
<>
<LinkWithIcon
icon={WallpaperIcon}
label="Overview"
href={`/${params.organizationId}/${projectId}`}
/>
<LinkWithIcon
icon={LayoutPanelTopIcon}
label="Dashboards"
href={`/${params.organizationId}/${projectId}`}
href={`/${params.organizationId}/${projectId}/dashboards`}
/>
<LinkWithIcon
icon={GanttChartIcon}

View File

@@ -12,7 +12,7 @@ export function StickyBelowHeader({
return (
<div
className={cn(
'md:sticky top-16 bg-white border-b border-border z-10',
'md:sticky bg-white border-b border-border z-10 [[id=dashboard]_&]:top-16 [[id=dashboard]_&]:rounded-none rounded-lg top-0',
className
)}
>

View File

@@ -2,6 +2,7 @@ import { getSession } from '@/server/auth';
import { getRecentDashboardsByUserId } from '@/server/services/dashboard.service';
import { getOrganizations } from '@/server/services/organization.service';
import Auth from '../auth';
import { LayoutSidebar } from './layout-sidebar';
interface AppLayoutProps {
@@ -15,8 +16,12 @@ export default async function AppLayout({ children }: AppLayoutProps) {
? await getRecentDashboardsByUserId(session?.user.id)
: [];
if (!session) {
return <Auth />;
}
return (
<div>
<div id="dashboard">
<LayoutSidebar {...{ organizations, recentDashboards }} />
<div className="lg:pl-72 transition-all">{children}</div>
</div>

View File

@@ -1,10 +0,0 @@
import { createRecentDashboard } from '@/server/services/dashboard.service';
import { revalidatePath } from 'next/cache';
import { NextResponse } from 'next/server';
export const dynamic = 'force-dynamic'; // defaults to auto
export async function POST(req: Request) {
await createRecentDashboard(await req.json());
revalidatePath('/', 'layout');
return NextResponse.json({ ok: 'qe' });
}

View File

@@ -1,24 +0,0 @@
import { createContext, useContext } from 'react';
import type { RequestCookie } from 'next/dist/compiled/@edge-runtime/cookies';
type ICookies = Record<string, string | null>;
const context = createContext<ICookies>({});
export const CookieProvider = ({
value,
children,
}: {
children: React.ReactNode;
value: RequestCookie[];
}) => {
const cookies = value.reduce((acc, cookie) => {
return {
...acc,
[cookie.name]: cookie.value,
};
}, {} as ICookies);
return <context.Provider value={cookies}>{children}</context.Provider>;
};
export const useCookies = (): ICookies => useContext(context);

View File

@@ -5,9 +5,6 @@ import Providers from './providers';
import '@/styles/globals.css';
import { getSession } from '@/server/auth';
import { cookies } from 'next/headers';
import Auth from './auth';
export const metadata = {};
@@ -30,9 +27,7 @@ export default async function RootLayout({
<body
className={cn('min-h-screen font-sans antialiased grainy bg-slate-50')}
>
<Providers cookies={cookies().getAll()} session={session}>
{session ? children : <Auth />}
</Providers>
<Providers session={session}>{children}</Providers>
</body>
</html>
);

View File

@@ -8,29 +8,25 @@ import { ModalProvider } from '@/modals';
import type { AppStore } from '@/redux';
import makeStore from '@/redux';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import { httpLink } from '@trpc/client';
import type { Session } from 'next-auth';
import { SessionProvider } from 'next-auth/react';
import type { RequestCookie } from 'next/dist/compiled/@edge-runtime/cookies';
import { Provider as ReduxProvider } from 'react-redux';
import superjson from 'superjson';
import { CookieProvider } from './cookie-provider';
export default function Providers({
children,
session,
cookies,
}: {
children: React.ReactNode;
session: Session | null;
cookies: RequestCookie[];
}) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
networkMode: 'always',
refetchOnMount: true,
refetchOnWindowFocus: false,
},
@@ -41,7 +37,7 @@ export default function Providers({
api.createClient({
transformer: superjson,
links: [
httpBatchLink({
httpLink({
url: 'http://localhost:3000/api/trpc',
}),
],
@@ -60,7 +56,7 @@ export default function Providers({
<api.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
<TooltipProvider delayDuration={200}>
<CookieProvider value={cookies}>{children}</CookieProvider>
{children}
<Toaster />
<ModalProvider />
</TooltipProvider>

View File

@@ -0,0 +1,33 @@
import OverviewMetrics from '@/app/(app)/[organizationId]/[projectId]/overview-metrics';
import { Logo } from '@/components/Logo';
import { getOrganizationByProjectId } from '@/server/services/organization.service';
import { getProjectById } from '@/server/services/project.service';
interface PageProps {
params: {
projectId: string;
};
}
export default async function Page({ params: { projectId } }: PageProps) {
const project = await getProjectById(projectId);
const organization = await getOrganizationByProjectId(projectId);
return (
<div className="p-4 md:p-16 bg-gradient-to-tl from-blue-950 to-blue-600">
<div className="max-w-4xl mx-auto">
<div className="flex justify-between items-end mb-4">
<div className="leading-none">
<span className="text-white mb-4">{organization?.name}</span>
<h1 className="text-white text-xl font-medium">{project?.name}</h1>
</div>
<a href="https://openpanel.dev?utm_source=openpanel.dev&utm_medium=share">
<Logo className="text-white" />
</a>
</div>
<div className="bg-white rounded-lg shadow">
<OverviewMetrics />
</div>
</div>
</div>
);
}

View File

@@ -1,9 +1,15 @@
import type { HtmlProps } from '@/types';
import { cn } from '@/utils/cn';
import { useChartContext } from './report/chart/ChartProvider';
type ColorSquareProps = HtmlProps<HTMLDivElement>;
export function ColorSquare({ children, className }: ColorSquareProps) {
const { hideID } = useChartContext();
if (hideID) {
return null;
}
return (
<div
className={cn(

View File

@@ -1,7 +1,15 @@
export function Logo() {
import { cn } from '@/utils/cn';
interface LogoProps {
className?: string;
}
export function Logo({ className }: LogoProps) {
return (
<div className="text-xl font-medium flex gap-2 items-center">
<img src="/logo.svg" className="max-h-10" />
<div
className={cn('text-xl font-medium flex gap-2 items-center', className)}
>
<img src="/logo.svg" className="max-h-8 rounded-md" />
openpanel.dev
</div>
);

View File

@@ -1,6 +1,6 @@
import { cn } from '@/utils/cn';
interface WidgetHeadProps {
export interface WidgetHeadProps {
children: React.ReactNode;
className?: string;
}
@@ -17,7 +17,7 @@ export function WidgetHead({ children, className }: WidgetHeadProps) {
);
}
interface WidgetBodyProps {
export interface WidgetBodyProps {
children: React.ReactNode;
className?: string;
}
@@ -25,7 +25,7 @@ export function WidgetBody({ children, className }: WidgetBodyProps) {
return <div className={cn('p-4', className)}>{children}</div>;
}
interface WidgetProps {
export interface WidgetProps {
children: React.ReactNode;
className?: string;
}

View File

@@ -1,4 +1,4 @@
import { toDots } from '@/utils/object';
import { toDots } from '@mixan/common';
import { Table, TableBody, TableCell, TableRow } from '../ui/table';

View File

@@ -1,9 +1,10 @@
import { useOrganizationParams } from '@/hooks/useOrganizationParams';
import { cn } from '@/utils/cn';
import { strip } from '@/utils/object';
import type { LinkProps } from 'next/link';
import Link from 'next/link';
import { strip } from '@mixan/common';
import { NavbarUserDropdown } from './NavbarUserDropdown';
function Item({

View File

@@ -0,0 +1,129 @@
'use client';
import { api } from '@/app/_trpc/client';
import { useAppParams } from '@/hooks/useAppParams';
import { cn } from '@/utils/cn';
import { X } from 'lucide-react';
import { Button } from '../ui/button';
import { Combobox } from '../ui/combobox';
import { Label } from '../ui/label';
import { useOverviewOptions } from './useOverviewOptions';
export function OverviewFiltersButtons() {
const options = useOverviewOptions();
return (
<>
{options.referrer && (
<Button
size="sm"
variant="ghost"
icon={X}
onClick={() => options.setReferrer(null)}
>
{options.referrer}
</Button>
)}
{options.device && (
<Button
size="sm"
variant="ghost"
icon={X}
onClick={() => options.setDevice(null)}
>
{options.device}
</Button>
)}
{options.page && (
<Button
size="sm"
variant="ghost"
icon={X}
onClick={() => options.setPage(null)}
>
{options.page}
</Button>
)}
{options.utmSource && (
<Button
size="sm"
variant="ghost"
icon={X}
onClick={() => options.setUtmSource(null)}
>
{options.utmSource}
</Button>
)}
{options.utmMedium && (
<Button
size="sm"
variant="ghost"
icon={X}
onClick={() => options.setUtmMedium(null)}
>
{options.utmMedium}
</Button>
)}
{options.utmCampaign && (
<Button
size="sm"
variant="ghost"
icon={X}
onClick={() => options.setUtmCampaign(null)}
>
{options.utmCampaign}
</Button>
)}
{options.utmTerm && (
<Button
size="sm"
variant="ghost"
icon={X}
onClick={() => options.setUtmTerm(null)}
>
{options.utmTerm}
</Button>
)}
{options.utmContent && (
<Button
size="sm"
variant="ghost"
icon={X}
onClick={() => options.setUtmContent(null)}
>
{options.utmContent}
</Button>
)}
{options.country && (
<Button
size="sm"
variant="ghost"
icon={X}
onClick={() => options.setCountry(null)}
>
{options.country}
</Button>
)}
{options.region && (
<Button
size="sm"
variant="ghost"
icon={X}
onClick={() => options.setRegion(null)}
>
{options.region}
</Button>
)}
{options.city && (
<Button
size="sm"
variant="ghost"
icon={X}
onClick={() => options.setCity(null)}
>
{options.city}
</Button>
)}
</>
);
}

View File

@@ -0,0 +1,121 @@
'use client';
import { api } from '@/app/_trpc/client';
import { useAppParams } from '@/hooks/useAppParams';
import { cn } from '@/utils/cn';
import { Combobox } from '../ui/combobox';
import { Label } from '../ui/label';
import { useOverviewOptions } from './useOverviewOptions';
export function OverviewFilters() {
const { projectId } = useAppParams();
const options = useOverviewOptions();
const { data: referrers } = api.chart.values.useQuery({
projectId,
property: 'referrer',
event: 'session_start',
});
const { data: devices } = api.chart.values.useQuery({
projectId,
property: 'device',
event: 'session_start',
});
const { data: pages } = api.chart.values.useQuery({
projectId,
property: 'path',
event: 'screen_view',
});
return (
<div>
<h2 className="text-xl font-medium mb-8">Overview filters</h2>
<div className="flex flex-col gap-4">
<div>
<Label className="flex justify-between">
Referrer
<button
className={cn(
'text-slate-500 transition-opacity opacity-100',
options.referrer === null && 'opacity-0'
)}
onClick={() => options.setReferrer(null)}
>
Reset
</button>
</Label>
<Combobox
className="w-full"
onChange={(value) => options.setReferrer(value)}
label="Referrer"
placeholder="Referrer"
items={
referrers?.values?.filter(Boolean)?.map((value) => ({
value,
label: value,
})) ?? []
}
value={options.referrer}
/>
</div>
<div>
<Label className="flex justify-between">
Device
<button
className={cn(
'opacity-100 text-slate-500 transition-opacity',
options.device === null && 'opacity-0'
)}
onClick={() => options.setDevice(null)}
>
Reset
</button>
</Label>
<Combobox
className="w-full"
onChange={(value) => options.setDevice(value)}
label="Device"
placeholder="Device"
items={
devices?.values?.filter(Boolean)?.map((value) => ({
value,
label: value,
})) ?? []
}
value={options.device}
/>
</div>
<div>
<Label className="flex justify-between">
Page
<button
className={cn(
'opacity-100 text-slate-500 transition-opacity',
options.page === null && 'opacity-0'
)}
onClick={() => options.setPage(null)}
>
Reset
</button>
</Label>
<Combobox
className="w-full"
onChange={(value) => options.setPage(value)}
label="Page"
placeholder="Page"
items={
pages?.values?.filter(Boolean)?.map((value) => ({
value,
label: value,
})) ?? []
}
value={options.page}
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,199 @@
'use client';
import { Chart } from '@/components/report/chart';
import { cn } from '@/utils/cn';
import { Widget, WidgetBody } from '../Widget';
import { WidgetButtons, WidgetHead } from './overview-widget';
import { useOverviewOptions } from './useOverviewOptions';
import { useOverviewWidget } from './useOverviewWidget';
export default function OverviewTopDevices() {
const { filters, interval, range, previous, setCountry, setRegion, setCity } =
useOverviewOptions();
const [widget, setWidget, widgets] = useOverviewWidget('tech', {
devices: {
title: 'Top devices',
btn: 'Devices',
chart: {
projectId: '',
events: [
{
segment: 'user',
filters,
id: 'A',
name: 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'device',
},
],
chartType: 'bar',
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
browser: {
title: 'Top browser',
btn: 'Browser',
chart: {
projectId: '',
events: [
{
segment: 'user',
filters,
id: 'A',
name: 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'browser',
},
],
chartType: 'bar',
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
browser_version: {
title: 'Top Browser Version',
btn: 'Browser Version',
chart: {
projectId: '',
events: [
{
segment: 'user',
filters,
id: 'A',
name: 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'browser_version',
},
],
chartType: 'bar',
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
os: {
title: 'Top OS',
btn: 'OS',
chart: {
projectId: '',
events: [
{
segment: 'user',
filters,
id: 'A',
name: 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'os',
},
],
chartType: 'bar',
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
os_version: {
title: 'Top OS version',
btn: 'OS Version',
chart: {
projectId: '',
events: [
{
segment: 'user',
filters,
id: 'A',
name: 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'os_version',
},
],
chartType: 'bar',
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
});
return (
<>
<Widget className="col-span-6 md:col-span-3">
<WidgetHead className="flex items-center justify-between">
<div className="title">{widget.title}</div>
<WidgetButtons>
{widgets.map((w) => (
<button
key={w.key}
onClick={() => setWidget(w.key)}
className={cn(w.key === widget.key && 'active')}
>
{w.btn}
</button>
))}
</WidgetButtons>
</WidgetHead>
<WidgetBody>
<Chart
hideID
{...widget.chart}
previous={false}
onClick={(item) => {
// switch (widget.key) {
// case 'browser':
// setWidget('browser_version');
// // setCountry(item.name);
// break;
// case 'regions':
// setWidget('cities');
// setRegion(item.name);
// break;
// case 'cities':
// setCity(item.name);
// break;
// }
}}
/>
</WidgetBody>
</Widget>
</>
);
}

View File

@@ -0,0 +1,75 @@
'use client';
import { Chart } from '@/components/report/chart';
import { cn } from '@/utils/cn';
import { Widget, WidgetBody } from '../Widget';
import { WidgetButtons, WidgetHead } from './overview-widget';
import { useOverviewOptions } from './useOverviewOptions';
import { useOverviewWidget } from './useOverviewWidget';
export default function OverviewTopEvents() {
const { filters, interval, range, previous } = useOverviewOptions();
const [widget, setWidget, widgets] = useOverviewWidget('ev', {
all: {
title: 'Top events',
btn: 'All',
chart: {
projectId: '',
events: [
{
segment: 'event',
filters: [
...filters,
{
id: 'ex_session',
name: 'name',
operator: 'isNot',
value: ['session_start', 'session_end'],
},
],
id: 'A',
name: '*',
},
],
breakdowns: [
{
id: 'A',
name: 'name',
},
],
chartType: 'bar',
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
});
return (
<>
<Widget className="col-span-6 md:col-span-3">
<WidgetHead className="flex items-center justify-between">
<div className="title">{widget.title}</div>
<WidgetButtons>
{widgets.map((w) => (
<button
key={w.key}
onClick={() => setWidget(w.key)}
className={cn(w.key === widget.key && 'active')}
>
{w.btn}
</button>
))}
</WidgetButtons>
</WidgetHead>
<WidgetBody>
<Chart hideID {...widget.chart} previous={false} />
</WidgetBody>
</Widget>
</>
);
}

View File

@@ -0,0 +1,171 @@
'use client';
import { Chart } from '@/components/report/chart';
import { cn } from '@/utils/cn';
import { Widget, WidgetBody } from '../Widget';
import { WidgetButtons, WidgetHead } from './overview-widget';
import { useOverviewOptions } from './useOverviewOptions';
import { useOverviewWidget } from './useOverviewWidget';
export default function OverviewTopGeo() {
const { filters, interval, range, previous, setCountry, setRegion, setCity } =
useOverviewOptions();
const [widget, setWidget, widgets] = useOverviewWidget('geo', {
map: {
title: 'Map',
btn: 'Map',
chart: {
projectId: '',
events: [
{
segment: 'event',
filters,
id: 'A',
name: 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'country',
},
],
chartType: 'map',
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
countries: {
title: 'Top countries',
btn: 'Countries',
chart: {
projectId: '',
events: [
{
segment: 'event',
filters,
id: 'A',
name: 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'country',
},
],
chartType: 'bar',
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
regions: {
title: 'Top regions',
btn: 'Regions',
chart: {
projectId: '',
events: [
{
segment: 'event',
filters,
id: 'A',
name: 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'region',
},
],
chartType: 'bar',
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
cities: {
title: 'Top cities',
btn: 'Cities',
chart: {
projectId: '',
events: [
{
segment: 'event',
filters,
id: 'A',
name: 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'city',
},
],
chartType: 'bar',
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
});
return (
<>
<Widget className="col-span-6 md:col-span-3">
<WidgetHead className="flex items-center justify-between">
<div className="title">{widget.title}</div>
<WidgetButtons>
{widgets.map((w) => (
<button
key={w.key}
onClick={() => setWidget(w.key)}
className={cn(w.key === widget.key && 'active')}
>
{w.btn}
</button>
))}
</WidgetButtons>
</WidgetHead>
<WidgetBody>
<Chart
hideID
{...widget.chart}
previous={false}
onClick={(item) => {
switch (widget.key) {
case 'countries':
setWidget('regions');
setCountry(item.name);
break;
case 'regions':
setWidget('cities');
setRegion(item.name);
break;
case 'cities':
setCity(item.name);
break;
}
}}
/>
</WidgetBody>
</Widget>
</>
);
}

View File

@@ -0,0 +1,130 @@
'use client';
import { Chart } from '@/components/report/chart';
import { cn } from '@/utils/cn';
import { Widget, WidgetBody } from '../Widget';
import { WidgetButtons, WidgetHead } from './overview-widget';
import { useOverviewOptions } from './useOverviewOptions';
import { useOverviewWidget } from './useOverviewWidget';
export default function OverviewTopPages() {
const { filters, interval, range, previous, setPage } = useOverviewOptions();
const [widget, setWidget, widgets] = useOverviewWidget('pages', {
top: {
title: 'Top pages',
btn: 'Top pages',
chart: {
projectId: '',
events: [
{
segment: 'event',
filters,
id: 'A',
name: 'screen_view',
},
],
breakdowns: [
{
id: 'A',
name: 'path',
},
],
chartType: 'bar',
lineType: 'monotone',
interval,
name: 'Top sources',
range,
previous,
metric: 'sum',
},
},
entries: {
title: 'Entry Pages',
btn: 'Entries',
chart: {
projectId: '',
events: [
{
segment: 'event',
filters,
id: 'A',
name: 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'path',
},
],
chartType: 'bar',
lineType: 'monotone',
interval,
name: 'Top sources',
range,
previous,
metric: 'sum',
},
},
exits: {
title: 'Exit Pages',
btn: 'Exits',
chart: {
projectId: '',
events: [
{
segment: 'event',
filters,
id: 'A',
name: 'session_end',
},
],
breakdowns: [
{
id: 'A',
name: 'path',
},
],
chartType: 'bar',
lineType: 'monotone',
interval,
name: 'Top sources',
range,
previous,
metric: 'sum',
},
},
});
return (
<>
<Widget className="col-span-6 md:col-span-3">
<WidgetHead className="flex items-center justify-between">
<div className="title">{widget.title}</div>
<WidgetButtons>
{widgets.map((w) => (
<button
key={w.key}
onClick={() => setWidget(w.key)}
className={cn(w.key === widget.key && 'active')}
>
{w.btn}
</button>
))}
</WidgetButtons>
</WidgetHead>
<WidgetBody>
<Chart
hideID
{...widget.chart}
previous={false}
onClick={(item) => {
setPage(item.name);
}}
/>
</WidgetBody>
</Widget>
</>
);
}

View File

@@ -0,0 +1,245 @@
'use client';
import { Chart } from '@/components/report/chart';
import type { IChartInput } from '@/types';
import { cn } from '@/utils/cn';
import { Widget, WidgetBody } from '../Widget';
import { WidgetButtons, WidgetHead } from './overview-widget';
import { useOverviewOptions } from './useOverviewOptions';
import { useOverviewWidget } from './useOverviewWidget';
export default function OverviewTopSources() {
const {
filters,
interval,
range,
previous,
setReferrer,
setUtmSource,
setUtmMedium,
setUtmCampaign,
setUtmTerm,
setUtmContent,
} = useOverviewOptions();
const [widget, setWidget, widgets] = useOverviewWidget('sources', {
all: {
title: 'Top sources',
btn: 'All',
chart: {
projectId: '',
events: [
{
segment: 'event',
filters: filters,
id: 'A',
name: 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'referrer',
},
],
chartType: 'bar',
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
utm_source: {
title: 'UTM Source',
btn: 'Source',
chart: {
projectId: '',
events: [
{
segment: 'event',
filters,
id: 'A',
name: 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'properties.query.utm_source',
},
],
chartType: 'bar',
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
utm_medium: {
title: 'UTM Medium',
btn: 'Medium',
chart: {
projectId: '',
events: [
{
segment: 'event',
filters,
id: 'A',
name: 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'properties.query.utm_medium',
},
],
chartType: 'bar',
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
utm_campaign: {
title: 'UTM Campaign',
btn: 'Campaign',
chart: {
projectId: '',
events: [
{
segment: 'event',
filters,
id: 'A',
name: 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'properties.query.utm_campaign',
},
],
chartType: 'bar',
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
utm_term: {
title: 'UTM Term',
btn: 'Term',
chart: {
projectId: '',
events: [
{
segment: 'event',
filters,
id: 'A',
name: 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'properties.query.utm_term',
},
],
chartType: 'bar',
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
utm_content: {
title: 'UTM Content',
btn: 'Content',
chart: {
projectId: '',
events: [
{
segment: 'event',
filters,
id: 'A',
name: 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'properties.query.utm_content',
},
],
chartType: 'bar',
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
});
return (
<>
<Widget className="col-span-6 md:col-span-3">
<WidgetHead className="flex items-center justify-between">
<div className="title">{widget.title}</div>
<WidgetButtons>
{widgets.map((w) => (
<button
key={w.key}
onClick={() => setWidget(w.key)}
className={cn(w.key === widget.key && 'active')}
>
{w.btn}
</button>
))}
</WidgetButtons>
</WidgetHead>
<WidgetBody>
<Chart
hideID
{...widget.chart}
previous={false}
onClick={(item) => {
switch (widget.key) {
case 'all':
setReferrer(item.name);
break;
case 'utm_source':
setUtmSource(item.name);
break;
case 'utm_medium':
setUtmMedium(item.name);
break;
case 'utm_campaign':
setUtmCampaign(item.name);
break;
case 'utm_term':
setUtmTerm(item.name);
break;
case 'utm_content':
setUtmContent(item.name);
break;
}
}}
/>
</WidgetBody>
</Widget>
</>
);
}

View File

@@ -0,0 +1,25 @@
import { cn } from '@/utils/cn';
import type { WidgetHeadProps } from '../Widget';
import { WidgetHead as WidgetHeadBase } from '../Widget';
export function WidgetHead({ className, ...props }: WidgetHeadProps) {
return (
<WidgetHeadBase
className={cn('flex items-center justify-between', className)}
{...props}
/>
);
}
export function WidgetButtons({ className, ...props }: WidgetHeadProps) {
return (
<div
className={cn(
'flex gap-2 [&_button]:text-xs [&_button]:opacity-50 [&_button.active]:opacity-100',
className
)}
{...props}
/>
);
}

View File

@@ -0,0 +1,234 @@
import { useMemo } from 'react';
import type { IChartInput } from '@/types';
import { getDefaultIntervalByRange, timeRanges } from '@/utils/constants';
import { mapKeys } from '@/utils/validation';
import {
parseAsBoolean,
parseAsInteger,
parseAsString,
parseAsStringEnum,
useQueryState,
} from 'nuqs';
const nuqsOptions = { history: 'push' } as const;
export function useOverviewOptions() {
const [previous, setPrevious] = useQueryState(
'name',
parseAsBoolean.withDefault(true).withOptions(nuqsOptions)
);
const [range, setRange] = useQueryState(
'range',
parseAsStringEnum(mapKeys(timeRanges))
.withDefault('7d')
.withOptions(nuqsOptions)
);
const interval = getDefaultIntervalByRange(range);
const [metric, setMetric] = useQueryState(
'metric',
parseAsInteger.withDefault(0).withOptions(nuqsOptions)
);
// Filters
const [referrer, setReferrer] = useQueryState(
'referrer',
parseAsString.withOptions(nuqsOptions)
);
const [device, setDevice] = useQueryState(
'device',
parseAsString.withOptions(nuqsOptions)
);
const [page, setPage] = useQueryState(
'page',
parseAsString.withOptions(nuqsOptions)
);
const [utmSource, setUtmSource] = useQueryState(
'utm_source',
parseAsString.withOptions(nuqsOptions)
);
const [utmMedium, setUtmMedium] = useQueryState(
'utm_medium',
parseAsString.withOptions(nuqsOptions)
);
const [utmCampaign, setUtmCampaign] = useQueryState(
'utm_campaign',
parseAsString.withOptions(nuqsOptions)
);
const [utmContent, setUtmContent] = useQueryState(
'utm_content',
parseAsString.withOptions(nuqsOptions)
);
const [utmTerm, setUtmTerm] = useQueryState(
'utm_term',
parseAsString.withOptions(nuqsOptions)
);
const [country, setCountry] = useQueryState(
'country',
parseAsString.withOptions(nuqsOptions)
);
const [region, setRegion] = useQueryState(
'region',
parseAsString.withOptions(nuqsOptions)
);
const [city, setCity] = useQueryState(
'city',
parseAsString.withOptions(nuqsOptions)
);
const filters = useMemo(() => {
const filters: IChartInput['events'][number]['filters'] = [];
if (referrer) {
filters.push({
id: 'referrer',
operator: 'is',
name: 'referrer',
value: [referrer],
});
}
if (page) {
filters.push({
id: 'path',
operator: 'is',
name: 'path',
value: [page],
});
}
if (device) {
filters.push({
id: 'device',
operator: 'is',
name: 'device',
value: [device],
});
}
if (utmSource) {
filters.push({
id: 'utm_source',
operator: 'is',
name: 'properties.query.utm_source',
value: [utmSource],
});
}
if (utmMedium) {
filters.push({
id: 'utm_medium',
operator: 'is',
name: 'properties.query.utm_medium',
value: [utmMedium],
});
}
if (utmCampaign) {
filters.push({
id: 'utm_campaign',
operator: 'is',
name: 'properties.query.utm_campaign',
value: [utmCampaign],
});
}
if (utmContent) {
filters.push({
id: 'utm_content',
operator: 'is',
name: 'properties.query.utm_content',
value: [utmContent],
});
}
if (utmTerm) {
filters.push({
id: 'utm_term',
operator: 'is',
name: 'properties.query.utm_term',
value: [utmTerm],
});
}
if (country) {
filters.push({
id: 'country',
operator: 'is',
name: 'country',
value: [country],
});
}
if (region) {
filters.push({
id: 'region',
operator: 'is',
name: 'region',
value: [region],
});
}
if (city) {
filters.push({
id: 'city',
operator: 'is',
name: 'city',
value: [city],
});
}
return filters;
}, [
referrer,
page,
device,
utmSource,
utmMedium,
utmCampaign,
utmContent,
utmTerm,
country,
region,
city,
]);
return {
previous,
setPrevious,
range,
setRange,
metric,
setMetric,
referrer,
setReferrer,
device,
setDevice,
page,
setPage,
// Computed
interval,
filters,
// UTM
utmSource,
setUtmSource,
utmMedium,
setUtmMedium,
utmCampaign,
setUtmCampaign,
utmContent,
setUtmContent,
utmTerm,
setUtmTerm,
// GEO
country,
setCountry,
region,
setRegion,
city,
setCity,
};
}

View File

@@ -0,0 +1,27 @@
import type { IChartInput } from '@/types';
import { mapKeys } from '@/utils/validation';
import { parseAsStringEnum, useQueryState } from 'nuqs';
export function useOverviewWidget<T extends string>(
key: string,
widgets: Record<T, { title: string; btn: string; chart: IChartInput }>
) {
const keys = Object.keys(widgets) as T[];
const [widget, setWidget] = useQueryState<T>(
key,
parseAsStringEnum(keys)
.withDefault(keys[0]!)
.withOptions({ history: 'push' })
);
return [
{
...widgets[widget]!,
key: widget,
},
setWidget,
mapKeys(widgets).map((key) => ({
...widgets[key],
key,
})),
] as const;
}

View File

@@ -1,13 +1,15 @@
import { useNumber } from '@/hooks/useNumerFormatter';
import { cn } from '@/utils/cn';
import { ChevronDown, ChevronUp } from 'lucide-react';
import { TrendingDownIcon, TrendingUpIcon } from 'lucide-react';
import { Badge } from '../ui/badge';
import { useChartContext } from './chart/ChartProvider';
interface PreviousDiffIndicatorProps {
diff?: number | null | undefined;
state?: string | null | undefined;
children?: React.ReactNode;
inverted?: boolean;
}
export function PreviousDiffIndicator({
@@ -15,25 +17,38 @@ export function PreviousDiffIndicator({
state,
children,
}: PreviousDiffIndicatorProps) {
const { previous } = useChartContext();
const { previous, previousIndicatorInverted } = useChartContext();
const number = useNumber();
if (diff === null || diff === undefined || previous === false) {
return children ?? null;
}
if (previousIndicatorInverted === true) {
return (
<>
<Badge
className="flex gap-1"
variant={state === 'positive' ? 'destructive' : 'success'}
>
{state === 'negative' && <TrendingUpIcon size={15} />}
{state === 'positive' && <TrendingDownIcon size={15} />}
{number.format(diff)}%
</Badge>
{children}
</>
);
}
return (
<>
<div
className={cn('flex items-center', [
state === 'positive' && 'text-emerald-500',
state === 'negative' && 'text-rose-500',
state === 'neutral' && 'text-slate-400',
])}
<Badge
className="flex gap-1"
variant={state === 'positive' ? 'success' : 'destructive'}
>
{state === 'positive' && <ChevronUp size={20} />}
{state === 'negative' && <ChevronDown size={20} />}
{state === 'positive' && <TrendingUpIcon size={15} />}
{state === 'negative' && <TrendingDownIcon size={15} />}
{number.format(diff)}%
</div>
</Badge>
{children}
</>
);

View File

@@ -1,28 +0,0 @@
import { useDispatch, useSelector } from '@/redux';
import { timeRanges } from '@/utils/constants';
import { RadioGroup, RadioGroupItem } from '../ui/radio-group';
import { changeDateRanges } from './reportSlice';
export function ReportDateRange() {
const dispatch = useDispatch();
const range = useSelector((state) => state.report.range);
return (
<RadioGroup className="overflow-auto">
{Object.values(timeRanges).map((key) => {
return (
<RadioGroupItem
key={key}
active={key === range}
onClick={() => {
dispatch(changeDateRanges(key));
}}
>
{key}
</RadioGroupItem>
);
})}
</RadioGroup>
);
}

View File

@@ -0,0 +1,20 @@
import type { IChartRange } from '@/types';
import { timeRanges } from '@/utils/constants';
import { CalendarIcon } from 'lucide-react';
import type { ExtendedComboboxProps } from '../ui/combobox';
import { Combobox } from '../ui/combobox';
export function ReportRange(props: ExtendedComboboxProps<IChartRange>) {
return (
<Combobox
icon={CalendarIcon}
placeholder={'Range'}
items={Object.values(timeRanges).map((key) => ({
label: key,
value: key,
}))}
{...props}
/>
);
}

View File

@@ -7,7 +7,6 @@ import { useAppParams } from '@/hooks/useAppParams';
import { pushModal } from '@/modals';
import { useDispatch, useSelector } from '@/redux';
import { SaveIcon } from 'lucide-react';
import { useParams } from 'next/navigation';
import { resetDirty } from './reportSlice';

View File

@@ -1,31 +1,46 @@
import { createContext, memo, useContext, useMemo } from 'react';
import type { IChartInput } from '@/types';
export interface ChartContextType {
editMode: boolean;
previous?: boolean;
export interface ChartContextType extends IChartInput {
editMode?: boolean;
hideID?: boolean;
onClick?: (item: any) => void;
}
type ChartProviderProps = {
children: React.ReactNode;
} & ChartContextType;
const ChartContext = createContext<ChartContextType>({
editMode: false,
const ChartContext = createContext<ChartContextType | null>({
events: [],
breakdowns: [],
chartType: 'linear',
lineType: 'monotone',
interval: 'day',
name: '',
range: '7d',
metric: 'sum',
previous: false,
projectId: '',
});
export function ChartProvider({
children,
editMode,
previous,
hideID,
...props
}: ChartProviderProps) {
return (
<ChartContext.Provider
value={useMemo(
() => ({
editMode,
editMode: editMode ?? false,
previous: previous ?? false,
hideID: hideID ?? false,
...props,
}),
[editMode, previous]
[editMode, previous, hideID, props]
)}
>
{children}
@@ -52,5 +67,5 @@ export function withChartProivder<ComponentProps>(
}
export function useChartContext() {
return useContext(ChartContext);
return useContext(ChartContext)!;
}

View File

@@ -0,0 +1,81 @@
import type { IChartData } from '@/app/_trpc/client';
import { ColorSquare } from '@/components/ColorSquare';
import { useNumber } from '@/hooks/useNumerFormatter';
import type { IChartMetric } from '@/types';
import { theme } from '@/utils/theme';
import AutoSizer from 'react-virtualized-auto-sizer';
import { Area, AreaChart } from 'recharts';
import { PreviousDiffIndicator } from '../PreviousDiffIndicator';
interface MetricCardProps {
serie: IChartData['series'][number];
color?: string;
metric: IChartMetric;
unit?: string;
}
export function MetricCard({
serie,
color: _color,
metric,
unit,
}: MetricCardProps) {
const color = _color || theme?.colors['chart-0'];
const number = useNumber();
return (
<div
className="group relative border border-border p-4 rounded-md bg-white overflow-hidden"
key={serie.name}
>
<div className="absolute -top-1 -left-1 -right-1 -bottom-1 z-0 opacity-10 transition-opacity duration-300 group-hover:opacity-50">
<AutoSizer>
{({ width, height }) => (
<AreaChart
width={width}
height={height / 3}
data={serie.data}
style={{ marginTop: (height / 3) * 2 }}
>
<defs>
<linearGradient id="area" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={color} stopOpacity={0.3} />
<stop offset="95%" stopColor={color} stopOpacity={0.1} />
</linearGradient>
</defs>
<Area
dataKey="count"
type="monotone"
fill="url(#area)"
fillOpacity={1}
stroke={color}
strokeWidth={2}
/>
</AreaChart>
)}
</AutoSizer>
</div>
<div className="relative">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 font-medium">
<ColorSquare>{serie.event.id}</ColorSquare>
{serie.name ?? serie.event.displayName ?? serie.event.name}
</div>
<PreviousDiffIndicator {...serie.metrics.previous[metric]} />
</div>
<div className="flex justify-between items-end mt-2">
<div className="text-2xl font-bold">
{number.format(serie.metrics[metric])}
{unit && <span className="ml-1 font-light text-xl">{unit}</span>}
</div>
{!!serie.metrics.previous[metric] && (
<div>
{number.format(serie.metrics.previous[metric]?.value)}
{unit}
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -34,7 +34,7 @@ export function ReportAreaChart({
const { editMode } = useChartContext();
const { series, setVisibleSeries } = useVisibleSeries(data);
const formatDate = useFormatDateInterval(interval);
const rechartData = useRechartDataModel(data);
const rechartData = useRechartDataModel(series);
return (
<>

View File

@@ -9,6 +9,11 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useNumber } from '@/hooks/useNumerFormatter';
import { cn } from '@/utils/cn';
import { getChartColor } from '@/utils/theme';
@@ -21,7 +26,6 @@ import {
} from '@tanstack/react-table';
import type { SortingState } from '@tanstack/react-table';
import { ChevronDown, ChevronUp } from 'lucide-react';
import { useElementSize } from 'usehooks-ts';
import { PreviousDiffIndicator } from '../PreviousDiffIndicator';
import { useChartContext } from './ChartProvider';
@@ -34,10 +38,11 @@ interface ReportBarChartProps {
}
export function ReportBarChart({ data }: ReportBarChartProps) {
const { editMode } = useChartContext();
const [ref, { width }] = useElementSize();
const { editMode, metric, unit, onClick } = useChartContext();
const [sorting, setSorting] = useState<SortingState>([]);
const maxCount = Math.max(...data.series.map((serie) => serie.metrics.sum));
const maxCount = Math.max(
...data.series.map((serie) => serie.metrics[metric])
);
const number = useNumber();
const table = useReactTable({
data: useMemo(
@@ -53,46 +58,45 @@ export function ReportBarChart({ data }: ReportBarChartProps) {
return (
<div className="flex items-center gap-2">
<ColorSquare>{info.row.original.event.id}</ColorSquare>
{info.getValue()}
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<div className="text-ellipsis overflow-hidden">
{info.getValue()}
</div>
</TooltipTrigger>
<TooltipContent>{info.getValue()}</TooltipContent>
</Tooltip>
</div>
);
},
footer: (info) => info.column.id,
size: width ? width * 0.3 : undefined,
}),
columnHelper.accessor((row) => row.metrics.sum, {
columnHelper.accessor((row) => row.metrics[metric], {
id: 'totalCount',
cell: (info) => (
<div className="text-right font-medium flex gap-2">
<div>{number.format(info.getValue())}</div>
<div className="flex gap-4 w-full">
<div className="relative flex-1">
<div
className="top-0 absolute shine h-[20px] rounded-full"
style={{
width: (info.getValue() / maxCount) * 100 + '%',
background: getChartColor(info.row.index),
}}
/>
</div>
<div className="font-bold">
{number.format(info.getValue())}
{unit}
</div>
<PreviousDiffIndicator
{...info.row.original.metrics.previous.sum}
{...info.row.original.metrics.previous[metric]}
/>
</div>
),
header: () => 'Count',
footer: (info) => info.column.id,
size: width ? width * 0.1 : undefined,
enableSorting: true,
}),
columnHelper.accessor((row) => row.metrics.sum, {
id: 'graph',
cell: (info) => (
<div
className="shine h-4 rounded [.mini_&]:h-3"
style={{
width: (info.getValue() / maxCount) * 100 + '%',
background: getChartColor(info.row.index),
}}
/>
),
header: () => 'Graph',
footer: (info) => info.column.id,
size: width ? width * 0.6 : undefined,
}),
];
}, [width]),
columnResizeMode: 'onChange',
}, [maxCount, number]),
state: {
sorting,
},
@@ -102,85 +106,64 @@ export function ReportBarChart({ data }: ReportBarChartProps) {
});
return (
<div ref={ref}>
<div className="overflow-x-auto">
<Table
{...{
className: editMode ? '' : 'mini',
style: {
width: table.getTotalSize(),
},
}}
>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
{...{
colSpan: header.colSpan,
style: {
width: header.getSize(),
},
}}
>
<div
{...{
className: cn(
'flex items-center gap-2',
header.column.getCanSort() &&
'cursor-pointer select-none'
),
onClick: header.column.getToggleSortingHandler(),
}}
>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
{{
asc: <ChevronUp className="ml-auto" size={14} />,
desc: <ChevronDown className="ml-auto" size={14} />,
}[header.column.getIsSorted() as string] ?? null}
</div>
<div
{...(editMode
? {
onMouseDown: header.getResizeHandler(),
onTouchStart: header.getResizeHandler(),
className: `resizer ${
header.column.getIsResizing() ? 'isResizing' : ''
}`,
style: {},
}
: {})}
/>
</TableHead>
))}
</TableRow>
<Table
overflow={editMode}
className={cn('table-fixed', editMode ? '' : 'mini')}
>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
{...{
colSpan: header.colSpan,
}}
>
<div
{...{
className: cn(
'flex items-center gap-2',
header.column.getCanSort() && 'cursor-pointer select-none'
),
onClick: header.column.getToggleSortingHandler(),
}}
>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
{{
asc: <ChevronUp className="ml-auto" size={14} />,
desc: <ChevronDown className="ml-auto" size={14} />,
}[header.column.getIsSorted() as string] ?? null}
</div>
</TableHead>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
{...{
style: {
width: cell.column.getSize(),
},
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
{...(onClick
? {
onClick() {
onClick(row.original);
},
className: 'cursor-pointer',
}
: {})}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableBody>
</Table>
</div>
</div>
</TableRow>
))}
</TableBody>
</Table>
);
}

View File

@@ -19,7 +19,7 @@ export function ReportChartTooltip({
active,
payload,
}: ReportLineChartTooltipProps) {
const { previous } = useChartContext();
const { previous, unit } = useChartContext();
const getLabel = useMappings();
const interval = useSelector((state) => state.report.interval);
const formatDate = useFormatDateInterval(interval);
@@ -41,7 +41,7 @@ export function ReportChartTooltip({
const hidden = sorted.slice(limit);
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 min-w-[180px]">
{visible.map((item, index) => {
// If we have a <Cell /> component, payload can be nested
const payload = item.payload.payload ?? item.payload;
@@ -57,11 +57,11 @@ export function ReportChartTooltip({
{index === 0 && data.date && (
<div className="flex justify-between gap-8">
<div>{formatDate(new Date(data.date))}</div>
{previous && data.previous?.date && (
{/* {previous && data.previous?.date && (
<div className="text-slate-400 italic">
{formatDate(new Date(data.previous.date))}
</div>
)}
)} */}
</div>
)}
<div className="flex gap-2">
@@ -74,11 +74,15 @@ export function ReportChartTooltip({
{getLabel(data.label)}
</div>
<div className="flex justify-between gap-8">
<div>{number.format(data.count)}</div>
<div>
{number.format(data.count)}
{unit}
</div>
<div className="flex gap-1">
<PreviousDiffIndicator {...data.previous}>
{!!data.previous && `(${data.previous.count})`}
{!!data.previous &&
`(${data.previous.value + (unit ? unit : '')})`}
</PreviousDiffIndicator>
</div>
</div>

View File

@@ -32,7 +32,7 @@ export function ReportHistogramChart({
const formatDate = useFormatDateInterval(interval);
const { series, setVisibleSeries } = useVisibleSeries(data);
const rechartData = useRechartDataModel(data);
const rechartData = useRechartDataModel(series);
return (
<>

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from 'react';
import React from 'react';
import type { IChartData } from '@/app/_trpc/client';
import { AutoSizer } from '@/components/AutoSizer';
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
@@ -35,7 +35,7 @@ export function ReportLineChart({
const { editMode, previous } = useChartContext();
const formatDate = useFormatDateInterval(interval);
const { series, setVisibleSeries } = useVisibleSeries(data);
const rechartData = useRechartDataModel(data);
const rechartData = useRechartDataModel(series);
return (
<>

View File

@@ -0,0 +1,40 @@
import { useMemo } from 'react';
import type { IChartData } from '@/app/_trpc/client';
import { useVisibleSeries } from '@/hooks/useVisibleSeries';
import { theme } from '@/utils/theme';
import WorldMap from 'react-svg-worldmap';
import AutoSizer from 'react-virtualized-auto-sizer';
import { useChartContext } from './ChartProvider';
interface ReportMapChartProps {
data: IChartData;
}
export function ReportMapChart({ data }: ReportMapChartProps) {
const { metric, unit } = useChartContext();
const { series } = useVisibleSeries(data, 100);
const mapData = useMemo(
() =>
series.map((s) => ({
country: s.name.toLowerCase(),
value: s.metrics[metric],
})),
[series, metric]
);
return (
<AutoSizer disableHeight>
{({ width }) => (
<WorldMap
size={width}
data={mapData}
color={theme.colors['chart-0']}
borderColor={'#103A96'}
value-suffix={unit}
/>
)}
</AutoSizer>
);
}

View File

@@ -1,25 +1,17 @@
import type { IChartData } from '@/app/_trpc/client';
import { ColorSquare } from '@/components/ColorSquare';
import { useNumber } from '@/hooks/useNumerFormatter';
import { useVisibleSeries } from '@/hooks/useVisibleSeries';
import { cn } from '@/utils/cn';
import { theme } from '@/utils/theme';
import { ChevronDown, ChevronUp, ChevronUpCircle } from 'lucide-react';
import AutoSizer from 'react-virtualized-auto-sizer';
import { Area, AreaChart } from 'recharts';
import { PreviousDiffIndicator } from '../PreviousDiffIndicator';
import { useChartContext } from './ChartProvider';
import { MetricCard } from './MetricCard';
interface ReportMetricChartProps {
data: IChartData;
}
export function ReportMetricChart({ data }: ReportMetricChartProps) {
const { editMode } = useChartContext();
const { editMode, metric, unit } = useChartContext();
const { series } = useVisibleSeries(data, editMode ? undefined : 2);
const color = theme?.colors['chart-0'];
const number = useNumber();
return (
<div
className={cn(
@@ -29,62 +21,12 @@ export function ReportMetricChart({ data }: ReportMetricChartProps) {
>
{series.map((serie) => {
return (
<div
className="relative border border-border p-4 rounded-md bg-white overflow-hidden"
<MetricCard
key={serie.name}
>
<div className="absolute -top-1 -left-1 -right-1 -bottom-1 z-0 opacity-20">
<AutoSizer>
{({ width, height }) => (
<AreaChart
width={width}
height={height / 3}
data={serie.data}
style={{ marginTop: (height / 3) * 2 }}
>
<defs>
<linearGradient id="area" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={color} stopOpacity={0.3} />
<stop
offset="95%"
stopColor={color}
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<Area
dataKey="count"
type="monotone"
fill="url(#area)"
fillOpacity={1}
stroke={color}
strokeWidth={2}
/>
</AreaChart>
)}
</AutoSizer>
</div>
<div className="relative">
<div className="flex items-center gap-2 text-lg font-medium">
<ColorSquare>{serie.event.id}</ColorSquare>
{serie.name ?? serie.event.displayName ?? serie.event.name}
</div>
<div className="flex justify-between items-end">
<div className="mt-6 font-mono text-3xl font-bold">
{number.format(serie.metrics.sum)}
</div>
{!!serie.metrics.previous.sum && (
<div className="flex flex-col items-end">
<PreviousDiffIndicator {...serie.metrics.previous.sum}>
<div className="font-mono">
{number.format(serie.metrics.previous.sum.value)}
</div>
</PreviousDiffIndicator>
</div>
)}
</div>
</div>
</div>
serie={serie}
metric={metric}
unit={unit}
/>
);
})}
</div>

View File

@@ -1,10 +1,10 @@
import { useEffect, useRef, useState } from 'react';
import type { IChartData } from '@/app/_trpc/client';
import { AutoSizer } from '@/components/AutoSizer';
import { useVisibleSeries } from '@/hooks/useVisibleSeries';
import { cn } from '@/utils/cn';
import { round } from '@/utils/math';
import { getChartColor } from '@/utils/theme';
import { truncate } from '@/utils/truncate';
import { Cell, Pie, PieChart, Tooltip } from 'recharts';
import { useChartContext } from './ChartProvider';
@@ -15,66 +15,19 @@ interface ReportPieChartProps {
data: IChartData;
}
const RADIAN = Math.PI / 180;
const renderLabel = ({
x,
y,
cx,
cy,
midAngle,
innerRadius,
outerRadius,
payload,
...props
}: any) => {
const radius = innerRadius + (outerRadius - innerRadius) * 0.5;
const xx = cx + radius * Math.cos(-midAngle * RADIAN);
const yy = cy + radius * Math.sin(-midAngle * RADIAN);
const label = payload.label;
const percent = round(payload.percent * 100, 1);
return (
<>
<text
x={xx}
y={yy}
fill="white"
textAnchor="middle"
dominantBaseline="central"
fontSize={12}
>
{percent}%
</text>
<text
x={x}
y={y}
fill="black"
textAnchor="middle"
dominantBaseline="central"
fontSize={12}
>
{label}
</text>
</>
);
};
export function ReportPieChart({ data }: ReportPieChartProps) {
const { editMode } = useChartContext();
const { series, setVisibleSeries } = useVisibleSeries(data);
const sum = series.reduce((acc, serie) => acc + serie.metrics.sum, 0);
// Get max 10 series and than combine others into one
const pieData = series.map((serie) => {
return {
id: serie.name,
color: getChartColor(serie.index),
index: serie.index,
label: serie.name,
count: serie.metrics.sum,
percent: serie.metrics.sum / sum,
};
});
const pieData = series.map((serie) => ({
id: serie.name,
color: getChartColor(serie.index),
index: serie.index,
label: serie.name,
count: serie.metrics.sum,
percent: serie.metrics.sum / sum,
}));
return (
<>
@@ -127,3 +80,58 @@ export function ReportPieChart({ data }: ReportPieChartProps) {
</>
);
}
const renderLabel = ({
cx,
cy,
midAngle,
innerRadius,
outerRadius,
fill,
payload,
}: {
cx: number;
cy: number;
midAngle: number;
innerRadius: number;
outerRadius: number;
fill: string;
payload: { label: string; percent: number };
}) => {
const RADIAN = Math.PI / 180;
const radius = 25 + innerRadius + (outerRadius - innerRadius);
const radiusProcent = innerRadius + (outerRadius - innerRadius) * 0.5;
const xProcent = cx + radiusProcent * Math.cos(-midAngle * RADIAN);
const yProcent = cy + radiusProcent * Math.sin(-midAngle * RADIAN);
const x = cx + radius * Math.cos(-midAngle * RADIAN);
const y = cy + radius * Math.sin(-midAngle * RADIAN);
const label = payload.label;
const percent = round(payload.percent * 100, 1);
return (
<>
<text
x={xProcent}
y={yProcent}
fill="white"
textAnchor="middle"
dominantBaseline="central"
fontSize={10}
fontWeight={700}
pointerEvents={'none'}
>
{percent}%
</text>
<text
x={x}
y={y}
fill={fill}
textAnchor={x > cx ? 'start' : 'end'}
dominantBaseline="central"
fontSize={10}
>
{truncate(label, 20)}
</text>
</>
);
};

View File

@@ -1,6 +1,7 @@
'use client';
import { memo } from 'react';
import type { RouterOutputs } from '@/app/_trpc/client';
import { api } from '@/app/_trpc/client';
import { useAppParams } from '@/hooks/useAppParams';
import type { IChartInput } from '@/types';
@@ -11,10 +12,13 @@ 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 = memo(
withChartProivder(function Chart({
@@ -26,18 +30,23 @@ export const Chart = memo(
range,
lineType,
previous,
formula,
unit,
metric,
initialData,
}: ReportChartProps) {
const params = useAppParams();
const hasEmptyFilters = events.some((event) =>
event.filters.some((filter) => filter.value.length === 0)
);
const enabled = events.length > 0 && !hasEmptyFilters;
const chart = api.chart.chart.useQuery(
{
interval,
chartType,
// dont send lineType since it does not need to be sent
lineType: 'monotone',
interval,
chartType,
events,
breakdowns,
name,
@@ -46,10 +55,14 @@ export const Chart = memo(
endDate: null,
projectId: params.projectId,
previous,
formula,
unit,
metric,
},
{
keepPreviousData: false,
keepPreviousData: true,
enabled,
initialData,
}
);
@@ -66,10 +79,10 @@ export const Chart = memo(
);
}
if (chart.isFetching) {
if (chart.isLoading) {
return (
<ChartAnimationContainer>
<ChartAnimation name="airplane" className="max-w-sm w-fill mx-auto" />
{/* <ChartAnimation name="airplane" className="max-w-sm w-fill mx-auto" /> */}
<p className="text-center font-medium">Loading...</p>
</ChartAnimationContainer>
);
@@ -99,6 +112,10 @@ export const Chart = memo(
);
}
if (chartType === 'map') {
return <ReportMapChart data={chart.data} />;
}
if (chartType === 'histogram') {
return <ReportHistogramChart interval={interval} data={chart.data} />;
}

View File

@@ -27,6 +27,8 @@ type InitialState = IChartInput & {
const initialState: InitialState = {
ready: false,
dirty: false,
// TODO: remove this
projectId: '',
name: 'Untitled',
chartType: 'linear',
lineType: 'monotone',
@@ -37,6 +39,9 @@ const initialState: InitialState = {
startDate: null,
endDate: null,
previous: false,
formula: undefined,
unit: undefined,
metric: 'sum',
};
export const reportSlice = createSlice({
@@ -100,6 +105,12 @@ export const reportSlice = createSlice({
});
},
// Previous
changePrevious: (state, action: PayloadAction<boolean>) => {
state.dirty = true;
state.previous = action.payload;
},
// Breakdowns
addBreakdown: (
state,
@@ -181,6 +192,12 @@ export const reportSlice = createSlice({
state.range = action.payload;
state.interval = getDefaultIntervalByRange(action.payload);
},
// Formula
changeFormula: (state, action: PayloadAction<string>) => {
state.dirty = true;
state.formula = action.payload;
},
},
});
@@ -201,6 +218,8 @@ export const {
changeChartType,
changeLineType,
resetDirty,
changeFormula,
changePrevious,
} = reportSlice.actions;
export default reportSlice.reducer;

View File

@@ -0,0 +1,62 @@
import { api } from '@/app/_trpc/client';
import { Combobox } from '@/components/ui/combobox';
import { useAppParams } from '@/hooks/useAppParams';
import { useDispatch } from '@/redux';
import type { IChartEvent } from '@/types';
import { cn } from '@/utils/cn';
import { DatabaseIcon, FilterIcon } from 'lucide-react';
import { changeEvent } from '../reportSlice';
interface EventPropertiesComboboxProps {
event: IChartEvent;
}
export function EventPropertiesCombobox({
event,
}: EventPropertiesComboboxProps) {
const dispatch = useDispatch();
const { projectId } = useAppParams();
const query = api.chart.properties.useQuery(
{
event: event.name,
projectId,
},
{
enabled: !!event.name,
}
);
const properties = (query.data ?? []).map((item) => ({
label: item,
value: item,
}));
return (
<Combobox
searchable
placeholder="Select a filter"
value=""
items={properties}
onChange={(value) => {
dispatch(
changeEvent({
...event,
property: value,
})
);
}}
>
<button
className={cn(
'flex items-center gap-1 rounded-md border border-border p-1 px-2 font-medium leading-none text-xs',
!event.property && 'border-destructive text-destructive'
)}
>
<DatabaseIcon size={12} />{' '}
{event.property ? `Property: ${event.property}` : 'Select property'}
</button>
</Combobox>
);
}

View File

@@ -34,7 +34,7 @@ const labels = [
];
export interface ReportEventMoreProps {
onClick: (action: 'createFilter' | 'remove') => void;
onClick: (action: 'remove') => void;
}
export function ReportEventMore({ onClick }: ReportEventMoreProps) {
@@ -49,10 +49,6 @@ export function ReportEventMore({ onClick }: ReportEventMoreProps) {
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[200px]">
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => onClick('createFilter')}>
<Filter className="mr-2 h-4 w-4" />
Add filter
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-red-600"

View File

@@ -1,25 +1,31 @@
'use client';
import { useState } from 'react';
import { api } from '@/app/_trpc/client';
import { ColorSquare } from '@/components/ColorSquare';
import { Dropdown } from '@/components/Dropdown';
import { Checkbox } from '@/components/ui/checkbox';
import { Combobox } from '@/components/ui/combobox';
import { Input } from '@/components/ui/input';
import { useAppParams } from '@/hooks/useAppParams';
import { useDebounceFn } from '@/hooks/useDebounceFn';
import { useDispatch, useSelector } from '@/redux';
import type { IChartEvent } from '@/types';
import { Filter, GanttChart, Users } from 'lucide-react';
import { useParams } from 'next/navigation';
import { GanttChart, Users } from 'lucide-react';
import { addEvent, changeEvent, removeEvent } from '../reportSlice';
import { ReportEventFilters } from './ReportEventFilters';
import {
addEvent,
changeEvent,
changePrevious,
removeEvent,
} from '../reportSlice';
import { EventPropertiesCombobox } from './EventPropertiesCombobox';
import { FiltersCombobox } from './filters/FiltersCombobox';
import { FiltersList } from './filters/FiltersList';
import { ReportEventMore } from './ReportEventMore';
import type { ReportEventMoreProps } from './ReportEventMore';
export function ReportEvents() {
const [isCreating, setIsCreating] = useState(false);
const previous = useSelector((state) => state.report.previous);
const selectedEvents = useSelector((state) => state.report.events);
const dispatch = useDispatch();
const params = useAppParams();
@@ -37,9 +43,6 @@ export function ReportEvents() {
const handleMore = (event: IChartEvent) => {
const callback: ReportEventMoreProps['onClick'] = (action) => {
switch (action) {
case 'createFilter': {
return setIsCreating(true);
}
case 'remove': {
return dispatch(removeEvent(event));
}
@@ -111,12 +114,20 @@ export function ReportEvents() {
},
{
value: 'user_average',
label: 'Unique users (average)',
label: 'Average event per user',
},
{
value: 'one_event_per_user',
label: 'One event per user',
},
{
value: 'property_sum',
label: 'Sum of property',
},
{
value: 'property_average',
label: 'Average of property',
},
]}
label="Segment"
>
@@ -127,12 +138,20 @@ export function ReportEvents() {
</>
) : event.segment === 'user_average' ? (
<>
<Users size={12} /> Unique users (average)
<Users size={12} /> Average event per user
</>
) : event.segment === 'one_event_per_user' ? (
<>
<Users size={12} /> One event per user
</>
) : event.segment === 'property_sum' ? (
<>
<Users size={12} /> Sum of property
</>
) : event.segment === 'property_average' ? (
<>
<Users size={12} /> Average of property
</>
) : (
<>
<GanttChart size={12} /> All events
@@ -140,18 +159,17 @@ export function ReportEvents() {
)}
</button>
</Dropdown>
<button
onClick={() => {
handleMore(event)('createFilter');
}}
className="flex items-center gap-1 rounded-md border border-border p-1 px-2 font-medium leading-none text-xs"
>
<Filter size={12} /> Filter
</button>
{/* */}
<FiltersCombobox event={event} />
{(event.segment === 'property_average' ||
event.segment === 'property_sum') && (
<EventPropertiesCombobox event={event} />
)}
</div>
{/* Filters */}
<ReportEventFilters {...{ isCreating, setIsCreating, event }} />
<FiltersList event={event} />
</div>
);
})}
@@ -172,6 +190,17 @@ export function ReportEvents() {
placeholder="Select event"
/>
</div>
<label
className="flex items-center gap-2 cursor-pointer select-none text-sm font-medium mt-4"
htmlFor="previous"
>
<Checkbox
id="previous"
checked={previous}
onCheckedChange={(val) => dispatch(changePrevious(!!val))}
/>
Show previous / Compare
</label>
</div>
);
}

View File

@@ -0,0 +1,26 @@
'use client';
import { Input } from '@/components/ui/input';
import { useDispatch, useSelector } from '@/redux';
import { changeFormula } from '../reportSlice';
export function ReportForumula() {
const forumula = useSelector((state) => state.report.formula);
const dispatch = useDispatch();
return (
<div>
<h3 className="mb-2 font-medium">Forumula</h3>
<div className="flex flex-col gap-4">
<Input
placeholder="eg: A/B"
value={forumula}
onChange={(event) => {
dispatch(changeFormula(event.target.value));
}}
/>
</div>
</div>
);
}

View File

@@ -3,11 +3,13 @@ import { SheetClose } from '@/components/ui/sheet';
import { ReportBreakdowns } from './ReportBreakdowns';
import { ReportEvents } from './ReportEvents';
import { ReportForumula } from './ReportForumula';
export function ReportSidebar() {
return (
<div className="flex flex-col gap-8 pb-12">
<ReportEvents />
<ReportForumula />
<ReportBreakdowns />
<div className="absolute bottom-0 left-0 right-0 p-6 bg-gradient-to-t from-white/100 to-white/0">
<SheetClose asChild>

View File

@@ -1,18 +1,8 @@
import type { Dispatch } from 'react';
import { api } from '@/app/_trpc/client';
import { ColorSquare } from '@/components/ColorSquare';
import { Dropdown } from '@/components/Dropdown';
import { Button } from '@/components/ui/button';
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from '@/components/ui/command';
import { RenderDots } from '@/components/ui/RenderDots';
import { useAppParams } from '@/hooks/useAppParams';
import { useMappings } from '@/hooks/useMappings';
@@ -23,93 +13,24 @@ import type {
IChartEventFilterValue,
} from '@/types';
import { operators } from '@/utils/constants';
import { CreditCard, SlidersHorizontal, Trash } from 'lucide-react';
import { SlidersHorizontal, Trash } from 'lucide-react';
import { useParams } from 'next/navigation';
import { changeEvent } from '../reportSlice';
interface ReportEventFiltersProps {
event: IChartEvent;
isCreating: boolean;
setIsCreating: Dispatch<boolean>;
}
export function ReportEventFilters({
event,
isCreating,
setIsCreating,
}: ReportEventFiltersProps) {
const params = useAppParams();
const dispatch = useDispatch();
const propertiesQuery = api.chart.properties.useQuery(
{
event: event.name,
projectId: params.projectId,
},
{
enabled: !!event.name,
}
);
return (
<div>
<div className="flex flex-col divide-y bg-slate-50">
{event.filters.map((filter) => {
return <Filter key={filter.name} filter={filter} event={event} />;
})}
<CommandDialog open={isCreating} onOpenChange={setIsCreating} modal>
<CommandInput placeholder="Search properties" />
<CommandList>
<CommandEmpty>Such emptyness 🤨</CommandEmpty>
<CommandGroup heading="Properties">
{propertiesQuery.data?.map((item) => (
<CommandItem
key={item}
onSelect={() => {
setIsCreating(false);
dispatch(
changeEvent({
...event,
filters: [
...event.filters,
{
id: (event.filters.length + 1).toString(),
name: item,
operator: 'is',
value: [],
},
],
})
);
}}
>
<CreditCard className="mr-2 h-4 w-4" />
<RenderDots className="text-sm">{item}</RenderDots>
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
</CommandList>
</CommandDialog>
</div>
</div>
);
}
import { changeEvent } from '../../reportSlice';
interface FilterProps {
event: IChartEvent;
filter: IChartEvent['filters'][number];
}
function Filter({ filter, event }: FilterProps) {
const params = useParams<{ organizationId: string; projectId: string }>();
export function FilterItem({ filter, event }: FilterProps) {
const { projectId } = useAppParams();
const getLabel = useMappings();
const dispatch = useDispatch();
const potentialValues = api.chart.values.useQuery({
event: event.name,
property: filter.name,
projectId: params?.projectId!,
projectId,
});
const valuesCombobox =

View File

@@ -0,0 +1,61 @@
import { api } from '@/app/_trpc/client';
import { Combobox } from '@/components/ui/combobox';
import { useAppParams } from '@/hooks/useAppParams';
import { useDispatch } from '@/redux';
import type { IChartEvent } from '@/types';
import { FilterIcon } from 'lucide-react';
import { changeEvent } from '../../reportSlice';
interface FiltersComboboxProps {
event: IChartEvent;
}
export function FiltersCombobox({ event }: FiltersComboboxProps) {
const dispatch = useDispatch();
const { projectId } = useAppParams();
const query = api.chart.properties.useQuery(
{
event: event.name,
projectId,
},
{
enabled: !!event.name,
}
);
const properties = (query.data ?? []).map((item) => ({
label: item,
value: item,
}));
return (
<Combobox
searchable
placeholder="Select a filter"
value=""
items={properties}
onChange={(value) => {
dispatch(
changeEvent({
...event,
filters: [
...event.filters,
{
id: (event.filters.length + 1).toString(),
name: value,
operator: 'is',
value: [],
},
],
})
);
}}
>
<button className="flex items-center gap-1 rounded-md border border-border p-1 px-2 font-medium leading-none text-xs">
<FilterIcon size={12} /> Add filter
</button>
</Combobox>
);
}

View File

@@ -0,0 +1,19 @@
import type { IChartEvent } from '@/types';
import { FilterItem } from './FilterItem';
interface ReportEventFiltersProps {
event: IChartEvent;
}
export function FiltersList({ event }: ReportEventFiltersProps) {
return (
<div>
<div className="flex flex-col divide-y bg-slate-50">
{event.filters.map((filter) => {
return <FilterItem key={filter.name} filter={filter} event={event} />;
})}
</div>
</div>
);
}

View File

@@ -6,7 +6,7 @@ import { cva } from 'class-variance-authority';
import type { VariantProps } from 'class-variance-authority';
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
'inline-flex items-center rounded-full border px-1.5 h-[20px] text-[10px] font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
@@ -16,6 +16,8 @@ const badgeVariants = cva(
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive:
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
success:
'border-transparent bg-emerald-500 text-emerald-100 hover:bg-emerald-500/80',
outline: 'text-foreground',
},
},

View File

@@ -38,7 +38,7 @@ const buttonVariants = cva(
}
);
interface ButtonProps
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;

View File

@@ -1,6 +1,7 @@
'use client';
import * as React from 'react';
import type { ButtonProps } from '@/components/ui/button';
import { Button } from '@/components/ui/button';
import {
Command,
@@ -15,9 +16,10 @@ import {
PopoverTrigger,
} from '@/components/ui/popover';
import { cn } from '@/utils/cn';
import type { LucideIcon } from 'lucide-react';
import { Check, ChevronsUpDown } from 'lucide-react';
interface ComboboxProps<T> {
export interface ComboboxProps<T> {
placeholder: string;
items: {
value: T;
@@ -30,8 +32,18 @@ interface ComboboxProps<T> {
onCreate?: (value: T) => void;
className?: string;
searchable?: boolean;
icon?: LucideIcon;
size?: ButtonProps['size'];
label?: string;
}
export type ExtendedComboboxProps<T> = Omit<
ComboboxProps<T>,
'items' | 'placeholder'
> & {
placeholder?: string;
};
export function Combobox<T extends string>({
placeholder,
items,
@@ -41,6 +53,9 @@ export function Combobox<T extends string>({
onCreate,
className,
searchable,
icon: Icon,
size,
label,
}: ComboboxProps<T>) {
const [open, setOpen] = React.useState(false);
const [search, setSearch] = React.useState('');
@@ -55,11 +70,13 @@ export function Combobox<T extends string>({
<PopoverTrigger asChild>
{children ?? (
<Button
size={size}
variant="outline"
role="combobox"
aria-expanded={open}
className={cn('justify-between min-w-[150px]', className)}
className={cn('justify-between', className)}
>
{Icon ? <Icon className="mr-2" size={16} /> : null}
<span className="overflow-hidden text-ellipsis whitespace-nowrap">
{value ? find(value)?.label ?? 'No match' : placeholder}
</span>
@@ -67,7 +84,7 @@ export function Combobox<T extends string>({
</Button>
)}
</PopoverTrigger>
<PopoverContent className="w-full min-w-0 p-0" align="start">
<PopoverContent className="w-full max-w-md p-0" align="start">
<Command>
{searchable === true && (
<CommandInput
@@ -80,7 +97,7 @@ export function Combobox<T extends string>({
<CommandEmpty className="p-2">
<Button
onClick={() => {
onCreate(search);
onCreate(search as T);
setSearch('');
setOpen(false);
}}
@@ -99,7 +116,7 @@ export function Combobox<T extends string>({
value={item.value}
onSelect={(currentValue) => {
const value = find(currentValue)?.value ?? currentValue;
onChange(value);
onChange(value as T);
setOpen(false);
}}
{...(item.disabled && { disabled: true })}

View File

@@ -32,7 +32,7 @@ const SheetOverlay = React.forwardRef<
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva(
'fixed z-50 gap-4 bg-background shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
'fixed z-50 gap-4 bg-background shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-150 data-[state=open]:duration-150',
{
variants: {
side: {

View File

@@ -5,10 +5,13 @@ import { cn } from '@/utils/cn';
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement> & { wrapper?: boolean }
>(({ className, wrapper, ...props }, ref) => (
React.HTMLAttributes<HTMLTableElement> & {
wrapper?: boolean;
overflow?: boolean;
}
>(({ className, wrapper, overflow = true, ...props }, ref) => (
<div className={cn('border border-border rounded-md bg-white', className)}>
<div className="relative w-full overflow-auto ">
<div className={cn('relative w-full', overflow && 'overflow-auto')}>
<table
ref={ref}
className={cn(

View File

@@ -1,8 +1,13 @@
import { isNil } from 'ramda';
export function useNumber() {
const locale = 'en-gb';
return {
format: (value: number) => {
format: (value: number | null | undefined) => {
if (isNil(value)) {
return 'N/A';
}
return new Intl.NumberFormat(locale, {
maximumSignificantDigits: 20,
}).format(value);

View File

@@ -4,20 +4,20 @@ import { getChartColor } from '@/utils/theme';
export type IRechartPayloadItem = IChartSerieDataItem & { color: string };
export function useRechartDataModel(data: IChartData) {
export function useRechartDataModel(series: IChartData['series']) {
return useMemo(() => {
return (
data.series[0]?.data.map(({ date }) => {
series[0]?.data.map(({ date }) => {
return {
date,
...data.series.reduce((acc, serie, idx) => {
...series.reduce((acc, serie, idx) => {
return {
...acc,
...serie.data.reduce(
(acc2, item) => {
if (item.date === date) {
if (item.previous) {
acc2[`${idx}:prev:count`] = item.previous.count;
acc2[`${idx}:prev:count`] = item.previous.value;
}
acc2[`${idx}:count`] = item.count;
acc2[`${idx}:payload`] = {
@@ -34,5 +34,5 @@ export function useRechartDataModel(data: IChartData) {
};
}) ?? []
);
}, [data]);
}, [series]);
}

View File

@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import type { IChartData } from '@/app/_trpc/client';
export type IVisibleSeries = ReturnType<typeof useVisibleSeries>['series'];
export function useVisibleSeries(data: IChartData, limit?: number | undefined) {
const max = limit ?? 5;
const [visibleSeries, setVisibleSeries] = useState<string[]>([]);

View File

@@ -10,7 +10,7 @@ import { toast } from '@/components/ui/use-toast';
import { useAppParams } from '@/hooks/useAppParams';
import type { IChartInput } from '@/types';
import { zodResolver } from '@hookform/resolvers/zod';
import { useParams, useRouter, useSearchParams } from 'next/navigation';
import { useRouter, useSearchParams } from 'next/navigation';
import { Controller, useForm } from 'react-hook-form';
import { z } from 'zod';

View File

@@ -0,0 +1,264 @@
import { getChartSql } from '@/server/services/chart.service';
import type {
IChartEvent,
IChartInput,
IGetChartDataInput,
IInterval,
} from '@/types';
import { alphabetIds } from '@/utils/constants';
import { average, max, min, round, sum } from '@/utils/math';
import * as mathjs from 'mathjs';
import { sort } from 'ramda';
import { chQuery } from '@mixan/db';
export type GetChartDataResult = Awaited<ReturnType<typeof getChartData>>;
export interface ResultItem {
label: string | null;
count: number | null;
date: string;
}
function getEventLegend(event: IChartEvent) {
return event.displayName ?? `${event.name} (${event.id})`;
}
function fillEmptySpotsInTimeline(
items: ResultItem[],
interval: IInterval,
startDate: string,
endDate: string
) {
const result = [];
const clonedStartDate = new Date(startDate);
const clonedEndDate = new Date(endDate);
const today = new Date();
if (interval === 'minute') {
clonedStartDate.setUTCSeconds(0, 0);
clonedEndDate.setUTCMinutes(clonedEndDate.getUTCMinutes() + 1, 0, 0);
} else if (interval === 'hour') {
clonedStartDate.setUTCMinutes(0, 0, 0);
clonedEndDate.setUTCMinutes(0, 0, 0);
} else {
clonedStartDate.setUTCHours(0, 0, 0, 0);
clonedEndDate.setUTCHours(0, 0, 0, 0);
}
if (interval === 'month') {
clonedStartDate.setUTCDate(1);
clonedEndDate.setUTCDate(1);
}
// Force if interval is month and the start date is the same month as today
const shouldForce = () =>
interval === 'month' &&
clonedStartDate.getUTCFullYear() === today.getUTCFullYear() &&
clonedStartDate.getUTCMonth() === today.getUTCMonth();
let prev = undefined;
while (
shouldForce() ||
clonedStartDate.getTime() <= clonedEndDate.getTime()
) {
if (prev === clonedStartDate.getTime()) {
console.log('GET OUT NOW!');
break;
}
prev = clonedStartDate.getTime();
const getYear = (date: Date) => date.getUTCFullYear();
const getMonth = (date: Date) => date.getUTCMonth();
const getDay = (date: Date) => date.getUTCDate();
const getHour = (date: Date) => date.getUTCHours();
const getMinute = (date: Date) => date.getUTCMinutes();
const item = items.find((item) => {
const date = new Date(item.date);
if (interval === 'month') {
return (
getYear(date) === getYear(clonedStartDate) &&
getMonth(date) === getMonth(clonedStartDate)
);
}
if (interval === 'day') {
return (
getYear(date) === getYear(clonedStartDate) &&
getMonth(date) === getMonth(clonedStartDate) &&
getDay(date) === getDay(clonedStartDate)
);
}
if (interval === 'hour') {
return (
getYear(date) === getYear(clonedStartDate) &&
getMonth(date) === getMonth(clonedStartDate) &&
getDay(date) === getDay(clonedStartDate) &&
getHour(date) === getHour(clonedStartDate)
);
}
if (interval === 'minute') {
return (
getYear(date) === getYear(clonedStartDate) &&
getMonth(date) === getMonth(clonedStartDate) &&
getDay(date) === getDay(clonedStartDate) &&
getHour(date) === getHour(clonedStartDate) &&
getMinute(date) === getMinute(clonedStartDate)
);
}
return false;
});
if (item) {
result.push({
...item,
date: clonedStartDate.toISOString(),
});
} else {
result.push({
date: clonedStartDate.toISOString(),
count: 0,
label: null,
});
}
switch (interval) {
case 'day': {
clonedStartDate.setUTCDate(clonedStartDate.getUTCDate() + 1);
break;
}
case 'hour': {
clonedStartDate.setUTCHours(clonedStartDate.getUTCHours() + 1);
break;
}
case 'minute': {
clonedStartDate.setUTCMinutes(clonedStartDate.getUTCMinutes() + 1);
break;
}
case 'month': {
clonedStartDate.setUTCMonth(clonedStartDate.getUTCMonth() + 1);
break;
}
}
}
return sort(function (a, b) {
return new Date(a.date).getTime() - new Date(b.date).getTime();
}, result);
}
export function withFormula(
{ formula }: IChartInput,
series: GetChartDataResult
) {
if (!formula) {
return series;
}
if (!series) {
return series;
}
if (!series[0]) {
return series;
}
if (!series[0].data) {
return series;
}
return [
{
...series[0],
data: series[0].data.map((item, dIndex) => {
const scope = series.reduce((acc, item) => {
return {
...acc,
[item.event.id]: item.data[dIndex]?.count ?? 0,
};
}, {});
const count = mathjs.parse(formula).compile().evaluate(scope) as number;
return {
...item,
count:
Number.isNaN(count) || !Number.isFinite(count)
? null
: round(count, 2),
};
}),
},
];
}
export async function getChartData(payload: IGetChartDataInput) {
let result = await chQuery<ResultItem>(getChartSql(payload));
if (result.length === 0 && payload.breakdowns.length > 0) {
result = await chQuery<ResultItem>(
getChartSql({
...payload,
breakdowns: [],
})
);
}
// group by sql label
const series = result.reduce(
(acc, item) => {
// item.label can be null when using breakdowns on a property
// that doesn't exist on all events
const label = item.label?.trim() || '(not set)';
if (label) {
if (acc[label]) {
acc[label]?.push(item);
} else {
acc[label] = [item];
}
}
return {
...acc,
};
},
{} as Record<string, ResultItem[]>
);
return Object.keys(series).map((key) => {
// If we have breakdowns, we want to use the breakdown key as the legend
// But only if it successfully broke it down, otherwise we use the getEventLabel
const serieName =
payload.breakdowns.length && !alphabetIds.includes(key as 'A')
? key
: getEventLegend(payload.event);
const data =
payload.chartType === 'area' ||
payload.chartType === 'linear' ||
payload.chartType === 'histogram' ||
payload.chartType === 'metric' ||
payload.chartType === 'pie' ||
payload.chartType === 'bar'
? fillEmptySpotsInTimeline(
series[key] ?? [],
payload.interval,
payload.startDate,
payload.endDate
).map((item) => {
return {
label: serieName,
count: item.count ? round(item.count) : null,
date: new Date(item.date).toISOString(),
};
})
: (series[key] ?? []).map((item) => ({
label: item.label,
count: item.count ? round(item.count) : null,
date: new Date(item.date).toISOString(),
}));
return {
name: serieName,
event: payload.event,
data,
};
});
}

View File

@@ -1,31 +1,56 @@
import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
import * as cache from '@/server/cache';
import { getChartSql } from '@/server/chart-sql/getChartSql';
import { isJsonPath, selectJsonPath } from '@/server/chart-sql/helpers';
import { db } from '@/server/db';
import { getUniqueEvents } from '@/server/services/event.service';
import type {
IChartEvent,
IChartRange,
IGetChartDataInput,
IInterval,
} from '@/types';
import { alphabetIds } from '@/utils/constants';
import type { IChartEvent, IChartInput, IChartRange } from '@/types';
import { getDaysOldDate } from '@/utils/date';
import { average, round, sum } from '@/utils/math';
import { toDots } from '@/utils/object';
import { zChartInputWithDates } from '@/utils/validation';
import { pipe, sort, uniq } from 'ramda';
import { average, max, min, round, sum } from '@/utils/math';
import { zChartInput } from '@/utils/validation';
import { flatten, map, pathOr, pipe, prop, sort, uniq } from 'ramda';
import { z } from 'zod';
import { chQuery } from '@mixan/db';
import { getChartData, withFormula } from './chart.formula';
type PreviousValue = {
value: number;
diff: number | null;
state: 'positive' | 'negative' | 'neutral';
} | null;
interface Metrics {
sum: number;
average: number;
min: number;
max: number;
previous: {
sum: PreviousValue;
average: PreviousValue;
min: PreviousValue;
max: PreviousValue;
};
}
interface FinalChart {
events: IChartInput['events'];
series: {
name: string;
event: IChartEvent;
metrics: Metrics;
data: {
date: string;
count: number;
label: string | null;
previous: PreviousValue;
}[];
}[];
metrics: Metrics;
}
export const chartRouter = createTRPCRouter({
events: protectedProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ input: { projectId } }) => {
const events = await cache.getOr(
`events_${projectId}`,
1000 * 60 * 60 * 24,
() => getUniqueEvents({ projectId: projectId })
const events = await chQuery<{ name: string }>(
`SELECT DISTINCT name FROM events WHERE project_id = '${projectId}'`
);
return [
@@ -39,32 +64,36 @@ export const chartRouter = createTRPCRouter({
properties: protectedProcedure
.input(z.object({ event: z.string().optional(), projectId: z.string() }))
.query(async ({ input: { projectId, event } }) => {
const events = await cache.getOr(
`events_${projectId}_${event ?? 'all'}`,
1000 * 60 * 60,
() =>
db.event.findMany({
take: 500,
distinct: 'name',
where: {
project_id: projectId,
...(event
? {
name: event,
}
: {}),
},
})
const events = await chQuery<{ keys: string[] }>(
`SELECT distinct mapKeys(properties) as keys from events where ${
event && event !== '*' ? `name = '${event}' AND ` : ''
} project_id = '${projectId}';`
);
const properties = events
.reduce((acc, event) => {
const properties = event as Record<string, unknown>;
const dotNotation = toDots(properties);
return [...acc, ...Object.keys(dotNotation)];
}, [] as string[])
.flatMap((event) => event.keys)
.map((item) => item.replace(/\.([0-9]+)\./g, '.*.'))
.map((item) => item.replace(/\.([0-9]+)/g, '[*]'));
.map((item) => item.replace(/\.([0-9]+)/g, '[*]'))
.map((item) => `properties.${item}`);
properties.push(
'name',
'path',
'referrer',
'referrer_name',
'duration',
'created_at',
'country',
'city',
'region',
'os',
'os_version',
'browser',
'browser_version',
'device',
'brand',
'model'
);
return pipe(
sort<string>((a, b) => a.length - b.length),
@@ -81,162 +110,226 @@ export const chartRouter = createTRPCRouter({
})
)
.query(async ({ input: { event, property, projectId } }) => {
const intervalInDays = 180;
if (isJsonPath(property)) {
const events = await db.$queryRawUnsafe<{ value: string }[]>(
`SELECT ${selectJsonPath(
property
)} AS value from events WHERE project_id = '${projectId}' AND name = '${event}' AND "createdAt" >= NOW() - INTERVAL '${intervalInDays} days'`
);
const sql = property.startsWith('properties.')
? `SELECT distinct mapValues(mapExtractKeyLike(properties, '${property
.replace(/^properties\./, '')
.replace(
'.*.',
'.%.'
)}')) as values from events where name = '${event}' AND project_id = '${projectId}';`
: `SELECT ${property} as values from events where name = '${event}' AND project_id = '${projectId}';`;
const events = await chQuery<{ values: string[] }>(sql);
const values = pipe(
(data: typeof events) => map(prop('values'), data),
flatten,
uniq,
sort((a, b) => a.length - b.length)
)(events);
return {
values,
};
}),
chart: protectedProcedure.input(zChartInput).query(async ({ input }) => {
const current = getDatesFromRange(input.range);
let diff = 0;
switch (input.range) {
case '30min': {
diff = 1000 * 60 * 30;
break;
}
case '1h': {
diff = 1000 * 60 * 60;
break;
}
case '24h':
case 'today': {
diff = 1000 * 60 * 60 * 24;
break;
}
case '7d': {
diff = 1000 * 60 * 60 * 24 * 7;
break;
}
case '14d': {
diff = 1000 * 60 * 60 * 24 * 14;
break;
}
case '1m': {
diff = 1000 * 60 * 60 * 24 * 30;
break;
}
case '3m': {
diff = 1000 * 60 * 60 * 24 * 90;
break;
}
case '6m': {
diff = 1000 * 60 * 60 * 24 * 180;
break;
}
}
const promises = [getSeriesFromEvents(input)];
if (input.previous) {
console.log('------->P R E V I O U S');
console.log({
startDate: new Date(
new Date(current.startDate).getTime() - diff
).toISOString(),
endDate: new Date(
new Date(current.endDate).getTime() - diff
).toISOString(),
});
promises.push(
getSeriesFromEvents({
...input,
...{
startDate: new Date(
new Date(current.startDate).getTime() - diff
).toISOString(),
endDate: new Date(
new Date(current.endDate).getTime() - diff
).toISOString(),
},
})
);
}
const result = await Promise.all(promises);
const series = result[0]!;
const previousSeries = result[1];
const final: FinalChart = {
events: input.events,
series: series.map((serie, index) => {
const previousSerie = previousSeries?.[index];
const metrics = {
sum: sum(serie.data.map((item) => item.count)),
average: round(average(serie.data.map((item) => item.count)), 2),
min: min(serie.data.map((item) => item.count)),
max: max(serie.data.map((item) => item.count)),
};
return {
values: uniq(events.map((item) => item.value)),
};
} else {
const events = await db.event.findMany({
where: {
project_id: projectId,
name: event,
[property]: {
not: null,
},
createdAt: {
gte: new Date(
new Date().getTime() - 1000 * 60 * 60 * 24 * intervalInDays
name: serie.name,
event: serie.event,
metrics: {
...metrics,
previous: {
sum: getPreviousMetric(
metrics.sum,
previousSerie
? sum(previousSerie?.data.map((item) => item.count))
: null
),
average: getPreviousMetric(
metrics.average,
previousSerie
? round(
average(previousSerie?.data.map((item) => item.count)),
2
)
: null
),
min: getPreviousMetric(
metrics.sum,
previousSerie
? min(previousSerie?.data.map((item) => item.count))
: null
),
max: getPreviousMetric(
metrics.sum,
previousSerie
? max(previousSerie?.data.map((item) => item.count))
: null
),
},
},
distinct: property as any,
select: {
[property]: true,
},
});
return {
values: uniq(events.map((item) => item[property]!)),
data: serie.data.map((item, index) => ({
date: item.date,
count: item.count ?? 0,
label: item.label,
previous: previousSerie?.data[index]
? getPreviousMetric(
item.count ?? 0,
previousSerie?.data[index]?.count ?? null
)
: null,
})),
};
}),
metrics: {
sum: 0,
average: 0,
min: 0,
max: 0,
previous: {
sum: null,
average: null,
min: null,
max: null,
},
},
};
final.metrics.sum = sum(final.series.map((item) => item.metrics.sum));
final.metrics.average = round(
average(final.series.map((item) => item.metrics.average)),
2
);
final.metrics.min = min(final.series.map((item) => item.metrics.min));
final.metrics.max = max(final.series.map((item) => item.metrics.max));
final.metrics.previous = {
sum: getPreviousMetric(
sum(final.series.map((item) => item.metrics.sum)),
sum(final.series.map((item) => item.metrics.previous.sum?.value ?? 0))
),
average: getPreviousMetric(
round(average(final.series.map((item) => item.metrics.average)), 2),
round(
average(
final.series.map(
(item) => item.metrics.previous.average?.value ?? 0
)
),
2
)
),
min: getPreviousMetric(
min(final.series.map((item) => item.metrics.min)),
min(final.series.map((item) => item.metrics.previous.min?.value ?? 0))
),
max: getPreviousMetric(
max(final.series.map((item) => item.metrics.max)),
max(final.series.map((item) => item.metrics.previous.max?.value ?? 0))
),
};
final.series = final.series.sort((a, b) => {
if (input.chartType === 'linear') {
const sumA = a.data.reduce((acc, item) => acc + (item.count ?? 0), 0);
const sumB = b.data.reduce((acc, item) => acc + (item.count ?? 0), 0);
return sumB - sumA;
} else {
return b.metrics[input.metric] - a.metrics[input.metric];
}
}),
});
chart: protectedProcedure
.input(zChartInputWithDates.merge(z.object({ projectId: z.string() })))
.query(async ({ input }) => {
const current = getDatesFromRange(input.range);
let diff = 0;
switch (input.range) {
case '30min': {
diff = 1000 * 60 * 30;
break;
}
case '1h': {
diff = 1000 * 60 * 60;
break;
}
case '24h':
case 'today': {
diff = 1000 * 60 * 60 * 24;
break;
}
case '7d': {
diff = 1000 * 60 * 60 * 24 * 17;
break;
}
case '14d': {
diff = 1000 * 60 * 60 * 24 * 14;
break;
}
case '1m': {
diff = 1000 * 60 * 60 * 24 * 30;
break;
}
case '3m': {
diff = 1000 * 60 * 60 * 24 * 90;
break;
}
case '6m': {
diff = 1000 * 60 * 60 * 24 * 180;
break;
}
}
const promises = [wrapper(input)];
if (input.previous) {
promises.push(
wrapper({
...input,
...{
startDate: new Date(
new Date(current.startDate).getTime() - diff
).toISOString(),
endDate: new Date(
new Date(current.endDate).getTime() - diff
).toISOString(),
},
})
);
}
const awaitedPromises = await Promise.all(promises);
const data = awaitedPromises[0]!;
const previousData = awaitedPromises[1];
return {
...data,
series: data.series.map((item, sIndex) => {
function getPreviousDiff(key: keyof (typeof data)['metrics']) {
const prev = previousData?.series?.[sIndex]?.metrics?.[key];
const diff = getPreviousDataDiff(item.metrics[key], prev);
return diff && prev
? {
diff: diff?.diff,
state: diff?.state,
value: prev,
}
: null;
}
return {
...item,
metrics: {
...item.metrics,
previous: {
sum: getPreviousDiff('sum'),
average: getPreviousDiff('average'),
},
},
data: item.data.map((item, dIndex) => {
const diff = getPreviousDataDiff(
item.count,
previousData?.series?.[sIndex]?.data?.[dIndex]?.count
);
return {
...item,
previous:
diff && previousData?.series?.[sIndex]?.data?.[dIndex]
? Object.assign(
{},
previousData?.series?.[sIndex]?.data?.[dIndex],
diff
)
: null,
};
}),
};
}),
};
}),
return final;
}),
});
const chartValidator = zChartInputWithDates.merge(
z.object({ projectId: z.string() })
);
type ChartInput = z.infer<typeof chartValidator>;
function getPreviousDataDiff(current: number, previous: number | undefined) {
if (!previous) {
function getPreviousMetric(
current: number,
previous: number | null
): PreviousValue {
if (previous === null) {
return null;
}
@@ -262,10 +355,11 @@ function getPreviousDataDiff(current: number, previous: number | undefined) {
: current < previous
? 'negative'
: 'neutral',
value: previous,
};
}
async function wrapper({ events, projectId, ...input }: ChartInput) {
async function getSeriesFromEvents(input: IChartInput) {
const { startDate, endDate } =
input.startDate && input.endDate
? {
@@ -273,65 +367,30 @@ async function wrapper({ events, projectId, ...input }: ChartInput) {
endDate: input.endDate,
}
: getDatesFromRange(input.range);
const series: Awaited<ReturnType<typeof getChartData>> = [];
for (const event of events) {
const result = await getChartData({
...input,
startDate,
endDate,
event,
projectId: projectId,
});
series.push(...result);
}
const sorted = [...series].sort((a, b) => {
if (input.chartType === 'linear') {
const sumA = a.data.reduce((acc, item) => acc + item.count, 0);
const sumB = b.data.reduce((acc, item) => acc + item.count, 0);
return sumB - sumA;
} else {
return b.metrics.sum - a.metrics.sum;
}
});
const metrics = {
max: Math.max(...sorted.map((item) => item.metrics.max)),
min: Math.min(...sorted.map((item) => item.metrics.min)),
sum: sum(sorted.map((item) => item.metrics.sum, 0)),
average: round(average(sorted.map((item) => item.metrics.average, 0)), 2),
};
return {
events: Object.entries(
series.reduce(
(acc, item) => {
if (acc[item.event.id]) {
acc[item.event.id] += item.metrics.sum;
} else {
acc[item.event.id] = item.metrics.sum;
}
return acc;
},
{} as Record<(typeof series)[number]['event']['id'], number>
const series = (
await Promise.all(
input.events.map(async (event) =>
getChartData({
...input,
startDate,
endDate,
event,
})
)
).map(([id, count]) => ({
count,
...events.find((event) => event.id === id)!,
})),
series: sorted,
metrics,
};
}
)
).flat();
interface ResultItem {
label: string | null;
count: number;
date: string;
}
function getEventLegend(event: IChartEvent) {
return event.displayName ?? `${event.name} (${event.id})`;
return withFormula(input, series);
// .sort((a, b) => {
// if (input.chartType === 'linear') {
// const sumA = a.data.reduce((acc, item) => acc + (item.count ?? 0), 0);
// const sumB = b.data.reduce((acc, item) => acc + (item.count ?? 0), 0);
// return sumB - sumA;
// } else {
// return b.metrics.sum - a.metrics.sum;
// }
// });
}
function getDatesFromRange(range: IChartRange) {
@@ -385,206 +444,3 @@ function getDatesFromRange(range: IChartRange) {
endDate: endDate.toISOString(),
};
}
async function getChartData(payload: IGetChartDataInput) {
let result = await db.$queryRawUnsafe<ResultItem[]>(getChartSql(payload));
if (result.length === 0 && payload.breakdowns.length > 0) {
result = await db.$queryRawUnsafe<ResultItem[]>(
getChartSql({
...payload,
breakdowns: [],
})
);
}
// group by sql label
const series = result.reduce(
(acc, item) => {
// item.label can be null when using breakdowns on a property
// that doesn't exist on all events
const label = item.label?.trim() ?? payload.event.id;
if (label) {
if (acc[label]) {
acc[label]?.push(item);
} else {
acc[label] = [item];
}
}
return {
...acc,
};
},
{} as Record<string, ResultItem[]>
);
return Object.keys(series).map((key) => {
// If we have breakdowns, we want to use the breakdown key as the legend
// But only if it successfully broke it down, otherwise we use the getEventLabel
const legend =
payload.breakdowns.length && !alphabetIds.includes(key as 'A')
? key
: getEventLegend(payload.event);
const data =
payload.chartType === 'area' ||
payload.chartType === 'linear' ||
payload.chartType === 'histogram' ||
payload.chartType === 'metric' ||
payload.chartType === 'pie' ||
payload.chartType === 'bar'
? fillEmptySpotsInTimeline(
series[key] ?? [],
payload.interval,
payload.startDate,
payload.endDate
).map((item) => {
return {
label: legend,
count: round(item.count),
date: new Date(item.date).toISOString(),
};
})
: (series[key] ?? []).map((item) => ({
label: item.label,
count: round(item.count),
date: new Date(item.date).toISOString(),
}));
const counts = data.map((item) => item.count);
return {
name: legend,
event: payload.event,
metrics: {
sum: sum(counts),
average: round(average(counts)),
max: Math.max(...counts),
min: Math.min(...counts),
},
data,
};
});
}
function fillEmptySpotsInTimeline(
items: ResultItem[],
interval: IInterval,
startDate: string,
endDate: string
) {
const result = [];
const clonedStartDate = new Date(startDate);
const clonedEndDate = new Date(endDate);
const today = new Date();
if (interval === 'minute') {
clonedStartDate.setUTCSeconds(0, 0);
clonedEndDate.setUTCMinutes(clonedEndDate.getUTCMinutes() + 1, 0, 0);
} else if (interval === 'hour') {
clonedStartDate.setUTCMinutes(0, 0, 0);
clonedEndDate.setUTCMinutes(0, 0, 0);
} else {
clonedStartDate.setUTCHours(0, 0, 0, 0);
clonedEndDate.setUTCHours(0, 0, 0, 0);
}
if (interval === 'month') {
clonedStartDate.setUTCDate(1);
clonedEndDate.setUTCDate(1);
}
// Force if interval is month and the start date is the same month as today
const shouldForce = () =>
interval === 'month' &&
clonedStartDate.getUTCFullYear() === today.getUTCFullYear() &&
clonedStartDate.getUTCMonth() === today.getUTCMonth();
let prev = undefined;
while (
shouldForce() ||
clonedStartDate.getTime() <= clonedEndDate.getTime()
) {
if (prev === clonedStartDate.getTime()) {
console.log('GET OUT NOW!');
break;
}
prev = clonedStartDate.getTime();
const getYear = (date: Date) => date.getUTCFullYear();
const getMonth = (date: Date) => date.getUTCMonth();
const getDay = (date: Date) => date.getUTCDate();
const getHour = (date: Date) => date.getUTCHours();
const getMinute = (date: Date) => date.getUTCMinutes();
const item = items.find((item) => {
const date = new Date(item.date);
if (interval === 'month') {
return (
getYear(date) === getYear(clonedStartDate) &&
getMonth(date) === getMonth(clonedStartDate)
);
}
if (interval === 'day') {
return (
getYear(date) === getYear(clonedStartDate) &&
getMonth(date) === getMonth(clonedStartDate) &&
getDay(date) === getDay(clonedStartDate)
);
}
if (interval === 'hour') {
return (
getYear(date) === getYear(clonedStartDate) &&
getMonth(date) === getMonth(clonedStartDate) &&
getDay(date) === getDay(clonedStartDate) &&
getHour(date) === getHour(clonedStartDate)
);
}
if (interval === 'minute') {
return (
getYear(date) === getYear(clonedStartDate) &&
getMonth(date) === getMonth(clonedStartDate) &&
getDay(date) === getDay(clonedStartDate) &&
getHour(date) === getHour(clonedStartDate) &&
getMinute(date) === getMinute(clonedStartDate)
);
}
});
if (item) {
result.push({
...item,
date: clonedStartDate.toISOString(),
});
} else {
result.push({
date: clonedStartDate.toISOString(),
count: 0,
label: null,
});
}
switch (interval) {
case 'day': {
clonedStartDate.setUTCDate(clonedStartDate.getUTCDate() + 1);
break;
}
case 'hour': {
clonedStartDate.setUTCHours(clonedStartDate.getUTCHours() + 1);
break;
}
case 'minute': {
clonedStartDate.setUTCMinutes(clonedStartDate.getUTCMinutes() + 1);
break;
}
case 'month': {
clonedStartDate.setUTCMonth(clonedStartDate.getUTCMonth() + 1);
break;
}
}
}
return sort(function (a, b) {
return new Date(a.date).getTime() - new Date(b.date).getTime();
}, result);
}

View File

@@ -1,19 +1,9 @@
import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
import { db } from '@/server/db';
import { transformEvent } from '@/server/services/event.service';
import { z } from 'zod';
import type { Event, Profile } from '@mixan/db';
function transformEvent(
event: Event & {
profile: Profile;
}
) {
return {
...event,
properties: event.properties as Record<string, unknown>,
};
}
import type { IDBEvent } from '@mixan/db';
import { chQuery, createSqlBuilder } from '@mixan/db';
export const eventRouter = createTRPCRouter({
list: protectedProcedure
@@ -27,28 +17,18 @@ export const eventRouter = createTRPCRouter({
})
)
.query(async ({ input: { take, skip, projectId, profileId, events } }) => {
return db.event
.findMany({
take,
skip,
where: {
project_id: projectId,
profile_id: profileId,
...(events && events.length > 0
? {
name: {
in: events,
},
}
: {}),
},
orderBy: {
createdAt: 'desc',
},
include: {
profile: true,
},
})
.then((events) => events.map(transformEvent));
const { sb, getSql } = createSqlBuilder();
sb.limit = take;
sb.offset = skip;
sb.where.projectId = `project_id = '${projectId}'`;
if (profileId) {
sb.where.profileId = `profile_id = '${profileId}'`;
}
if (events?.length) {
sb.where.name = `name IN (${events.map((e) => `'${e}'`).join(',')})`;
}
return (await chQuery<IDBEvent>(getSql())).map(transformEvent);
}),
});

View File

@@ -53,7 +53,7 @@ export const reportRouter = createTRPCRouter({
save: protectedProcedure
.input(
z.object({
report: zChartInput,
report: zChartInput.omit({ projectId: true }),
dashboardId: z.string(),
})
)
@@ -74,6 +74,7 @@ export const reportRouter = createTRPCRouter({
chart_type: report.chartType,
line_type: report.lineType,
range: report.range,
formula: report.formula,
},
});
}),
@@ -81,7 +82,7 @@ export const reportRouter = createTRPCRouter({
.input(
z.object({
reportId: z.string(),
report: zChartInput,
report: zChartInput.omit({ projectId: true }),
})
)
.mutation(({ input: { report, reportId } }) => {
@@ -97,6 +98,7 @@ export const reportRouter = createTRPCRouter({
chart_type: report.chartType,
line_type: report.lineType,
range: report.range,
formula: report.formula,
},
});
}),

View File

@@ -1,79 +0,0 @@
import type { IGetChartDataInput } from '@/types';
import {
createSqlBuilder,
getWhereClause,
isJsonPath,
selectJsonPath,
} from './helpers';
function log(sql: string) {
const logs = ['--- START', sql, '--- END'];
console.log(logs.join('\n'));
return sql;
}
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 log(`${getSelect()} ${getFrom()} ${getGroupBy()} ${getOrderBy()}`);
}
return log(
`${getSelect()} ${getFrom()} ${getWhere()} ${getGroupBy()} ${getOrderBy()}`
);
}

View File

@@ -1,140 +0,0 @@
import type { IChartEventFilter } from '@/types';
export function getWhereClause(filters: IChartEventFilter[]) {
const where: string[] = [];
if (filters.length > 0) {
filters.forEach((filter) => {
const { name, value, operator } = filter;
switch (operator) {
case 'contains': {
if (name.includes('.*.') || name.endsWith('[*]')) {
// TODO: Make sure this works
// where.push(
// `properties @? '$.${name
// .replace(/^properties\./, '')
// .replace(/\.\*\./g, '[*].')} ? (@ like_regex "${value[0]}")'`
// );
} else {
where.push(
`(${value
.map(
(val) =>
`${propertyNameToSql(name)} like '%${String(val).replace(
/'/g,
"''"
)}%'`
)
.join(' OR ')})`
);
}
break;
}
case 'is': {
if (name.includes('.*.') || name.endsWith('[*]')) {
where.push(
`properties @? '$.${name
.replace(/^properties\./, '')
.replace(/\.\*\./g, '[*].')} ? (${value
.map((val) => `@ == "${val}"`)
.join(' || ')})'`
);
} else {
where.push(
`${propertyNameToSql(name)} in (${value
.map((val) => `'${val}'`)
.join(', ')})`
);
}
break;
}
case 'isNot': {
if (name.includes('.*.') || name.endsWith('[*]')) {
where.push(
`properties @? '$.${name
.replace(/^properties\./, '')
.replace(/\.\*\./g, '[*].')} ? (${value
.map((val) => `@ != "${val}"`)
.join(' && ')})'`
);
} else if (name.includes('.')) {
where.push(
`${propertyNameToSql(name)} not in (${value
.map((val) => `'${val}'`)
.join(', ')})`
);
}
break;
}
}
});
}
return where;
}
export function selectJsonPath(property: string) {
const jsonPath = property
.replace(/^properties\./, '')
.replace(/\.\*\./g, '.**.');
return `jsonb_path_query(properties, '$.${jsonPath}')`;
}
export function isJsonPath(property: string) {
return property.startsWith('properties');
}
export function propertyNameToSql(name: string) {
if (name.includes('.')) {
const str = name
.split('.')
.map((item, index) => (index === 0 ? item : `'${item}'`))
.join('->');
const findLastOf = '->';
const lastArrow = str.lastIndexOf(findLastOf);
if (lastArrow === -1) {
return str;
}
const first = str.slice(0, lastArrow);
const last = str.slice(lastArrow + findLastOf.length);
return `${first}->>${last}`;
}
return name;
}
export function createSqlBuilder() {
const join = (obj: Record<string, string> | string[], joiner: string) =>
Object.values(obj).filter(Boolean).join(joiner);
const sb: {
where: Record<string, string>;
select: Record<string, string>;
groupBy: Record<string, string>;
orderBy: Record<string, string>;
from: string;
} = {
where: {},
from: 'events',
select: {},
groupBy: {},
orderBy: {},
};
return {
sb,
join,
getWhere: () =>
Object.keys(sb.where).length ? 'WHERE ' + join(sb.where, ' AND ') : '',
getFrom: () => `FROM ${sb.from}`,
getSelect: () =>
'SELECT ' + (Object.keys(sb.select).length ? join(sb.select, ', ') : '*'),
getGroupBy: () =>
Object.keys(sb.groupBy).length
? 'GROUP BY ' + join(sb.groupBy, ', ')
: '',
getOrderBy: () =>
Object.keys(sb.orderBy).length
? 'ORDER BY ' + join(sb.orderBy, ', ')
: '',
};
}

View File

@@ -0,0 +1,167 @@
import type { IGetChartDataInput } from '@/types';
import { createSqlBuilder, formatClickhouseDate } from '@mixan/db';
function log(sql: string) {
const logs = ['--- START', sql, '--- END'];
console.log(logs.join('\n'));
return sql;
}
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.select.label = `'${event.name}' as label`;
sb.where.eventName = `name = '${event.name}'`;
}
event.filters.forEach((filter, index) => {
const id = `f${index}`;
const { name, value, operator } = filter;
if (name.startsWith('properties.')) {
const whereFrom = `mapValues(mapExtractKeyLike(properties, '${name
.replace(/^properties\./, '')
.replace('.*.', '.%.')}'))`;
switch (operator) {
case 'is': {
sb.where[id] = `arrayExists(x -> ${value
.map((val) => `x = '${String(val).trim()}'`)
.join(' OR ')}, ${whereFrom})`;
break;
}
case 'isNot': {
sb.where[id] = `arrayExists(x -> ${value
.map((val) => `x != '${String(val).trim()}'`)
.join(' OR ')}, ${whereFrom})`;
break;
}
case 'contains': {
sb.where[id] = `arrayExists(x -> ${value
.map((val) => `x LIKE '%${String(val).trim()}%'`)
.join(' OR ')}, ${whereFrom})`;
break;
}
case 'doesNotContain': {
sb.where[id] = `arrayExists(x -> ${value
.map((val) => `x NOT LIKE '%${String(val).trim()}%'`)
.join(' OR ')}, ${whereFrom})`;
break;
}
}
} else {
switch (operator) {
case 'is': {
sb.where[id] = `${name} IN (${value
.map((val) => `'${String(val).trim()}'`)
.join(', ')})`;
break;
}
case 'isNot': {
sb.where[id] = `${name} NOT IN (${value
.map((val) => `'${String(val).trim()}'`)
.join(', ')})`;
break;
}
case 'contains': {
sb.where[id] = value
.map((val) => `${name} LIKE '%${String(val).trim()}%'`)
.join(' OR ');
break;
}
case 'doesNotContain': {
sb.where[id] = value
.map((val) => `${name} NOT LIKE '%${String(val).trim()}%'`)
.join(' OR ');
break;
}
}
}
});
sb.select.count = `count(*) as count`;
switch (interval) {
case 'minute': {
sb.select.date = `toStartOfMinute(created_at) as date`;
break;
}
case 'hour': {
sb.select.date = `toStartOfHour(created_at) as date`;
break;
}
case 'day': {
sb.select.date = `toStartOfDay(created_at) as date`;
break;
}
case 'month': {
sb.select.date = `toStartOfMonth(created_at) as date`;
break;
}
}
sb.groupBy.date = 'date';
sb.orderBy.date = 'date ASC';
if (startDate) {
sb.where.startDate = `created_at >= '${formatClickhouseDate(startDate)}'`;
}
if (endDate) {
sb.where.endDate = `created_at <= '${formatClickhouseDate(endDate)}'`;
}
const breakdown = breakdowns[0]!;
if (breakdown) {
const value = breakdown.name.startsWith('properties.')
? `mapValues(mapExtractKeyLike(properties, '${breakdown.name
.replace(/^properties\./, '')
.replace('.*.', '.%.')}'))`
: breakdown.name;
sb.select.label = breakdown.name.startsWith('properties.')
? `arrayElement(${value}, 1) as label`
: `${breakdown.name} as label`;
sb.groupBy.label = `label`;
}
if (event.segment === 'user') {
sb.select.count = `countDistinct(profile_id) as count`;
}
if (event.segment === 'user_average') {
sb.select.count = `COUNT(*)::float / COUNT(DISTINCT profile_id)::float as count`;
}
if (event.segment === 'property_sum' && event.property) {
sb.select.count = `sum(${event.property}) as count`;
}
if (event.segment === 'property_average' && event.property) {
sb.select.count = `avg(${event.property}) 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, created_at DESC
) as subQuery`;
return log(`${getSelect()} ${getFrom()} ${getGroupBy()} ${getOrderBy()}`);
}
return log(
`${getSelect()} ${getFrom()} ${getWhere()} ${getGroupBy()} ${getOrderBy()}`
);
}

View File

@@ -1,5 +1,15 @@
import type { IDBEvent } from '@mixan/db';
import { db } from '../db';
export function transformEvent({ created_at, ...event }: IDBEvent) {
return {
...event,
profile: undefined,
createdAt: new Date(created_at),
};
}
export function getUniqueEvents({ projectId }: { projectId: string }) {
return db.event.findMany({
take: 500,

View File

@@ -23,3 +23,15 @@ export function getOrganizationById(id: string) {
},
});
}
export function getOrganizationByProjectId(projectId: string) {
return db.organization.findFirst({
where: {
projects: {
some: {
id: projectId,
},
},
},
});
}

View File

@@ -1,5 +1,7 @@
import { unstable_cache } from 'next/cache';
import { chQuery } from '@mixan/db';
import { db } from '../db';
export type IServiceProject = Awaited<ReturnType<typeof getProjectById>>;
@@ -20,6 +22,17 @@ export function getProjectsByOrganizationId(organizationId: string) {
});
}
export async function getProjectWithMostEvents(organizationId: string) {
return db.project.findFirst({
where: {
organization_id: organizationId,
},
orderBy: {
eventsCount: 'desc',
},
});
}
export function getFirstProjectByOrganizationId(organizationId: string) {
const tag = `getFirstProjectByOrganizationId_${organizationId}`;
return unstable_cache(

View File

@@ -26,7 +26,7 @@ export function transformFilter(
};
}
export function transformEvent(
export function transformReportEvent(
event: Partial<IChartEvent>,
index: number
): IChartEvent {
@@ -36,6 +36,7 @@ export function transformEvent(
id: event.id ?? alphabetIds[index]!,
name: event.name || 'unknown_event',
displayName: event.displayName,
property: event.property,
};
}
@@ -44,7 +45,8 @@ export function transformReport(
): IChartInput & { id: string } {
return {
id: report.id,
events: (report.events as IChartEvent[]).map(transformEvent),
projectId: report.project_id,
events: (report.events as IChartEvent[]).map(transformReportEvent),
breakdowns: report.breakdowns as IChartBreakdown[],
chartType: report.chart_type,
lineType: (report.line_type ?? 'kuk') as IChartLineType,
@@ -52,6 +54,9 @@ export function transformReport(
name: report.name || 'Untitled',
range: (report.range as IChartRange) ?? timeRanges['1m'],
previous: report.previous ?? false,
formula: report.formula ?? undefined,
metric: report.metric ?? 'sum',
unit: report.unit ?? undefined,
};
}

View File

@@ -3,9 +3,9 @@ import type {
zChartBreakdown,
zChartEvent,
zChartInput,
zChartInputWithDates,
zChartType,
zLineType,
zMetric,
zTimeInterval,
} from '@/utils/validation';
import type { TooltipProps } from 'recharts';
@@ -19,7 +19,6 @@ export type HtmlProps<T> = Omit<
>;
export type IChartInput = z.infer<typeof zChartInput>;
export type IChartInputWithDates = z.infer<typeof zChartInputWithDates>;
export type IChartEvent = z.infer<typeof zChartEvent>;
export type IChartEventFilter = IChartEvent['filters'][number];
export type IChartEventFilterValue =
@@ -27,6 +26,7 @@ export type IChartEventFilterValue =
export type IChartBreakdown = z.infer<typeof zChartBreakdown>;
export type IInterval = z.infer<typeof zTimeInterval>;
export type IChartType = z.infer<typeof zChartType>;
export type IChartMetric = z.infer<typeof zMetric>;
export type IChartLineType = z.infer<typeof zLineType>;
export type IChartRange = keyof typeof timeRanges;
export type IToolTipProps<T> = Omit<TooltipProps<number, string>, 'payload'> & {
@@ -43,7 +43,4 @@ export type IGetChartDataInput = {
projectId: string;
startDate: string;
endDate: string;
} & Omit<
IChartInputWithDates,
'events' | 'name' | 'startDate' | 'endDate' | 'range'
>;
} & Omit<IChartInput, 'events' | 'name' | 'startDate' | 'endDate' | 'range'>;

View File

@@ -3,7 +3,7 @@ export const operators = {
isNot: 'Is not',
contains: 'Contains',
doesNotContain: 'Not contains',
};
} as const;
export const chartTypes = {
linear: 'Linear',
@@ -12,7 +12,8 @@ export const chartTypes = {
pie: 'Pie',
metric: 'Metric',
area: 'Area',
};
map: 'Map',
} as const;
export const lineTypes = {
monotone: 'Monotone',
@@ -30,14 +31,14 @@ export const lineTypes = {
bumpY: 'Bump Y',
bump: 'Bump',
linearClosed: 'Linear closed',
};
} as const;
export const intervals = {
minute: 'Minute',
day: 'Day',
hour: 'Hour',
month: 'Month',
};
minute: 'minute',
day: 'day',
hour: 'hour',
month: 'month',
} as const;
export const alphabetIds = [
'A',
@@ -65,6 +66,13 @@ export const timeRanges = {
'1y': '1y',
} as const;
export const metrics = {
sum: 'sum',
average: 'average',
min: 'min',
max: 'max',
} as const;
export function isMinuteIntervalEnabledByRange(range: keyof typeof timeRanges) {
return range === '30min' || range === '1h';
}
@@ -77,7 +85,9 @@ export function isHourIntervalEnabledByRange(range: keyof typeof timeRanges) {
);
}
export function getDefaultIntervalByRange(range: keyof typeof timeRanges) {
export function getDefaultIntervalByRange(
range: keyof typeof timeRanges
): keyof typeof intervals {
if (range === '30min' || range === '1h') {
return 'minute';
} else if (range === 'today' || range === '24h') {

View File

@@ -1,12 +1,22 @@
import { isNumber } from 'mathjs';
export const round = (num: number, decimals = 2) => {
const factor = Math.pow(10, decimals);
return Math.round((num + Number.EPSILON) * factor) / factor;
};
export const average = (arr: number[]) =>
arr.reduce((p, c) => p + c, 0) / arr.length;
export const average = (arr: (number | null)[]) => {
const filtered = arr.filter(isNumber);
return filtered.reduce((p, c) => p + c, 0) / filtered.length;
};
export const sum = (arr: number[]) =>
round(arr.reduce((acc, item) => acc + item, 0));
export const sum = (arr: (number | null)[]): number =>
round(arr.filter(isNumber).reduce((acc, item) => acc + item, 0));
export const min = (arr: (number | null)[]): number =>
Math.min(...arr.filter(isNumber));
export const max = (arr: (number | null)[]): number =>
Math.max(...arr.filter(isNumber));
export const isFloat = (n: number) => n % 1 !== 0;

View File

@@ -0,0 +1,6 @@
export function truncate(str: string, len: number) {
if (str.length <= len) {
return str;
}
return str.slice(0, len) + '...';
}

View File

@@ -4,6 +4,7 @@ import {
chartTypes,
intervals,
lineTypes,
metrics,
operators,
timeRanges,
} from './constants';
@@ -15,11 +16,21 @@ export function objectToZodEnums<K extends string>(
return [firstKey!, ...otherKeys];
}
export const mapKeys = objectToZodEnums;
export const zChartEvent = z.object({
id: z.string(),
name: z.string(),
displayName: z.string().optional(),
segment: z.enum(['event', 'user', 'user_average', 'one_event_per_user']),
property: z.string().optional(),
segment: z.enum([
'event',
'user',
'user_average',
'one_event_per_user',
'property_sum',
'property_average',
]),
filters: z.array(
z.object({
id: z.string(),
@@ -43,6 +54,8 @@ export const zLineType = z.enum(objectToZodEnums(lineTypes));
export const zTimeInterval = z.enum(objectToZodEnums(intervals));
export const zMetric = z.enum(objectToZodEnums(metrics));
export const zChartInput = z.object({
name: z.string(),
chartType: zChartType,
@@ -52,9 +65,11 @@ export const zChartInput = z.object({
breakdowns: zChartBreakdowns,
range: z.enum(objectToZodEnums(timeRanges)),
previous: z.boolean(),
});
export const zChartInputWithDates = zChartInput.extend({
formula: z.string().optional(),
metric: zMetric,
unit: z.string().optional(),
previousIndicatorInverted: z.boolean().optional(),
projectId: z.string(),
startDate: z.string().nullish(),
endDate: z.string().nullable(),
endDate: z.string().nullish(),
});

View File

@@ -14,6 +14,7 @@
"@bull-board/express": "^5.13.0",
"@mixan/db": "workspace:*",
"@mixan/queue": "workspace:*",
"@mixan/common": "workspace:*",
"bullmq": "^5.1.1",
"express": "^4.18.2",
"ramda": "^0.29.1"

View File

@@ -5,10 +5,12 @@ import { Worker } from 'bullmq';
import express from 'express';
import { connection, eventsQueue } from '@mixan/queue';
import { cronQueue } from '@mixan/queue/src/queues';
import { cronJob } from './jobs/cron';
import { eventsJob } from './jobs/events';
const PORT = process.env.PORT || 3001;
const PORT = process.env.PORT || 3000;
const serverAdapter = new ExpressAdapter();
serverAdapter.setBasePath('/');
const app = express();
@@ -17,13 +19,43 @@ new Worker(eventsQueue.name, eventsJob, {
connection,
});
createBullBoard({
queues: [new BullMQAdapter(eventsQueue)],
serverAdapter: serverAdapter,
new Worker(cronQueue.name, cronJob, {
connection,
});
app.use('/', serverAdapter.getRouter());
async function start() {
createBullBoard({
queues: [new BullMQAdapter(eventsQueue), new BullMQAdapter(cronQueue)],
serverAdapter: serverAdapter,
});
app.listen(PORT, () => {
console.log(`For the UI, open http://localhost:${PORT}/`);
});
app.use('/', serverAdapter.getRouter());
app.listen(PORT, () => {
console.log(`For the UI, open http://localhost:${PORT}/`);
});
const repeatableJobs = await cronQueue.getRepeatableJobs();
console.log(repeatableJobs);
await cronQueue.add(
'salt',
{
type: 'salt',
payload: undefined,
},
{
jobId: 'salt',
repeat: {
utc: true,
pattern: '0 0 * * *',
},
}
);
// if (!repeatableJobs.find((job) => job.name === 'salt')) {
// console.log('Add salt job to queue');
// }
}
start();

View File

@@ -0,0 +1,22 @@
import { generateSalt } from '@mixan/common';
import { db, getCurrentSalt } from '@mixan/db';
export async function salt() {
const oldSalt = await getCurrentSalt();
const newSalt = await db.salt.create({
data: {
salt: generateSalt(),
},
});
// Delete rest of the salts
await db.salt.deleteMany({
where: {
salt: {
notIn: [newSalt.salt, oldSalt],
},
},
});
return newSalt;
}

View File

@@ -0,0 +1,13 @@
import type { Job } from 'bullmq';
import type { CronQueuePayload } from '@mixan/queue/src/queues';
import { salt } from './cron.salt';
export async function cronJob(job: Job<CronQueuePayload>) {
switch (job.data.type) {
case 'salt': {
return await salt();
}
}
}

Some files were not shown because too many files have changed in this diff Show More