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/profileId.txt
packages/sdk/test.ts packages/sdk/test.ts
dump.sql dump.sql
dump-*.sql dump-*
.sql
clickhouse
# Logs # Logs

View File

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

View File

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

View File

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

View File

@@ -53,7 +53,7 @@ export function ListDashboards({ dashboards }: ListDashboardsProps) {
<Card key={item.id} hover> <Card key={item.id} hover>
<div> <div>
<Link <Link
href={`/${organizationId}/${projectId}/${item.id}`} href={`/${organizationId}/${projectId}/dashboards/${item.id}`}
className="block p-4 flex flex-col" className="block p-4 flex flex-col"
> >
<span className="font-medium">{item.name}</span> <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 { ExpandableListItem } from '@/components/general/ExpandableListItem';
import { ProfileAvatar } from '@/components/profiles/ProfileAvatar'; import { ProfileAvatar } from '@/components/profiles/ProfileAvatar';
import { useAppParams } from '@/hooks/useAppParams'; import { useAppParams } from '@/hooks/useAppParams';
import { cn } from '@/utils/cn';
import { formatDateTime } from '@/utils/date'; import { formatDateTime } from '@/utils/date';
import { getProfileName } from '@/utils/getters'; import { getProfileName } from '@/utils/getters';
import { round } from '@/utils/math'; import { round } from '@/utils/math';
import { Activity, BotIcon, MonitorPlay } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { EventIcon } from './event-icon'; import { EventIcon } from './event-icon';
@@ -48,7 +46,7 @@ export function EventListItem({
switch (name) { switch (name) {
case 'screen_view': { case 'screen_view': {
const route = (properties?.route || properties?.path) as string; const route = (properties?.route || properties?.path)!;
if (route) { if (route) {
bullets.push(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 PageLayout from '@/app/(app)/page-layout';
import { getDashboardsByProjectId } from '@/server/services/dashboard.service';
import { HeaderDashboards } from './header-dashboards'; import OverviewMetrics from './overview-metrics';
import { ListDashboards } from './list-dashboards';
interface PageProps { interface PageProps {
params: { params: {
@@ -11,15 +9,10 @@ interface PageProps {
}; };
} }
export default async function Page({ export default function Page({ params: { organizationId } }: PageProps) {
params: { organizationId, projectId },
}: PageProps) {
const dashboards = await getDashboardsByProjectId(projectId);
return ( return (
<PageLayout title="Dashboards" organizationId={organizationId}> <PageLayout title="Overview" organizationId={organizationId}>
<HeaderDashboards projectId={projectId} /> <OverviewMetrics />
<ListDashboards dashboards={dashboards} />
</PageLayout> </PageLayout>
); );
} }

View File

@@ -1,12 +1,12 @@
'use client'; 'use client';
import { useEffect, useRef } from 'react'; import { useEffect } from 'react';
import { api } from '@/app/_trpc/client';
import { StickyBelowHeader } from '@/app/(app)/layout-sticky-below-header'; import { StickyBelowHeader } from '@/app/(app)/layout-sticky-below-header';
import { Chart } from '@/components/report/chart'; import { Chart } from '@/components/report/chart';
import { ReportChartType } from '@/components/report/ReportChartType'; import { ReportChartType } from '@/components/report/ReportChartType';
import { ReportInterval } from '@/components/report/ReportInterval'; import { ReportInterval } from '@/components/report/ReportInterval';
import { ReportLineType } from '@/components/report/ReportLineType'; import { ReportLineType } from '@/components/report/ReportLineType';
import { ReportRange } from '@/components/report/ReportRange';
import { ReportSaveButton } from '@/components/report/ReportSaveButton'; import { ReportSaveButton } from '@/components/report/ReportSaveButton';
import { import {
changeDateRanges, changeDateRanges,
@@ -58,17 +58,12 @@ export default function ReportEditor({
</SheetTrigger> </SheetTrigger>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 col-span-4"> <div className="grid grid-cols-2 md:grid-cols-4 gap-2 col-span-4">
<ReportChartType className="min-w-0 flex-1" /> <ReportChartType className="min-w-0 flex-1" />
<Combobox <ReportRange
className="min-w-0 flex-1" className="min-w-0 flex-1"
placeholder="Range"
value={report.range} value={report.range}
onChange={(value) => { onChange={(value) => {
dispatch(changeDateRanges(value)); dispatch(changeDateRanges(value));
}} }}
items={Object.values(timeRanges).map((key) => ({
label: key,
value: key,
}))}
/> />
<ReportInterval className="min-w-0 flex-1" /> <ReportInterval className="min-w-0 flex-1" />
<ReportLineType 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'; import { redirect } from 'next/navigation';
interface PageProps { interface PageProps {
@@ -8,10 +8,11 @@ interface PageProps {
} }
export default async function Page({ params: { organizationId } }: PageProps) { export default async function Page({ params: { organizationId } }: PageProps) {
const project = await getFirstProjectByOrganizationId(organizationId); const project = await getProjectWithMostEvents(organizationId);
if (project) { if (project) {
return redirect(`/${organizationId}/${project.id}`); 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 { useAppParams } from '@/hooks/useAppParams';
import type { IServiceRecentDashboards } from '@/server/services/dashboard.service'; import type { IServiceRecentDashboards } from '@/server/services/dashboard.service';
import { cn } from '@/utils/cn';
import { import {
BuildingIcon, BuildingIcon,
CogIcon, CogIcon,
DotIcon,
GanttChartIcon, GanttChartIcon,
KeySquareIcon, KeySquareIcon,
LayoutPanelTopIcon, LayoutPanelTopIcon,
UserIcon, UserIcon,
UsersIcon, UsersIcon,
WallpaperIcon,
WarehouseIcon, WarehouseIcon,
} from 'lucide-react'; } from 'lucide-react';
import type { LucideProps } from 'lucide-react'; import type { LucideProps } from 'lucide-react';
@@ -20,18 +23,32 @@ function LinkWithIcon({
href, href,
icon: Icon, icon: Icon,
label, label,
active: overrideActive,
}: { }: {
href: string; href: string;
icon: React.ElementType<LucideProps>; icon: React.ElementType<LucideProps>;
label: React.ReactNode; label: React.ReactNode;
active?: boolean;
}) { }) {
const pathname = usePathname();
const active = overrideActive || href === pathname;
return ( return (
<Link <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} href={href}
> >
<Icon size={20} /> <Icon size={20} />
{label} <div className="flex-1">{label}</div>
<DotIcon
size={20}
className={cn(
'transition-opacity',
active ? 'opacity-100' : 'opacity-0'
)}
/>
</Link> </Link>
); );
} }
@@ -53,10 +70,15 @@ export default function LayoutMenu({
return ( return (
<> <>
<LinkWithIcon
icon={WallpaperIcon}
label="Overview"
href={`/${params.organizationId}/${projectId}`}
/>
<LinkWithIcon <LinkWithIcon
icon={LayoutPanelTopIcon} icon={LayoutPanelTopIcon}
label="Dashboards" label="Dashboards"
href={`/${params.organizationId}/${projectId}`} href={`/${params.organizationId}/${projectId}/dashboards`}
/> />
<LinkWithIcon <LinkWithIcon
icon={GanttChartIcon} icon={GanttChartIcon}

View File

@@ -12,7 +12,7 @@ export function StickyBelowHeader({
return ( return (
<div <div
className={cn( 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 className
)} )}
> >

View File

@@ -2,6 +2,7 @@ import { getSession } from '@/server/auth';
import { getRecentDashboardsByUserId } from '@/server/services/dashboard.service'; import { getRecentDashboardsByUserId } from '@/server/services/dashboard.service';
import { getOrganizations } from '@/server/services/organization.service'; import { getOrganizations } from '@/server/services/organization.service';
import Auth from '../auth';
import { LayoutSidebar } from './layout-sidebar'; import { LayoutSidebar } from './layout-sidebar';
interface AppLayoutProps { interface AppLayoutProps {
@@ -15,8 +16,12 @@ export default async function AppLayout({ children }: AppLayoutProps) {
? await getRecentDashboardsByUserId(session?.user.id) ? await getRecentDashboardsByUserId(session?.user.id)
: []; : [];
if (!session) {
return <Auth />;
}
return ( return (
<div> <div id="dashboard">
<LayoutSidebar {...{ organizations, recentDashboards }} /> <LayoutSidebar {...{ organizations, recentDashboards }} />
<div className="lg:pl-72 transition-all">{children}</div> <div className="lg:pl-72 transition-all">{children}</div>
</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 '@/styles/globals.css';
import { getSession } from '@/server/auth'; import { getSession } from '@/server/auth';
import { cookies } from 'next/headers';
import Auth from './auth';
export const metadata = {}; export const metadata = {};
@@ -30,9 +27,7 @@ export default async function RootLayout({
<body <body
className={cn('min-h-screen font-sans antialiased grainy bg-slate-50')} className={cn('min-h-screen font-sans antialiased grainy bg-slate-50')}
> >
<Providers cookies={cookies().getAll()} session={session}> <Providers session={session}>{children}</Providers>
{session ? children : <Auth />}
</Providers>
</body> </body>
</html> </html>
); );

View File

@@ -8,29 +8,25 @@ import { ModalProvider } from '@/modals';
import type { AppStore } from '@/redux'; import type { AppStore } from '@/redux';
import makeStore from '@/redux'; import makeStore from '@/redux';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client'; import { httpLink } from '@trpc/client';
import type { Session } from 'next-auth'; import type { Session } from 'next-auth';
import { SessionProvider } from 'next-auth/react'; import { SessionProvider } from 'next-auth/react';
import type { RequestCookie } from 'next/dist/compiled/@edge-runtime/cookies';
import { Provider as ReduxProvider } from 'react-redux'; import { Provider as ReduxProvider } from 'react-redux';
import superjson from 'superjson'; import superjson from 'superjson';
import { CookieProvider } from './cookie-provider';
export default function Providers({ export default function Providers({
children, children,
session, session,
cookies,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
session: Session | null; session: Session | null;
cookies: RequestCookie[];
}) { }) {
const [queryClient] = useState( const [queryClient] = useState(
() => () =>
new QueryClient({ new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
networkMode: 'always',
refetchOnMount: true, refetchOnMount: true,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
}, },
@@ -41,7 +37,7 @@ export default function Providers({
api.createClient({ api.createClient({
transformer: superjson, transformer: superjson,
links: [ links: [
httpBatchLink({ httpLink({
url: 'http://localhost:3000/api/trpc', url: 'http://localhost:3000/api/trpc',
}), }),
], ],
@@ -60,7 +56,7 @@ export default function Providers({
<api.Provider client={trpcClient} queryClient={queryClient}> <api.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<TooltipProvider delayDuration={200}> <TooltipProvider delayDuration={200}>
<CookieProvider value={cookies}>{children}</CookieProvider> {children}
<Toaster /> <Toaster />
<ModalProvider /> <ModalProvider />
</TooltipProvider> </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 type { HtmlProps } from '@/types';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import { useChartContext } from './report/chart/ChartProvider';
type ColorSquareProps = HtmlProps<HTMLDivElement>; type ColorSquareProps = HtmlProps<HTMLDivElement>;
export function ColorSquare({ children, className }: ColorSquareProps) { export function ColorSquare({ children, className }: ColorSquareProps) {
const { hideID } = useChartContext();
if (hideID) {
return null;
}
return ( return (
<div <div
className={cn( 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 ( return (
<div className="text-xl font-medium flex gap-2 items-center"> <div
<img src="/logo.svg" className="max-h-10" /> className={cn('text-xl font-medium flex gap-2 items-center', className)}
>
<img src="/logo.svg" className="max-h-8 rounded-md" />
openpanel.dev openpanel.dev
</div> </div>
); );

View File

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

View File

@@ -1,9 +1,10 @@
import { useOrganizationParams } from '@/hooks/useOrganizationParams'; import { useOrganizationParams } from '@/hooks/useOrganizationParams';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import { strip } from '@/utils/object';
import type { LinkProps } from 'next/link'; import type { LinkProps } from 'next/link';
import Link from 'next/link'; import Link from 'next/link';
import { strip } from '@mixan/common';
import { NavbarUserDropdown } from './NavbarUserDropdown'; import { NavbarUserDropdown } from './NavbarUserDropdown';
function Item({ 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 { useNumber } from '@/hooks/useNumerFormatter';
import { cn } from '@/utils/cn'; 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'; import { useChartContext } from './chart/ChartProvider';
interface PreviousDiffIndicatorProps { interface PreviousDiffIndicatorProps {
diff?: number | null | undefined; diff?: number | null | undefined;
state?: string | null | undefined; state?: string | null | undefined;
children?: React.ReactNode; children?: React.ReactNode;
inverted?: boolean;
} }
export function PreviousDiffIndicator({ export function PreviousDiffIndicator({
@@ -15,25 +17,38 @@ export function PreviousDiffIndicator({
state, state,
children, children,
}: PreviousDiffIndicatorProps) { }: PreviousDiffIndicatorProps) {
const { previous } = useChartContext(); const { previous, previousIndicatorInverted } = useChartContext();
const number = useNumber(); const number = useNumber();
if (diff === null || diff === undefined || previous === false) { if (diff === null || diff === undefined || previous === false) {
return children ?? null; return children ?? null;
} }
if (previousIndicatorInverted === true) {
return ( return (
<> <>
<div <Badge
className={cn('flex items-center', [ className="flex gap-1"
state === 'positive' && 'text-emerald-500', variant={state === 'positive' ? 'destructive' : 'success'}
state === 'negative' && 'text-rose-500',
state === 'neutral' && 'text-slate-400',
])}
> >
{state === 'positive' && <ChevronUp size={20} />} {state === 'negative' && <TrendingUpIcon size={15} />}
{state === 'negative' && <ChevronDown size={20} />} {state === 'positive' && <TrendingDownIcon size={15} />}
{number.format(diff)}% {number.format(diff)}%
</div> </Badge>
{children}
</>
);
}
return (
<>
<Badge
className="flex gap-1"
variant={state === 'positive' ? 'success' : 'destructive'}
>
{state === 'positive' && <TrendingUpIcon size={15} />}
{state === 'negative' && <TrendingDownIcon size={15} />}
{number.format(diff)}%
</Badge>
{children} {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 { pushModal } from '@/modals';
import { useDispatch, useSelector } from '@/redux'; import { useDispatch, useSelector } from '@/redux';
import { SaveIcon } from 'lucide-react'; import { SaveIcon } from 'lucide-react';
import { useParams } from 'next/navigation';
import { resetDirty } from './reportSlice'; import { resetDirty } from './reportSlice';

View File

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

View File

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

View File

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

View File

@@ -32,7 +32,7 @@ export function ReportHistogramChart({
const formatDate = useFormatDateInterval(interval); const formatDate = useFormatDateInterval(interval);
const { series, setVisibleSeries } = useVisibleSeries(data); const { series, setVisibleSeries } = useVisibleSeries(data);
const rechartData = useRechartDataModel(data); const rechartData = useRechartDataModel(series);
return ( 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 type { IChartData } from '@/app/_trpc/client';
import { AutoSizer } from '@/components/AutoSizer'; import { AutoSizer } from '@/components/AutoSizer';
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval'; import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
@@ -35,7 +35,7 @@ export function ReportLineChart({
const { editMode, previous } = useChartContext(); const { editMode, previous } = useChartContext();
const formatDate = useFormatDateInterval(interval); const formatDate = useFormatDateInterval(interval);
const { series, setVisibleSeries } = useVisibleSeries(data); const { series, setVisibleSeries } = useVisibleSeries(data);
const rechartData = useRechartDataModel(data); const rechartData = useRechartDataModel(series);
return ( 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 type { IChartData } from '@/app/_trpc/client';
import { ColorSquare } from '@/components/ColorSquare';
import { useNumber } from '@/hooks/useNumerFormatter';
import { useVisibleSeries } from '@/hooks/useVisibleSeries'; import { useVisibleSeries } from '@/hooks/useVisibleSeries';
import { cn } from '@/utils/cn'; 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 { useChartContext } from './ChartProvider';
import { MetricCard } from './MetricCard';
interface ReportMetricChartProps { interface ReportMetricChartProps {
data: IChartData; data: IChartData;
} }
export function ReportMetricChart({ data }: ReportMetricChartProps) { export function ReportMetricChart({ data }: ReportMetricChartProps) {
const { editMode } = useChartContext(); const { editMode, metric, unit } = useChartContext();
const { series } = useVisibleSeries(data, editMode ? undefined : 2); const { series } = useVisibleSeries(data, editMode ? undefined : 2);
const color = theme?.colors['chart-0'];
const number = useNumber();
return ( return (
<div <div
className={cn( className={cn(
@@ -29,62 +21,12 @@ export function ReportMetricChart({ data }: ReportMetricChartProps) {
> >
{series.map((serie) => { {series.map((serie) => {
return ( return (
<div <MetricCard
className="relative border border-border p-4 rounded-md bg-white overflow-hidden"
key={serie.name} key={serie.name}
> serie={serie}
<div className="absolute -top-1 -left-1 -right-1 -bottom-1 z-0 opacity-20"> metric={metric}
<AutoSizer> unit={unit}
{({ 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>
); );
})} })}
</div> </div>

View File

@@ -1,10 +1,10 @@
import { useEffect, useRef, useState } from 'react';
import type { IChartData } from '@/app/_trpc/client'; import type { IChartData } from '@/app/_trpc/client';
import { AutoSizer } from '@/components/AutoSizer'; import { AutoSizer } from '@/components/AutoSizer';
import { useVisibleSeries } from '@/hooks/useVisibleSeries'; import { useVisibleSeries } from '@/hooks/useVisibleSeries';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import { round } from '@/utils/math'; import { round } from '@/utils/math';
import { getChartColor } from '@/utils/theme'; import { getChartColor } from '@/utils/theme';
import { truncate } from '@/utils/truncate';
import { Cell, Pie, PieChart, Tooltip } from 'recharts'; import { Cell, Pie, PieChart, Tooltip } from 'recharts';
import { useChartContext } from './ChartProvider'; import { useChartContext } from './ChartProvider';
@@ -15,66 +15,19 @@ interface ReportPieChartProps {
data: IChartData; 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) { export function ReportPieChart({ data }: ReportPieChartProps) {
const { editMode } = useChartContext(); const { editMode } = useChartContext();
const { series, setVisibleSeries } = useVisibleSeries(data); const { series, setVisibleSeries } = useVisibleSeries(data);
const sum = series.reduce((acc, serie) => acc + serie.metrics.sum, 0); 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) => ({
const pieData = series.map((serie) => {
return {
id: serie.name, id: serie.name,
color: getChartColor(serie.index), color: getChartColor(serie.index),
index: serie.index, index: serie.index,
label: serie.name, label: serie.name,
count: serie.metrics.sum, count: serie.metrics.sum,
percent: serie.metrics.sum / sum, percent: serie.metrics.sum / sum,
}; }));
});
return ( 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'; 'use client';
import { memo } from 'react'; import { memo } from 'react';
import type { RouterOutputs } from '@/app/_trpc/client';
import { api } from '@/app/_trpc/client'; import { api } from '@/app/_trpc/client';
import { useAppParams } from '@/hooks/useAppParams'; import { useAppParams } from '@/hooks/useAppParams';
import type { IChartInput } from '@/types'; import type { IChartInput } from '@/types';
@@ -11,10 +12,13 @@ import { ReportAreaChart } from './ReportAreaChart';
import { ReportBarChart } from './ReportBarChart'; import { ReportBarChart } from './ReportBarChart';
import { ReportHistogramChart } from './ReportHistogramChart'; import { ReportHistogramChart } from './ReportHistogramChart';
import { ReportLineChart } from './ReportLineChart'; import { ReportLineChart } from './ReportLineChart';
import { ReportMapChart } from './ReportMapChart';
import { ReportMetricChart } from './ReportMetricChart'; import { ReportMetricChart } from './ReportMetricChart';
import { ReportPieChart } from './ReportPieChart'; import { ReportPieChart } from './ReportPieChart';
export type ReportChartProps = IChartInput; export type ReportChartProps = IChartInput & {
initialData?: RouterOutputs['chart']['chart'];
};
export const Chart = memo( export const Chart = memo(
withChartProivder(function Chart({ withChartProivder(function Chart({
@@ -26,18 +30,23 @@ export const Chart = memo(
range, range,
lineType, lineType,
previous, previous,
formula,
unit,
metric,
initialData,
}: ReportChartProps) { }: ReportChartProps) {
const params = useAppParams(); const params = useAppParams();
const hasEmptyFilters = events.some((event) => const hasEmptyFilters = events.some((event) =>
event.filters.some((filter) => filter.value.length === 0) event.filters.some((filter) => filter.value.length === 0)
); );
const enabled = events.length > 0 && !hasEmptyFilters; const enabled = events.length > 0 && !hasEmptyFilters;
const chart = api.chart.chart.useQuery( const chart = api.chart.chart.useQuery(
{ {
interval,
chartType,
// dont send lineType since it does not need to be sent // dont send lineType since it does not need to be sent
lineType: 'monotone', lineType: 'monotone',
interval,
chartType,
events, events,
breakdowns, breakdowns,
name, name,
@@ -46,10 +55,14 @@ export const Chart = memo(
endDate: null, endDate: null,
projectId: params.projectId, projectId: params.projectId,
previous, previous,
formula,
unit,
metric,
}, },
{ {
keepPreviousData: false, keepPreviousData: true,
enabled, enabled,
initialData,
} }
); );
@@ -66,10 +79,10 @@ export const Chart = memo(
); );
} }
if (chart.isFetching) { if (chart.isLoading) {
return ( return (
<ChartAnimationContainer> <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> <p className="text-center font-medium">Loading...</p>
</ChartAnimationContainer> </ChartAnimationContainer>
); );
@@ -99,6 +112,10 @@ export const Chart = memo(
); );
} }
if (chartType === 'map') {
return <ReportMapChart data={chart.data} />;
}
if (chartType === 'histogram') { if (chartType === 'histogram') {
return <ReportHistogramChart interval={interval} data={chart.data} />; return <ReportHistogramChart interval={interval} data={chart.data} />;
} }

View File

@@ -27,6 +27,8 @@ type InitialState = IChartInput & {
const initialState: InitialState = { const initialState: InitialState = {
ready: false, ready: false,
dirty: false, dirty: false,
// TODO: remove this
projectId: '',
name: 'Untitled', name: 'Untitled',
chartType: 'linear', chartType: 'linear',
lineType: 'monotone', lineType: 'monotone',
@@ -37,6 +39,9 @@ const initialState: InitialState = {
startDate: null, startDate: null,
endDate: null, endDate: null,
previous: false, previous: false,
formula: undefined,
unit: undefined,
metric: 'sum',
}; };
export const reportSlice = createSlice({ 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 // Breakdowns
addBreakdown: ( addBreakdown: (
state, state,
@@ -181,6 +192,12 @@ export const reportSlice = createSlice({
state.range = action.payload; state.range = action.payload;
state.interval = getDefaultIntervalByRange(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, changeChartType,
changeLineType, changeLineType,
resetDirty, resetDirty,
changeFormula,
changePrevious,
} = reportSlice.actions; } = reportSlice.actions;
export default reportSlice.reducer; 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 { export interface ReportEventMoreProps {
onClick: (action: 'createFilter' | 'remove') => void; onClick: (action: 'remove') => void;
} }
export function ReportEventMore({ onClick }: ReportEventMoreProps) { export function ReportEventMore({ onClick }: ReportEventMoreProps) {
@@ -49,10 +49,6 @@ export function ReportEventMore({ onClick }: ReportEventMoreProps) {
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[200px]"> <DropdownMenuContent align="end" className="w-[200px]">
<DropdownMenuGroup> <DropdownMenuGroup>
<DropdownMenuItem onClick={() => onClick('createFilter')}>
<Filter className="mr-2 h-4 w-4" />
Add filter
</DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
className="text-red-600" className="text-red-600"

View File

@@ -1,25 +1,31 @@
'use client'; 'use client';
import { useState } from 'react';
import { api } from '@/app/_trpc/client'; import { api } from '@/app/_trpc/client';
import { ColorSquare } from '@/components/ColorSquare'; import { ColorSquare } from '@/components/ColorSquare';
import { Dropdown } from '@/components/Dropdown'; import { Dropdown } from '@/components/Dropdown';
import { Checkbox } from '@/components/ui/checkbox';
import { Combobox } from '@/components/ui/combobox'; import { Combobox } from '@/components/ui/combobox';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { useAppParams } from '@/hooks/useAppParams'; import { useAppParams } from '@/hooks/useAppParams';
import { useDebounceFn } from '@/hooks/useDebounceFn'; import { useDebounceFn } from '@/hooks/useDebounceFn';
import { useDispatch, useSelector } from '@/redux'; import { useDispatch, useSelector } from '@/redux';
import type { IChartEvent } from '@/types'; import type { IChartEvent } from '@/types';
import { Filter, GanttChart, Users } from 'lucide-react'; import { GanttChart, Users } from 'lucide-react';
import { useParams } from 'next/navigation';
import { addEvent, changeEvent, removeEvent } from '../reportSlice'; import {
import { ReportEventFilters } from './ReportEventFilters'; 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 { ReportEventMore } from './ReportEventMore';
import type { ReportEventMoreProps } from './ReportEventMore'; import type { ReportEventMoreProps } from './ReportEventMore';
export function ReportEvents() { export function ReportEvents() {
const [isCreating, setIsCreating] = useState(false); const previous = useSelector((state) => state.report.previous);
const selectedEvents = useSelector((state) => state.report.events); const selectedEvents = useSelector((state) => state.report.events);
const dispatch = useDispatch(); const dispatch = useDispatch();
const params = useAppParams(); const params = useAppParams();
@@ -37,9 +43,6 @@ export function ReportEvents() {
const handleMore = (event: IChartEvent) => { const handleMore = (event: IChartEvent) => {
const callback: ReportEventMoreProps['onClick'] = (action) => { const callback: ReportEventMoreProps['onClick'] = (action) => {
switch (action) { switch (action) {
case 'createFilter': {
return setIsCreating(true);
}
case 'remove': { case 'remove': {
return dispatch(removeEvent(event)); return dispatch(removeEvent(event));
} }
@@ -111,12 +114,20 @@ export function ReportEvents() {
}, },
{ {
value: 'user_average', value: 'user_average',
label: 'Unique users (average)', label: 'Average event per user',
}, },
{ {
value: 'one_event_per_user', value: 'one_event_per_user',
label: '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" label="Segment"
> >
@@ -127,12 +138,20 @@ export function ReportEvents() {
</> </>
) : event.segment === 'user_average' ? ( ) : event.segment === 'user_average' ? (
<> <>
<Users size={12} /> Unique users (average) <Users size={12} /> Average event per user
</> </>
) : event.segment === 'one_event_per_user' ? ( ) : event.segment === 'one_event_per_user' ? (
<> <>
<Users size={12} /> 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 <GanttChart size={12} /> All events
@@ -140,18 +159,17 @@ export function ReportEvents() {
)} )}
</button> </button>
</Dropdown> </Dropdown>
<button {/* */}
onClick={() => { <FiltersCombobox event={event} />
handleMore(event)('createFilter');
}} {(event.segment === 'property_average' ||
className="flex items-center gap-1 rounded-md border border-border p-1 px-2 font-medium leading-none text-xs" event.segment === 'property_sum') && (
> <EventPropertiesCombobox event={event} />
<Filter size={12} /> Filter )}
</button>
</div> </div>
{/* Filters */} {/* Filters */}
<ReportEventFilters {...{ isCreating, setIsCreating, event }} /> <FiltersList event={event} />
</div> </div>
); );
})} })}
@@ -172,6 +190,17 @@ export function ReportEvents() {
placeholder="Select event" placeholder="Select event"
/> />
</div> </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> </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 { ReportBreakdowns } from './ReportBreakdowns';
import { ReportEvents } from './ReportEvents'; import { ReportEvents } from './ReportEvents';
import { ReportForumula } from './ReportForumula';
export function ReportSidebar() { export function ReportSidebar() {
return ( return (
<div className="flex flex-col gap-8 pb-12"> <div className="flex flex-col gap-8 pb-12">
<ReportEvents /> <ReportEvents />
<ReportForumula />
<ReportBreakdowns /> <ReportBreakdowns />
<div className="absolute bottom-0 left-0 right-0 p-6 bg-gradient-to-t from-white/100 to-white/0"> <div className="absolute bottom-0 left-0 right-0 p-6 bg-gradient-to-t from-white/100 to-white/0">
<SheetClose asChild> <SheetClose asChild>

View File

@@ -1,18 +1,8 @@
import type { Dispatch } from 'react';
import { api } from '@/app/_trpc/client'; import { api } from '@/app/_trpc/client';
import { ColorSquare } from '@/components/ColorSquare'; import { ColorSquare } from '@/components/ColorSquare';
import { Dropdown } from '@/components/Dropdown'; import { Dropdown } from '@/components/Dropdown';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced'; 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 { RenderDots } from '@/components/ui/RenderDots';
import { useAppParams } from '@/hooks/useAppParams'; import { useAppParams } from '@/hooks/useAppParams';
import { useMappings } from '@/hooks/useMappings'; import { useMappings } from '@/hooks/useMappings';
@@ -23,93 +13,24 @@ import type {
IChartEventFilterValue, IChartEventFilterValue,
} from '@/types'; } from '@/types';
import { operators } from '@/utils/constants'; import { operators } from '@/utils/constants';
import { CreditCard, SlidersHorizontal, Trash } from 'lucide-react'; import { SlidersHorizontal, Trash } from 'lucide-react';
import { useParams } from 'next/navigation'; import { useParams } from 'next/navigation';
import { changeEvent } from '../reportSlice'; 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>
);
}
interface FilterProps { interface FilterProps {
event: IChartEvent; event: IChartEvent;
filter: IChartEvent['filters'][number]; filter: IChartEvent['filters'][number];
} }
function Filter({ filter, event }: FilterProps) { export function FilterItem({ filter, event }: FilterProps) {
const params = useParams<{ organizationId: string; projectId: string }>(); const { projectId } = useAppParams();
const getLabel = useMappings(); const getLabel = useMappings();
const dispatch = useDispatch(); const dispatch = useDispatch();
const potentialValues = api.chart.values.useQuery({ const potentialValues = api.chart.values.useQuery({
event: event.name, event: event.name,
property: filter.name, property: filter.name,
projectId: params?.projectId!, projectId,
}); });
const valuesCombobox = 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'; import type { VariantProps } from 'class-variance-authority';
const badgeVariants = cva( 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: { variants: {
variant: { variant: {
@@ -16,6 +16,8 @@ const badgeVariants = cva(
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80', 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive: destructive:
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80', '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', outline: 'text-foreground',
}, },
}, },

View File

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

View File

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

View File

@@ -32,7 +32,7 @@ const SheetOverlay = React.forwardRef<
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName; SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva( 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: { variants: {
side: { side: {

View File

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

View File

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

View File

@@ -4,20 +4,20 @@ import { getChartColor } from '@/utils/theme';
export type IRechartPayloadItem = IChartSerieDataItem & { color: string }; export type IRechartPayloadItem = IChartSerieDataItem & { color: string };
export function useRechartDataModel(data: IChartData) { export function useRechartDataModel(series: IChartData['series']) {
return useMemo(() => { return useMemo(() => {
return ( return (
data.series[0]?.data.map(({ date }) => { series[0]?.data.map(({ date }) => {
return { return {
date, date,
...data.series.reduce((acc, serie, idx) => { ...series.reduce((acc, serie, idx) => {
return { return {
...acc, ...acc,
...serie.data.reduce( ...serie.data.reduce(
(acc2, item) => { (acc2, item) => {
if (item.date === date) { if (item.date === date) {
if (item.previous) { if (item.previous) {
acc2[`${idx}:prev:count`] = item.previous.count; acc2[`${idx}:prev:count`] = item.previous.value;
} }
acc2[`${idx}:count`] = item.count; acc2[`${idx}:count`] = item.count;
acc2[`${idx}:payload`] = { 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 { useEffect, useMemo, useRef, useState } from 'react';
import type { IChartData } from '@/app/_trpc/client'; import type { IChartData } from '@/app/_trpc/client';
export type IVisibleSeries = ReturnType<typeof useVisibleSeries>['series'];
export function useVisibleSeries(data: IChartData, limit?: number | undefined) { export function useVisibleSeries(data: IChartData, limit?: number | undefined) {
const max = limit ?? 5; const max = limit ?? 5;
const [visibleSeries, setVisibleSeries] = useState<string[]>([]); const [visibleSeries, setVisibleSeries] = useState<string[]>([]);

View File

@@ -10,7 +10,7 @@ import { toast } from '@/components/ui/use-toast';
import { useAppParams } from '@/hooks/useAppParams'; import { useAppParams } from '@/hooks/useAppParams';
import type { IChartInput } from '@/types'; import type { IChartInput } from '@/types';
import { zodResolver } from '@hookform/resolvers/zod'; 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 { Controller, useForm } from 'react-hook-form';
import { z } from 'zod'; 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 { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
import * as cache from '@/server/cache'; import type { IChartEvent, IChartInput, IChartRange } from '@/types';
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 { getDaysOldDate } from '@/utils/date'; import { getDaysOldDate } from '@/utils/date';
import { average, round, sum } from '@/utils/math'; import { average, max, min, round, sum } from '@/utils/math';
import { toDots } from '@/utils/object'; import { zChartInput } from '@/utils/validation';
import { zChartInputWithDates } from '@/utils/validation'; import { flatten, map, pathOr, pipe, prop, sort, uniq } from 'ramda';
import { pipe, sort, uniq } from 'ramda';
import { z } from 'zod'; 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({ export const chartRouter = createTRPCRouter({
events: protectedProcedure events: protectedProcedure
.input(z.object({ projectId: z.string() })) .input(z.object({ projectId: z.string() }))
.query(async ({ input: { projectId } }) => { .query(async ({ input: { projectId } }) => {
const events = await cache.getOr( const events = await chQuery<{ name: string }>(
`events_${projectId}`, `SELECT DISTINCT name FROM events WHERE project_id = '${projectId}'`
1000 * 60 * 60 * 24,
() => getUniqueEvents({ projectId: projectId })
); );
return [ return [
@@ -39,32 +64,36 @@ export const chartRouter = createTRPCRouter({
properties: protectedProcedure properties: protectedProcedure
.input(z.object({ event: z.string().optional(), projectId: z.string() })) .input(z.object({ event: z.string().optional(), projectId: z.string() }))
.query(async ({ input: { projectId, event } }) => { .query(async ({ input: { projectId, event } }) => {
const events = await cache.getOr( const events = await chQuery<{ keys: string[] }>(
`events_${projectId}_${event ?? 'all'}`, `SELECT distinct mapKeys(properties) as keys from events where ${
1000 * 60 * 60, event && event !== '*' ? `name = '${event}' AND ` : ''
() => } project_id = '${projectId}';`
db.event.findMany({
take: 500,
distinct: 'name',
where: {
project_id: projectId,
...(event
? {
name: event,
}
: {}),
},
})
); );
const properties = events const properties = events
.reduce((acc, event) => { .flatMap((event) => event.keys)
const properties = event as Record<string, unknown>;
const dotNotation = toDots(properties);
return [...acc, ...Object.keys(dotNotation)];
}, [] as string[])
.map((item) => item.replace(/\.([0-9]+)\./g, '.*.')) .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( return pipe(
sort<string>((a, b) => a.length - b.length), sort<string>((a, b) => a.length - b.length),
@@ -81,46 +110,30 @@ export const chartRouter = createTRPCRouter({
}) })
) )
.query(async ({ input: { event, property, projectId } }) => { .query(async ({ input: { event, property, projectId } }) => {
const intervalInDays = 180; const sql = property.startsWith('properties.')
if (isJsonPath(property)) { ? `SELECT distinct mapValues(mapExtractKeyLike(properties, '${property
const events = await db.$queryRawUnsafe<{ value: string }[]>( .replace(/^properties\./, '')
`SELECT ${selectJsonPath( .replace(
property '.*.',
)} AS value from events WHERE project_id = '${projectId}' AND name = '${event}' AND "createdAt" >= NOW() - INTERVAL '${intervalInDays} days'` '.%.'
); )}')) 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 { return {
values: uniq(events.map((item) => item.value)), values,
}; };
} 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
),
},
},
distinct: property as any,
select: {
[property]: true,
},
});
return {
values: uniq(events.map((item) => item[property]!)),
};
}
}), }),
chart: protectedProcedure chart: protectedProcedure.input(zChartInput).query(async ({ input }) => {
.input(zChartInputWithDates.merge(z.object({ projectId: z.string() })))
.query(async ({ input }) => {
const current = getDatesFromRange(input.range); const current = getDatesFromRange(input.range);
let diff = 0; let diff = 0;
@@ -139,7 +152,7 @@ export const chartRouter = createTRPCRouter({
break; break;
} }
case '7d': { case '7d': {
diff = 1000 * 60 * 60 * 24 * 17; diff = 1000 * 60 * 60 * 24 * 7;
break; break;
} }
case '14d': { case '14d': {
@@ -160,11 +173,21 @@ export const chartRouter = createTRPCRouter({
} }
} }
const promises = [wrapper(input)]; const promises = [getSeriesFromEvents(input)];
if (input.previous) { 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( promises.push(
wrapper({ getSeriesFromEvents({
...input, ...input,
...{ ...{
startDate: new Date( startDate: new Date(
@@ -178,65 +201,135 @@ export const chartRouter = createTRPCRouter({
); );
} }
const awaitedPromises = await Promise.all(promises); const result = await Promise.all(promises);
const data = awaitedPromises[0]!; const series = result[0]!;
const previousData = awaitedPromises[1]; 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 { return {
...data, name: serie.name,
series: data.series.map((item, sIndex) => { event: serie.event,
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: { metrics: {
...item.metrics, ...metrics,
previous: { previous: {
sum: getPreviousDiff('sum'), sum: getPreviousMetric(
average: getPreviousDiff('average'), 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
),
}, },
}, },
data: item.data.map((item, dIndex) => { data: serie.data.map((item, index) => ({
const diff = getPreviousDataDiff( date: item.date,
item.count, count: item.count ?? 0,
previousData?.series?.[sIndex]?.data?.[dIndex]?.count label: item.label,
); previous: previousSerie?.data[index]
return { ? getPreviousMetric(
...item, item.count ?? 0,
previous: previousSerie?.data[index]?.count ?? null
diff && previousData?.series?.[sIndex]?.data?.[dIndex]
? Object.assign(
{},
previousData?.series?.[sIndex]?.data?.[dIndex],
diff
) )
: 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];
}
});
return final;
}), }),
}); });
const chartValidator = zChartInputWithDates.merge( function getPreviousMetric(
z.object({ projectId: z.string() }) current: number,
); previous: number | null
type ChartInput = z.infer<typeof chartValidator>; ): PreviousValue {
if (previous === null) {
function getPreviousDataDiff(current: number, previous: number | undefined) {
if (!previous) {
return null; return null;
} }
@@ -262,10 +355,11 @@ function getPreviousDataDiff(current: number, previous: number | undefined) {
: current < previous : current < previous
? 'negative' ? 'negative'
: 'neutral', : 'neutral',
value: previous,
}; };
} }
async function wrapper({ events, projectId, ...input }: ChartInput) { async function getSeriesFromEvents(input: IChartInput) {
const { startDate, endDate } = const { startDate, endDate } =
input.startDate && input.endDate input.startDate && input.endDate
? { ? {
@@ -273,65 +367,30 @@ async function wrapper({ events, projectId, ...input }: ChartInput) {
endDate: input.endDate, endDate: input.endDate,
} }
: getDatesFromRange(input.range); : getDatesFromRange(input.range);
const series: Awaited<ReturnType<typeof getChartData>> = [];
for (const event of events) { const series = (
const result = await getChartData({ await Promise.all(
input.events.map(async (event) =>
getChartData({
...input, ...input,
startDate, startDate,
endDate, endDate,
event, 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>
) )
).map(([id, count]) => ({ )
count, ).flat();
...events.find((event) => event.id === id)!,
})),
series: sorted,
metrics,
};
}
interface ResultItem { return withFormula(input, series);
label: string | null; // .sort((a, b) => {
count: number; // if (input.chartType === 'linear') {
date: string; // 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;
function getEventLegend(event: IChartEvent) { // } else {
return event.displayName ?? `${event.name} (${event.id})`; // return b.metrics.sum - a.metrics.sum;
// }
// });
} }
function getDatesFromRange(range: IChartRange) { function getDatesFromRange(range: IChartRange) {
@@ -385,206 +444,3 @@ function getDatesFromRange(range: IChartRange) {
endDate: endDate.toISOString(), 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 { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
import { db } from '@/server/db'; import { transformEvent } from '@/server/services/event.service';
import { z } from 'zod'; import { z } from 'zod';
import type { Event, Profile } from '@mixan/db'; import type { IDBEvent } from '@mixan/db';
import { chQuery, createSqlBuilder } from '@mixan/db';
function transformEvent(
event: Event & {
profile: Profile;
}
) {
return {
...event,
properties: event.properties as Record<string, unknown>,
};
}
export const eventRouter = createTRPCRouter({ export const eventRouter = createTRPCRouter({
list: protectedProcedure list: protectedProcedure
@@ -27,28 +17,18 @@ export const eventRouter = createTRPCRouter({
}) })
) )
.query(async ({ input: { take, skip, projectId, profileId, events } }) => { .query(async ({ input: { take, skip, projectId, profileId, events } }) => {
return db.event const { sb, getSql } = createSqlBuilder();
.findMany({
take, sb.limit = take;
skip, sb.offset = skip;
where: { sb.where.projectId = `project_id = '${projectId}'`;
project_id: projectId, if (profileId) {
profile_id: profileId, sb.where.profileId = `profile_id = '${profileId}'`;
...(events && events.length > 0
? {
name: {
in: events,
},
} }
: {}), if (events?.length) {
}, sb.where.name = `name IN (${events.map((e) => `'${e}'`).join(',')})`;
orderBy: { }
createdAt: 'desc',
}, return (await chQuery<IDBEvent>(getSql())).map(transformEvent);
include: {
profile: true,
},
})
.then((events) => events.map(transformEvent));
}), }),
}); });

View File

@@ -53,7 +53,7 @@ export const reportRouter = createTRPCRouter({
save: protectedProcedure save: protectedProcedure
.input( .input(
z.object({ z.object({
report: zChartInput, report: zChartInput.omit({ projectId: true }),
dashboardId: z.string(), dashboardId: z.string(),
}) })
) )
@@ -74,6 +74,7 @@ export const reportRouter = createTRPCRouter({
chart_type: report.chartType, chart_type: report.chartType,
line_type: report.lineType, line_type: report.lineType,
range: report.range, range: report.range,
formula: report.formula,
}, },
}); });
}), }),
@@ -81,7 +82,7 @@ export const reportRouter = createTRPCRouter({
.input( .input(
z.object({ z.object({
reportId: z.string(), reportId: z.string(),
report: zChartInput, report: zChartInput.omit({ projectId: true }),
}) })
) )
.mutation(({ input: { report, reportId } }) => { .mutation(({ input: { report, reportId } }) => {
@@ -97,6 +98,7 @@ export const reportRouter = createTRPCRouter({
chart_type: report.chartType, chart_type: report.chartType,
line_type: report.lineType, line_type: report.lineType,
range: report.range, 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'; 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 }) { export function getUniqueEvents({ projectId }: { projectId: string }) {
return db.event.findMany({ return db.event.findMany({
take: 500, 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 { unstable_cache } from 'next/cache';
import { chQuery } from '@mixan/db';
import { db } from '../db'; import { db } from '../db';
export type IServiceProject = Awaited<ReturnType<typeof getProjectById>>; 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) { export function getFirstProjectByOrganizationId(organizationId: string) {
const tag = `getFirstProjectByOrganizationId_${organizationId}`; const tag = `getFirstProjectByOrganizationId_${organizationId}`;
return unstable_cache( return unstable_cache(

View File

@@ -26,7 +26,7 @@ export function transformFilter(
}; };
} }
export function transformEvent( export function transformReportEvent(
event: Partial<IChartEvent>, event: Partial<IChartEvent>,
index: number index: number
): IChartEvent { ): IChartEvent {
@@ -36,6 +36,7 @@ export function transformEvent(
id: event.id ?? alphabetIds[index]!, id: event.id ?? alphabetIds[index]!,
name: event.name || 'unknown_event', name: event.name || 'unknown_event',
displayName: event.displayName, displayName: event.displayName,
property: event.property,
}; };
} }
@@ -44,7 +45,8 @@ export function transformReport(
): IChartInput & { id: string } { ): IChartInput & { id: string } {
return { return {
id: report.id, 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[], breakdowns: report.breakdowns as IChartBreakdown[],
chartType: report.chart_type, chartType: report.chart_type,
lineType: (report.line_type ?? 'kuk') as IChartLineType, lineType: (report.line_type ?? 'kuk') as IChartLineType,
@@ -52,6 +54,9 @@ export function transformReport(
name: report.name || 'Untitled', name: report.name || 'Untitled',
range: (report.range as IChartRange) ?? timeRanges['1m'], range: (report.range as IChartRange) ?? timeRanges['1m'],
previous: report.previous ?? false, 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, zChartBreakdown,
zChartEvent, zChartEvent,
zChartInput, zChartInput,
zChartInputWithDates,
zChartType, zChartType,
zLineType, zLineType,
zMetric,
zTimeInterval, zTimeInterval,
} from '@/utils/validation'; } from '@/utils/validation';
import type { TooltipProps } from 'recharts'; import type { TooltipProps } from 'recharts';
@@ -19,7 +19,6 @@ export type HtmlProps<T> = Omit<
>; >;
export type IChartInput = z.infer<typeof zChartInput>; export type IChartInput = z.infer<typeof zChartInput>;
export type IChartInputWithDates = z.infer<typeof zChartInputWithDates>;
export type IChartEvent = z.infer<typeof zChartEvent>; export type IChartEvent = z.infer<typeof zChartEvent>;
export type IChartEventFilter = IChartEvent['filters'][number]; export type IChartEventFilter = IChartEvent['filters'][number];
export type IChartEventFilterValue = export type IChartEventFilterValue =
@@ -27,6 +26,7 @@ export type IChartEventFilterValue =
export type IChartBreakdown = z.infer<typeof zChartBreakdown>; export type IChartBreakdown = z.infer<typeof zChartBreakdown>;
export type IInterval = z.infer<typeof zTimeInterval>; export type IInterval = z.infer<typeof zTimeInterval>;
export type IChartType = z.infer<typeof zChartType>; export type IChartType = z.infer<typeof zChartType>;
export type IChartMetric = z.infer<typeof zMetric>;
export type IChartLineType = z.infer<typeof zLineType>; export type IChartLineType = z.infer<typeof zLineType>;
export type IChartRange = keyof typeof timeRanges; export type IChartRange = keyof typeof timeRanges;
export type IToolTipProps<T> = Omit<TooltipProps<number, string>, 'payload'> & { export type IToolTipProps<T> = Omit<TooltipProps<number, string>, 'payload'> & {
@@ -43,7 +43,4 @@ export type IGetChartDataInput = {
projectId: string; projectId: string;
startDate: string; startDate: string;
endDate: string; endDate: string;
} & Omit< } & Omit<IChartInput, 'events' | 'name' | 'startDate' | 'endDate' | 'range'>;
IChartInputWithDates,
'events' | 'name' | 'startDate' | 'endDate' | 'range'
>;

View File

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

View File

@@ -1,12 +1,22 @@
import { isNumber } from 'mathjs';
export const round = (num: number, decimals = 2) => { export const round = (num: number, decimals = 2) => {
const factor = Math.pow(10, decimals); const factor = Math.pow(10, decimals);
return Math.round((num + Number.EPSILON) * factor) / factor; return Math.round((num + Number.EPSILON) * factor) / factor;
}; };
export const average = (arr: number[]) => export const average = (arr: (number | null)[]) => {
arr.reduce((p, c) => p + c, 0) / arr.length; const filtered = arr.filter(isNumber);
return filtered.reduce((p, c) => p + c, 0) / filtered.length;
};
export const sum = (arr: number[]) => export const sum = (arr: (number | null)[]): number =>
round(arr.reduce((acc, item) => acc + item, 0)); 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; 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, chartTypes,
intervals, intervals,
lineTypes, lineTypes,
metrics,
operators, operators,
timeRanges, timeRanges,
} from './constants'; } from './constants';
@@ -15,11 +16,21 @@ export function objectToZodEnums<K extends string>(
return [firstKey!, ...otherKeys]; return [firstKey!, ...otherKeys];
} }
export const mapKeys = objectToZodEnums;
export const zChartEvent = z.object({ export const zChartEvent = z.object({
id: z.string(), id: z.string(),
name: z.string(), name: z.string(),
displayName: z.string().optional(), displayName: z.string().optional(),
segment: z.enum(['event', 'user', 'user_average', '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( filters: z.array(
z.object({ z.object({
id: z.string(), id: z.string(),
@@ -43,6 +54,8 @@ export const zLineType = z.enum(objectToZodEnums(lineTypes));
export const zTimeInterval = z.enum(objectToZodEnums(intervals)); export const zTimeInterval = z.enum(objectToZodEnums(intervals));
export const zMetric = z.enum(objectToZodEnums(metrics));
export const zChartInput = z.object({ export const zChartInput = z.object({
name: z.string(), name: z.string(),
chartType: zChartType, chartType: zChartType,
@@ -52,9 +65,11 @@ export const zChartInput = z.object({
breakdowns: zChartBreakdowns, breakdowns: zChartBreakdowns,
range: z.enum(objectToZodEnums(timeRanges)), range: z.enum(objectToZodEnums(timeRanges)),
previous: z.boolean(), previous: z.boolean(),
}); formula: z.string().optional(),
metric: zMetric,
export const zChartInputWithDates = zChartInput.extend({ unit: z.string().optional(),
previousIndicatorInverted: z.boolean().optional(),
projectId: z.string(),
startDate: z.string().nullish(), startDate: z.string().nullish(),
endDate: z.string().nullable(), endDate: z.string().nullish(),
}); });

View File

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

View File

@@ -5,10 +5,12 @@ import { Worker } from 'bullmq';
import express from 'express'; import express from 'express';
import { connection, eventsQueue } from '@mixan/queue'; import { connection, eventsQueue } from '@mixan/queue';
import { cronQueue } from '@mixan/queue/src/queues';
import { cronJob } from './jobs/cron';
import { eventsJob } from './jobs/events'; import { eventsJob } from './jobs/events';
const PORT = process.env.PORT || 3001; const PORT = process.env.PORT || 3000;
const serverAdapter = new ExpressAdapter(); const serverAdapter = new ExpressAdapter();
serverAdapter.setBasePath('/'); serverAdapter.setBasePath('/');
const app = express(); const app = express();
@@ -17,13 +19,43 @@ new Worker(eventsQueue.name, eventsJob, {
connection, connection,
}); });
createBullBoard({ new Worker(cronQueue.name, cronJob, {
queues: [new BullMQAdapter(eventsQueue)], connection,
});
async function start() {
createBullBoard({
queues: [new BullMQAdapter(eventsQueue), new BullMQAdapter(cronQueue)],
serverAdapter: serverAdapter, serverAdapter: serverAdapter,
}); });
app.use('/', serverAdapter.getRouter()); app.use('/', serverAdapter.getRouter());
app.listen(PORT, () => { app.listen(PORT, () => {
console.log(`For the UI, open http://localhost:${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