a lot
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -2,7 +2,9 @@
|
||||
packages/sdk/profileId.txt
|
||||
packages/sdk/test.ts
|
||||
dump.sql
|
||||
dump-*.sql
|
||||
dump-*
|
||||
.sql
|
||||
clickhouse
|
||||
|
||||
# Logs
|
||||
|
||||
|
||||
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
@@ -15,5 +15,8 @@
|
||||
"typescript.preferences.autoImportFileExcludePatterns": [
|
||||
"next/router.d.ts",
|
||||
"next/dist/client/router.d.ts"
|
||||
]
|
||||
],
|
||||
"[sql]": {
|
||||
"editor.defaultFormatter": "adpyke.vscode-sql-formatter"
|
||||
}
|
||||
}
|
||||
|
||||
15
ROADMAP.md
Normal file
15
ROADMAP.md
Normal 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
43
apps/sdk-api/package.json
Normal 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"
|
||||
}
|
||||
226
apps/sdk-api/scripts/test-events.ts
Normal file
226
apps/sdk-api/scripts/test-events.ts
Normal 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();
|
||||
176
apps/sdk-api/src/controllers/event.controller.ts
Normal file
176
apps/sdk-api/src/controllers/event.controller.ts
Normal 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
78
apps/sdk-api/src/index.ts
Normal 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();
|
||||
18
apps/sdk-api/src/routes/event.router.ts
Normal file
18
apps/sdk-api/src/routes/event.router.ts
Normal 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;
|
||||
43
apps/sdk-api/src/utils/auth.ts
Normal file
43
apps/sdk-api/src/utils/auth.ts
Normal 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;
|
||||
}
|
||||
25
apps/sdk-api/src/utils/parseIp.ts
Normal file
25
apps/sdk-api/src/utils/parseIp.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
29
apps/sdk-api/src/utils/parseUserAgent.ts
Normal file
29
apps/sdk-api/src/utils/parseUserAgent.ts
Normal 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';
|
||||
}
|
||||
12
apps/sdk-api/tsconfig.json
Normal file
12
apps/sdk-api/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "@mixan/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["."],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
18
apps/sdk-api/tsup.config.ts
Normal file
18
apps/sdk-api/tsup.config.ts
Normal 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);
|
||||
@@ -12,7 +12,9 @@
|
||||
"with-env": "dotenv -e ../../.env -c --"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clickhouse/client": "^0.2.9",
|
||||
"@hookform/resolvers": "^3.3.2",
|
||||
"@mixan/common": "workspace:^",
|
||||
"@mixan/db": "workspace:^",
|
||||
"@mixan/queue": "workspace:^",
|
||||
"@mixan/types": "workspace:*",
|
||||
@@ -44,6 +46,7 @@
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"lottie-react": "^2.4.0",
|
||||
"lucide-react": "^0.286.0",
|
||||
"mathjs": "^12.3.0",
|
||||
"mitt": "^3.0.1",
|
||||
"next": "~14.0.4",
|
||||
"next-auth": "^4.23.0",
|
||||
@@ -58,9 +61,11 @@
|
||||
"react-in-viewport": "1.0.0-alpha.30",
|
||||
"react-redux": "^8.1.3",
|
||||
"react-responsive": "^9.0.2",
|
||||
"react-svg-worldmap": "2.0.0-alpha.16",
|
||||
"react-syntax-highlighter": "^15.5.0",
|
||||
"react-virtualized-auto-sizer": "^1.0.20",
|
||||
"recharts": "^2.8.0",
|
||||
"request-ip": "^3.3.0",
|
||||
"slugify": "^1.6.6",
|
||||
"superjson": "^1.13.1",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
@@ -79,6 +84,7 @@
|
||||
"@types/react": "^18.2.20",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"@types/react-syntax-highlighter": "^15.5.9",
|
||||
"@types/request-ip": "^0.0.41",
|
||||
"@typescript-eslint/eslint-plugin": "^6.6.0",
|
||||
"@typescript-eslint/parser": "^6.6.0",
|
||||
"autoprefixer": "^10.4.14",
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { StickyBelowHeader } from '@/app/(app)/layout-sticky-below-header';
|
||||
import { LazyChart } from '@/components/report/chart/LazyChart';
|
||||
import { ReportRange } from '@/components/report/ReportRange';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import {
|
||||
@@ -19,7 +20,7 @@ import { cn } from '@/utils/cn';
|
||||
import { getDefaultIntervalByRange, timeRanges } from '@/utils/constants';
|
||||
import { ChevronRight, MoreHorizontal, PlusIcon, Trash } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
interface ListReportsProps {
|
||||
reports: Awaited<ReturnType<typeof getReportsByDashboardId>>;
|
||||
@@ -33,17 +34,10 @@ export function ListReports({ reports }: ListReportsProps) {
|
||||
return (
|
||||
<>
|
||||
<StickyBelowHeader className="p-4 items-center justify-between flex">
|
||||
<Combobox
|
||||
className="min-w-0"
|
||||
<ReportRange
|
||||
placeholder="Override range"
|
||||
value={range}
|
||||
onChange={(value) => {
|
||||
setRange((p) => (p === value ? null : value));
|
||||
}}
|
||||
items={Object.values(timeRanges).map((key) => ({
|
||||
label: key,
|
||||
value: key,
|
||||
}))}
|
||||
onChange={(value) => setRange((p) => (p === value ? null : value))}
|
||||
/>
|
||||
<Button
|
||||
icon={PlusIcon}
|
||||
@@ -4,7 +4,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { pushModal } from '@/modals';
|
||||
import { PlusIcon } from 'lucide-react';
|
||||
|
||||
import { StickyBelowHeader } from '../../layout-sticky-below-header';
|
||||
import { StickyBelowHeader } from '../../../layout-sticky-below-header';
|
||||
|
||||
interface HeaderDashboardsProps {
|
||||
projectId: string;
|
||||
@@ -53,7 +53,7 @@ export function ListDashboards({ dashboards }: ListDashboardsProps) {
|
||||
<Card key={item.id} hover>
|
||||
<div>
|
||||
<Link
|
||||
href={`/${organizationId}/${projectId}/${item.id}`}
|
||||
href={`/${organizationId}/${projectId}/dashboards/${item.id}`}
|
||||
className="block p-4 flex flex-col"
|
||||
>
|
||||
<span className="font-medium">{item.name}</span>
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -6,11 +6,9 @@ import { ListProperties } from '@/components/events/ListProperties';
|
||||
import { ExpandableListItem } from '@/components/general/ExpandableListItem';
|
||||
import { ProfileAvatar } from '@/components/profiles/ProfileAvatar';
|
||||
import { useAppParams } from '@/hooks/useAppParams';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { formatDateTime } from '@/utils/date';
|
||||
import { getProfileName } from '@/utils/getters';
|
||||
import { round } from '@/utils/math';
|
||||
import { Activity, BotIcon, MonitorPlay } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { EventIcon } from './event-icon';
|
||||
@@ -48,7 +46,7 @@ export function EventListItem({
|
||||
|
||||
switch (name) {
|
||||
case 'screen_view': {
|
||||
const route = (properties?.route || properties?.path) as string;
|
||||
const route = (properties?.route || properties?.path)!;
|
||||
if (route) {
|
||||
bullets.push(route);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
import PageLayout from '@/app/(app)/page-layout';
|
||||
import { getDashboardsByProjectId } from '@/server/services/dashboard.service';
|
||||
|
||||
import { HeaderDashboards } from './header-dashboards';
|
||||
import { ListDashboards } from './list-dashboards';
|
||||
import OverviewMetrics from './overview-metrics';
|
||||
|
||||
interface PageProps {
|
||||
params: {
|
||||
@@ -11,15 +9,10 @@ interface PageProps {
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Page({
|
||||
params: { organizationId, projectId },
|
||||
}: PageProps) {
|
||||
const dashboards = await getDashboardsByProjectId(projectId);
|
||||
|
||||
export default function Page({ params: { organizationId } }: PageProps) {
|
||||
return (
|
||||
<PageLayout title="Dashboards" organizationId={organizationId}>
|
||||
<HeaderDashboards projectId={projectId} />
|
||||
<ListDashboards dashboards={dashboards} />
|
||||
<PageLayout title="Overview" organizationId={organizationId}>
|
||||
<OverviewMetrics />
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { api } from '@/app/_trpc/client';
|
||||
import { useEffect } from 'react';
|
||||
import { StickyBelowHeader } from '@/app/(app)/layout-sticky-below-header';
|
||||
import { Chart } from '@/components/report/chart';
|
||||
import { ReportChartType } from '@/components/report/ReportChartType';
|
||||
import { ReportInterval } from '@/components/report/ReportInterval';
|
||||
import { ReportLineType } from '@/components/report/ReportLineType';
|
||||
import { ReportRange } from '@/components/report/ReportRange';
|
||||
import { ReportSaveButton } from '@/components/report/ReportSaveButton';
|
||||
import {
|
||||
changeDateRanges,
|
||||
@@ -58,17 +58,12 @@ export default function ReportEditor({
|
||||
</SheetTrigger>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 col-span-4">
|
||||
<ReportChartType className="min-w-0 flex-1" />
|
||||
<Combobox
|
||||
<ReportRange
|
||||
className="min-w-0 flex-1"
|
||||
placeholder="Range"
|
||||
value={report.range}
|
||||
onChange={(value) => {
|
||||
dispatch(changeDateRanges(value));
|
||||
}}
|
||||
items={Object.values(timeRanges).map((key) => ({
|
||||
label: key,
|
||||
value: key,
|
||||
}))}
|
||||
/>
|
||||
<ReportInterval className="min-w-0 flex-1" />
|
||||
<ReportLineType className="min-w-0 flex-1" />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getFirstProjectByOrganizationId } from '@/server/services/project.service';
|
||||
import { getProjectWithMostEvents } from '@/server/services/project.service';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
interface PageProps {
|
||||
@@ -8,10 +8,11 @@ interface PageProps {
|
||||
}
|
||||
|
||||
export default async function Page({ params: { organizationId } }: PageProps) {
|
||||
const project = await getFirstProjectByOrganizationId(organizationId);
|
||||
const project = await getProjectWithMostEvents(organizationId);
|
||||
|
||||
if (project) {
|
||||
return redirect(`/${organizationId}/${project.id}`);
|
||||
}
|
||||
|
||||
return <p>List projects maybe?</p>;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -2,14 +2,17 @@
|
||||
|
||||
import { useAppParams } from '@/hooks/useAppParams';
|
||||
import type { IServiceRecentDashboards } from '@/server/services/dashboard.service';
|
||||
import { cn } from '@/utils/cn';
|
||||
import {
|
||||
BuildingIcon,
|
||||
CogIcon,
|
||||
DotIcon,
|
||||
GanttChartIcon,
|
||||
KeySquareIcon,
|
||||
LayoutPanelTopIcon,
|
||||
UserIcon,
|
||||
UsersIcon,
|
||||
WallpaperIcon,
|
||||
WarehouseIcon,
|
||||
} from 'lucide-react';
|
||||
import type { LucideProps } from 'lucide-react';
|
||||
@@ -20,18 +23,32 @@ function LinkWithIcon({
|
||||
href,
|
||||
icon: Icon,
|
||||
label,
|
||||
active: overrideActive,
|
||||
}: {
|
||||
href: string;
|
||||
icon: React.ElementType<LucideProps>;
|
||||
label: React.ReactNode;
|
||||
active?: boolean;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const active = overrideActive || href === pathname;
|
||||
return (
|
||||
<Link
|
||||
className="flex gap-2 items-center px-3 py-3 transition-colors hover:bg-slate-100 leading-none rounded-lg"
|
||||
className={cn(
|
||||
'text-slate-600 flex gap-2 items-center px-3 py-3 transition-colors hover:text-white hover:bg-blue-700 leading-none rounded-lg',
|
||||
active && 'bg-blue-600 text-white'
|
||||
)}
|
||||
href={href}
|
||||
>
|
||||
<Icon size={20} />
|
||||
{label}
|
||||
<div className="flex-1">{label}</div>
|
||||
<DotIcon
|
||||
size={20}
|
||||
className={cn(
|
||||
'transition-opacity',
|
||||
active ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -53,10 +70,15 @@ export default function LayoutMenu({
|
||||
|
||||
return (
|
||||
<>
|
||||
<LinkWithIcon
|
||||
icon={WallpaperIcon}
|
||||
label="Overview"
|
||||
href={`/${params.organizationId}/${projectId}`}
|
||||
/>
|
||||
<LinkWithIcon
|
||||
icon={LayoutPanelTopIcon}
|
||||
label="Dashboards"
|
||||
href={`/${params.organizationId}/${projectId}`}
|
||||
href={`/${params.organizationId}/${projectId}/dashboards`}
|
||||
/>
|
||||
<LinkWithIcon
|
||||
icon={GanttChartIcon}
|
||||
|
||||
@@ -12,7 +12,7 @@ export function StickyBelowHeader({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'md:sticky top-16 bg-white border-b border-border z-10',
|
||||
'md:sticky bg-white border-b border-border z-10 [[id=dashboard]_&]:top-16 [[id=dashboard]_&]:rounded-none rounded-lg top-0',
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { getSession } from '@/server/auth';
|
||||
import { getRecentDashboardsByUserId } from '@/server/services/dashboard.service';
|
||||
import { getOrganizations } from '@/server/services/organization.service';
|
||||
|
||||
import Auth from '../auth';
|
||||
import { LayoutSidebar } from './layout-sidebar';
|
||||
|
||||
interface AppLayoutProps {
|
||||
@@ -15,8 +16,12 @@ export default async function AppLayout({ children }: AppLayoutProps) {
|
||||
? await getRecentDashboardsByUserId(session?.user.id)
|
||||
: [];
|
||||
|
||||
if (!session) {
|
||||
return <Auth />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div id="dashboard">
|
||||
<LayoutSidebar {...{ organizations, recentDashboards }} />
|
||||
<div className="lg:pl-72 transition-all">{children}</div>
|
||||
</div>
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
@@ -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);
|
||||
@@ -5,9 +5,6 @@ import Providers from './providers';
|
||||
import '@/styles/globals.css';
|
||||
|
||||
import { getSession } from '@/server/auth';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
import Auth from './auth';
|
||||
|
||||
export const metadata = {};
|
||||
|
||||
@@ -30,9 +27,7 @@ export default async function RootLayout({
|
||||
<body
|
||||
className={cn('min-h-screen font-sans antialiased grainy bg-slate-50')}
|
||||
>
|
||||
<Providers cookies={cookies().getAll()} session={session}>
|
||||
{session ? children : <Auth />}
|
||||
</Providers>
|
||||
<Providers session={session}>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -8,29 +8,25 @@ import { ModalProvider } from '@/modals';
|
||||
import type { AppStore } from '@/redux';
|
||||
import makeStore from '@/redux';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { httpBatchLink } from '@trpc/client';
|
||||
import { httpLink } from '@trpc/client';
|
||||
import type { Session } from 'next-auth';
|
||||
import { SessionProvider } from 'next-auth/react';
|
||||
import type { RequestCookie } from 'next/dist/compiled/@edge-runtime/cookies';
|
||||
import { Provider as ReduxProvider } from 'react-redux';
|
||||
import superjson from 'superjson';
|
||||
|
||||
import { CookieProvider } from './cookie-provider';
|
||||
|
||||
export default function Providers({
|
||||
children,
|
||||
session,
|
||||
cookies,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
session: Session | null;
|
||||
cookies: RequestCookie[];
|
||||
}) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
networkMode: 'always',
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
@@ -41,7 +37,7 @@ export default function Providers({
|
||||
api.createClient({
|
||||
transformer: superjson,
|
||||
links: [
|
||||
httpBatchLink({
|
||||
httpLink({
|
||||
url: 'http://localhost:3000/api/trpc',
|
||||
}),
|
||||
],
|
||||
@@ -60,7 +56,7 @@ export default function Providers({
|
||||
<api.Provider client={trpcClient} queryClient={queryClient}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<CookieProvider value={cookies}>{children}</CookieProvider>
|
||||
{children}
|
||||
<Toaster />
|
||||
<ModalProvider />
|
||||
</TooltipProvider>
|
||||
|
||||
33
apps/web/src/app/share/project/[projectId]/page.tsx
Normal file
33
apps/web/src/app/share/project/[projectId]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,15 @@
|
||||
import type { HtmlProps } from '@/types';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
import { useChartContext } from './report/chart/ChartProvider';
|
||||
|
||||
type ColorSquareProps = HtmlProps<HTMLDivElement>;
|
||||
|
||||
export function ColorSquare({ children, className }: ColorSquareProps) {
|
||||
const { hideID } = useChartContext();
|
||||
if (hideID) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
export function Logo() {
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
interface LogoProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Logo({ className }: LogoProps) {
|
||||
return (
|
||||
<div className="text-xl font-medium flex gap-2 items-center">
|
||||
<img src="/logo.svg" className="max-h-10" />
|
||||
<div
|
||||
className={cn('text-xl font-medium flex gap-2 items-center', className)}
|
||||
>
|
||||
<img src="/logo.svg" className="max-h-8 rounded-md" />
|
||||
openpanel.dev
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
interface WidgetHeadProps {
|
||||
export interface WidgetHeadProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
@@ -17,7 +17,7 @@ export function WidgetHead({ children, className }: WidgetHeadProps) {
|
||||
);
|
||||
}
|
||||
|
||||
interface WidgetBodyProps {
|
||||
export interface WidgetBodyProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
@@ -25,7 +25,7 @@ export function WidgetBody({ children, className }: WidgetBodyProps) {
|
||||
return <div className={cn('p-4', className)}>{children}</div>;
|
||||
}
|
||||
|
||||
interface WidgetProps {
|
||||
export interface WidgetProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { toDots } from '@/utils/object';
|
||||
import { toDots } from '@mixan/common';
|
||||
|
||||
import { Table, TableBody, TableCell, TableRow } from '../ui/table';
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useOrganizationParams } from '@/hooks/useOrganizationParams';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { strip } from '@/utils/object';
|
||||
import type { LinkProps } from 'next/link';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { strip } from '@mixan/common';
|
||||
|
||||
import { NavbarUserDropdown } from './NavbarUserDropdown';
|
||||
|
||||
function Item({
|
||||
|
||||
129
apps/web/src/components/overview/overview-filters-buttons.tsx
Normal file
129
apps/web/src/components/overview/overview-filters-buttons.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
121
apps/web/src/components/overview/overview-filters.tsx
Normal file
121
apps/web/src/components/overview/overview-filters.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
199
apps/web/src/components/overview/overview-top-devices.tsx
Normal file
199
apps/web/src/components/overview/overview-top-devices.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
75
apps/web/src/components/overview/overview-top-events.tsx
Normal file
75
apps/web/src/components/overview/overview-top-events.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
171
apps/web/src/components/overview/overview-top-geo.tsx
Normal file
171
apps/web/src/components/overview/overview-top-geo.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
130
apps/web/src/components/overview/overview-top-pages.tsx
Normal file
130
apps/web/src/components/overview/overview-top-pages.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
245
apps/web/src/components/overview/overview-top-sources.tsx
Normal file
245
apps/web/src/components/overview/overview-top-sources.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
25
apps/web/src/components/overview/overview-widget.tsx
Normal file
25
apps/web/src/components/overview/overview-widget.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
234
apps/web/src/components/overview/useOverviewOptions.ts
Normal file
234
apps/web/src/components/overview/useOverviewOptions.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
27
apps/web/src/components/overview/useOverviewWidget.tsx
Normal file
27
apps/web/src/components/overview/useOverviewWidget.tsx
Normal 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;
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { TrendingDownIcon, TrendingUpIcon } from 'lucide-react';
|
||||
|
||||
import { Badge } from '../ui/badge';
|
||||
import { useChartContext } from './chart/ChartProvider';
|
||||
|
||||
interface PreviousDiffIndicatorProps {
|
||||
diff?: number | null | undefined;
|
||||
state?: string | null | undefined;
|
||||
children?: React.ReactNode;
|
||||
inverted?: boolean;
|
||||
}
|
||||
|
||||
export function PreviousDiffIndicator({
|
||||
@@ -15,25 +17,38 @@ export function PreviousDiffIndicator({
|
||||
state,
|
||||
children,
|
||||
}: PreviousDiffIndicatorProps) {
|
||||
const { previous } = useChartContext();
|
||||
const { previous, previousIndicatorInverted } = useChartContext();
|
||||
const number = useNumber();
|
||||
if (diff === null || diff === undefined || previous === false) {
|
||||
return children ?? null;
|
||||
}
|
||||
|
||||
if (previousIndicatorInverted === true) {
|
||||
return (
|
||||
<>
|
||||
<Badge
|
||||
className="flex gap-1"
|
||||
variant={state === 'positive' ? 'destructive' : 'success'}
|
||||
>
|
||||
{state === 'negative' && <TrendingUpIcon size={15} />}
|
||||
{state === 'positive' && <TrendingDownIcon size={15} />}
|
||||
{number.format(diff)}%
|
||||
</Badge>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn('flex items-center', [
|
||||
state === 'positive' && 'text-emerald-500',
|
||||
state === 'negative' && 'text-rose-500',
|
||||
state === 'neutral' && 'text-slate-400',
|
||||
])}
|
||||
<Badge
|
||||
className="flex gap-1"
|
||||
variant={state === 'positive' ? 'success' : 'destructive'}
|
||||
>
|
||||
{state === 'positive' && <ChevronUp size={20} />}
|
||||
{state === 'negative' && <ChevronDown size={20} />}
|
||||
{state === 'positive' && <TrendingUpIcon size={15} />}
|
||||
{state === 'negative' && <TrendingDownIcon size={15} />}
|
||||
{number.format(diff)}%
|
||||
</div>
|
||||
</Badge>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
20
apps/web/src/components/report/ReportRange.tsx
Normal file
20
apps/web/src/components/report/ReportRange.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import { useAppParams } from '@/hooks/useAppParams';
|
||||
import { pushModal } from '@/modals';
|
||||
import { useDispatch, useSelector } from '@/redux';
|
||||
import { SaveIcon } from 'lucide-react';
|
||||
import { useParams } from 'next/navigation';
|
||||
|
||||
import { resetDirty } from './reportSlice';
|
||||
|
||||
|
||||
@@ -1,31 +1,46 @@
|
||||
import { createContext, memo, useContext, useMemo } from 'react';
|
||||
import type { IChartInput } from '@/types';
|
||||
|
||||
export interface ChartContextType {
|
||||
editMode: boolean;
|
||||
previous?: boolean;
|
||||
export interface ChartContextType extends IChartInput {
|
||||
editMode?: boolean;
|
||||
hideID?: boolean;
|
||||
onClick?: (item: any) => void;
|
||||
}
|
||||
|
||||
type ChartProviderProps = {
|
||||
children: React.ReactNode;
|
||||
} & ChartContextType;
|
||||
|
||||
const ChartContext = createContext<ChartContextType>({
|
||||
editMode: false,
|
||||
const ChartContext = createContext<ChartContextType | null>({
|
||||
events: [],
|
||||
breakdowns: [],
|
||||
chartType: 'linear',
|
||||
lineType: 'monotone',
|
||||
interval: 'day',
|
||||
name: '',
|
||||
range: '7d',
|
||||
metric: 'sum',
|
||||
previous: false,
|
||||
projectId: '',
|
||||
});
|
||||
|
||||
export function ChartProvider({
|
||||
children,
|
||||
editMode,
|
||||
previous,
|
||||
hideID,
|
||||
...props
|
||||
}: ChartProviderProps) {
|
||||
return (
|
||||
<ChartContext.Provider
|
||||
value={useMemo(
|
||||
() => ({
|
||||
editMode,
|
||||
editMode: editMode ?? false,
|
||||
previous: previous ?? false,
|
||||
hideID: hideID ?? false,
|
||||
...props,
|
||||
}),
|
||||
[editMode, previous]
|
||||
[editMode, previous, hideID, props]
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
@@ -52,5 +67,5 @@ export function withChartProivder<ComponentProps>(
|
||||
}
|
||||
|
||||
export function useChartContext() {
|
||||
return useContext(ChartContext);
|
||||
return useContext(ChartContext)!;
|
||||
}
|
||||
|
||||
81
apps/web/src/components/report/chart/MetricCard.tsx
Normal file
81
apps/web/src/components/report/chart/MetricCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -34,7 +34,7 @@ export function ReportAreaChart({
|
||||
const { editMode } = useChartContext();
|
||||
const { series, setVisibleSeries } = useVisibleSeries(data);
|
||||
const formatDate = useFormatDateInterval(interval);
|
||||
const rechartData = useRechartDataModel(data);
|
||||
const rechartData = useRechartDataModel(series);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -9,6 +9,11 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
@@ -21,7 +26,6 @@ import {
|
||||
} from '@tanstack/react-table';
|
||||
import type { SortingState } from '@tanstack/react-table';
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { useElementSize } from 'usehooks-ts';
|
||||
|
||||
import { PreviousDiffIndicator } from '../PreviousDiffIndicator';
|
||||
import { useChartContext } from './ChartProvider';
|
||||
@@ -34,10 +38,11 @@ interface ReportBarChartProps {
|
||||
}
|
||||
|
||||
export function ReportBarChart({ data }: ReportBarChartProps) {
|
||||
const { editMode } = useChartContext();
|
||||
const [ref, { width }] = useElementSize();
|
||||
const { editMode, metric, unit, onClick } = useChartContext();
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const maxCount = Math.max(...data.series.map((serie) => serie.metrics.sum));
|
||||
const maxCount = Math.max(
|
||||
...data.series.map((serie) => serie.metrics[metric])
|
||||
);
|
||||
const number = useNumber();
|
||||
const table = useReactTable({
|
||||
data: useMemo(
|
||||
@@ -53,46 +58,45 @@ export function ReportBarChart({ data }: ReportBarChartProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<ColorSquare>{info.row.original.event.id}</ColorSquare>
|
||||
{info.getValue()}
|
||||
<Tooltip delayDuration={200}>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="text-ellipsis overflow-hidden">
|
||||
{info.getValue()}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{info.getValue()}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
footer: (info) => info.column.id,
|
||||
size: width ? width * 0.3 : undefined,
|
||||
}),
|
||||
columnHelper.accessor((row) => row.metrics.sum, {
|
||||
columnHelper.accessor((row) => row.metrics[metric], {
|
||||
id: 'totalCount',
|
||||
cell: (info) => (
|
||||
<div className="text-right font-medium flex gap-2">
|
||||
<div>{number.format(info.getValue())}</div>
|
||||
<div className="flex gap-4 w-full">
|
||||
<div className="relative flex-1">
|
||||
<div
|
||||
className="top-0 absolute shine h-[20px] rounded-full"
|
||||
style={{
|
||||
width: (info.getValue() / maxCount) * 100 + '%',
|
||||
background: getChartColor(info.row.index),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="font-bold">
|
||||
{number.format(info.getValue())}
|
||||
{unit}
|
||||
</div>
|
||||
<PreviousDiffIndicator
|
||||
{...info.row.original.metrics.previous.sum}
|
||||
{...info.row.original.metrics.previous[metric]}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
header: () => 'Count',
|
||||
footer: (info) => info.column.id,
|
||||
size: width ? width * 0.1 : undefined,
|
||||
enableSorting: true,
|
||||
}),
|
||||
columnHelper.accessor((row) => row.metrics.sum, {
|
||||
id: 'graph',
|
||||
cell: (info) => (
|
||||
<div
|
||||
className="shine h-4 rounded [.mini_&]:h-3"
|
||||
style={{
|
||||
width: (info.getValue() / maxCount) * 100 + '%',
|
||||
background: getChartColor(info.row.index),
|
||||
}}
|
||||
/>
|
||||
),
|
||||
header: () => 'Graph',
|
||||
footer: (info) => info.column.id,
|
||||
size: width ? width * 0.6 : undefined,
|
||||
}),
|
||||
];
|
||||
}, [width]),
|
||||
columnResizeMode: 'onChange',
|
||||
}, [maxCount, number]),
|
||||
state: {
|
||||
sorting,
|
||||
},
|
||||
@@ -102,85 +106,64 @@ export function ReportBarChart({ data }: ReportBarChartProps) {
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<div className="overflow-x-auto">
|
||||
<Table
|
||||
{...{
|
||||
className: editMode ? '' : 'mini',
|
||||
style: {
|
||||
width: table.getTotalSize(),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
{...{
|
||||
colSpan: header.colSpan,
|
||||
style: {
|
||||
width: header.getSize(),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div
|
||||
{...{
|
||||
className: cn(
|
||||
'flex items-center gap-2',
|
||||
header.column.getCanSort() &&
|
||||
'cursor-pointer select-none'
|
||||
),
|
||||
onClick: header.column.getToggleSortingHandler(),
|
||||
}}
|
||||
>
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
{{
|
||||
asc: <ChevronUp className="ml-auto" size={14} />,
|
||||
desc: <ChevronDown className="ml-auto" size={14} />,
|
||||
}[header.column.getIsSorted() as string] ?? null}
|
||||
</div>
|
||||
<div
|
||||
{...(editMode
|
||||
? {
|
||||
onMouseDown: header.getResizeHandler(),
|
||||
onTouchStart: header.getResizeHandler(),
|
||||
className: `resizer ${
|
||||
header.column.getIsResizing() ? 'isResizing' : ''
|
||||
}`,
|
||||
style: {},
|
||||
}
|
||||
: {})}
|
||||
/>
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
<Table
|
||||
overflow={editMode}
|
||||
className={cn('table-fixed', editMode ? '' : 'mini')}
|
||||
>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
{...{
|
||||
colSpan: header.colSpan,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
{...{
|
||||
className: cn(
|
||||
'flex items-center gap-2',
|
||||
header.column.getCanSort() && 'cursor-pointer select-none'
|
||||
),
|
||||
onClick: header.column.getToggleSortingHandler(),
|
||||
}}
|
||||
>
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
{{
|
||||
asc: <ChevronUp className="ml-auto" size={14} />,
|
||||
desc: <ChevronDown className="ml-auto" size={14} />,
|
||||
}[header.column.getIsSorted() as string] ?? null}
|
||||
</div>
|
||||
</TableHead>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
{...{
|
||||
style: {
|
||||
width: cell.column.getSize(),
|
||||
},
|
||||
}}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
{...(onClick
|
||||
? {
|
||||
onClick() {
|
||||
onClick(row.original);
|
||||
},
|
||||
className: 'cursor-pointer',
|
||||
}
|
||||
: {})}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ export function ReportChartTooltip({
|
||||
active,
|
||||
payload,
|
||||
}: ReportLineChartTooltipProps) {
|
||||
const { previous } = useChartContext();
|
||||
const { previous, unit } = useChartContext();
|
||||
const getLabel = useMappings();
|
||||
const interval = useSelector((state) => state.report.interval);
|
||||
const formatDate = useFormatDateInterval(interval);
|
||||
@@ -41,7 +41,7 @@ export function ReportChartTooltip({
|
||||
const hidden = sorted.slice(limit);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-xl border bg-white p-3 text-sm shadow-xl">
|
||||
<div className="flex flex-col gap-2 rounded-xl border bg-white p-3 text-sm shadow-xl min-w-[180px]">
|
||||
{visible.map((item, index) => {
|
||||
// If we have a <Cell /> component, payload can be nested
|
||||
const payload = item.payload.payload ?? item.payload;
|
||||
@@ -57,11 +57,11 @@ export function ReportChartTooltip({
|
||||
{index === 0 && data.date && (
|
||||
<div className="flex justify-between gap-8">
|
||||
<div>{formatDate(new Date(data.date))}</div>
|
||||
{previous && data.previous?.date && (
|
||||
{/* {previous && data.previous?.date && (
|
||||
<div className="text-slate-400 italic">
|
||||
{formatDate(new Date(data.previous.date))}
|
||||
</div>
|
||||
)}
|
||||
)} */}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
@@ -74,11 +74,15 @@ export function ReportChartTooltip({
|
||||
{getLabel(data.label)}
|
||||
</div>
|
||||
<div className="flex justify-between gap-8">
|
||||
<div>{number.format(data.count)}</div>
|
||||
<div>
|
||||
{number.format(data.count)}
|
||||
{unit}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1">
|
||||
<PreviousDiffIndicator {...data.previous}>
|
||||
{!!data.previous && `(${data.previous.count})`}
|
||||
{!!data.previous &&
|
||||
`(${data.previous.value + (unit ? unit : '')})`}
|
||||
</PreviousDiffIndicator>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -32,7 +32,7 @@ export function ReportHistogramChart({
|
||||
const formatDate = useFormatDateInterval(interval);
|
||||
const { series, setVisibleSeries } = useVisibleSeries(data);
|
||||
|
||||
const rechartData = useRechartDataModel(data);
|
||||
const rechartData = useRechartDataModel(series);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import React from 'react';
|
||||
import type { IChartData } from '@/app/_trpc/client';
|
||||
import { AutoSizer } from '@/components/AutoSizer';
|
||||
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
|
||||
@@ -35,7 +35,7 @@ export function ReportLineChart({
|
||||
const { editMode, previous } = useChartContext();
|
||||
const formatDate = useFormatDateInterval(interval);
|
||||
const { series, setVisibleSeries } = useVisibleSeries(data);
|
||||
const rechartData = useRechartDataModel(data);
|
||||
const rechartData = useRechartDataModel(series);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
40
apps/web/src/components/report/chart/ReportMapChart.tsx
Normal file
40
apps/web/src/components/report/chart/ReportMapChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +1,17 @@
|
||||
import type { IChartData } from '@/app/_trpc/client';
|
||||
import { ColorSquare } from '@/components/ColorSquare';
|
||||
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||
import { useVisibleSeries } from '@/hooks/useVisibleSeries';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { theme } from '@/utils/theme';
|
||||
import { ChevronDown, ChevronUp, ChevronUpCircle } from 'lucide-react';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { Area, AreaChart } from 'recharts';
|
||||
|
||||
import { PreviousDiffIndicator } from '../PreviousDiffIndicator';
|
||||
import { useChartContext } from './ChartProvider';
|
||||
import { MetricCard } from './MetricCard';
|
||||
|
||||
interface ReportMetricChartProps {
|
||||
data: IChartData;
|
||||
}
|
||||
|
||||
export function ReportMetricChart({ data }: ReportMetricChartProps) {
|
||||
const { editMode } = useChartContext();
|
||||
const { editMode, metric, unit } = useChartContext();
|
||||
const { series } = useVisibleSeries(data, editMode ? undefined : 2);
|
||||
const color = theme?.colors['chart-0'];
|
||||
const number = useNumber();
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -29,62 +21,12 @@ export function ReportMetricChart({ data }: ReportMetricChartProps) {
|
||||
>
|
||||
{series.map((serie) => {
|
||||
return (
|
||||
<div
|
||||
className="relative border border-border p-4 rounded-md bg-white overflow-hidden"
|
||||
<MetricCard
|
||||
key={serie.name}
|
||||
>
|
||||
<div className="absolute -top-1 -left-1 -right-1 -bottom-1 z-0 opacity-20">
|
||||
<AutoSizer>
|
||||
{({ width, height }) => (
|
||||
<AreaChart
|
||||
width={width}
|
||||
height={height / 3}
|
||||
data={serie.data}
|
||||
style={{ marginTop: (height / 3) * 2 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="area" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor={color} stopOpacity={0.3} />
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor={color}
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<Area
|
||||
dataKey="count"
|
||||
type="monotone"
|
||||
fill="url(#area)"
|
||||
fillOpacity={1}
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</AreaChart>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="flex items-center gap-2 text-lg font-medium">
|
||||
<ColorSquare>{serie.event.id}</ColorSquare>
|
||||
{serie.name ?? serie.event.displayName ?? serie.event.name}
|
||||
</div>
|
||||
<div className="flex justify-between items-end">
|
||||
<div className="mt-6 font-mono text-3xl font-bold">
|
||||
{number.format(serie.metrics.sum)}
|
||||
</div>
|
||||
{!!serie.metrics.previous.sum && (
|
||||
<div className="flex flex-col items-end">
|
||||
<PreviousDiffIndicator {...serie.metrics.previous.sum}>
|
||||
<div className="font-mono">
|
||||
{number.format(serie.metrics.previous.sum.value)}
|
||||
</div>
|
||||
</PreviousDiffIndicator>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
serie={serie}
|
||||
metric={metric}
|
||||
unit={unit}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { IChartData } from '@/app/_trpc/client';
|
||||
import { AutoSizer } from '@/components/AutoSizer';
|
||||
import { useVisibleSeries } from '@/hooks/useVisibleSeries';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { round } from '@/utils/math';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
import { truncate } from '@/utils/truncate';
|
||||
import { Cell, Pie, PieChart, Tooltip } from 'recharts';
|
||||
|
||||
import { useChartContext } from './ChartProvider';
|
||||
@@ -15,66 +15,19 @@ interface ReportPieChartProps {
|
||||
data: IChartData;
|
||||
}
|
||||
|
||||
const RADIAN = Math.PI / 180;
|
||||
const renderLabel = ({
|
||||
x,
|
||||
y,
|
||||
cx,
|
||||
cy,
|
||||
midAngle,
|
||||
innerRadius,
|
||||
outerRadius,
|
||||
payload,
|
||||
...props
|
||||
}: any) => {
|
||||
const radius = innerRadius + (outerRadius - innerRadius) * 0.5;
|
||||
const xx = cx + radius * Math.cos(-midAngle * RADIAN);
|
||||
const yy = cy + radius * Math.sin(-midAngle * RADIAN);
|
||||
const label = payload.label;
|
||||
const percent = round(payload.percent * 100, 1);
|
||||
|
||||
return (
|
||||
<>
|
||||
<text
|
||||
x={xx}
|
||||
y={yy}
|
||||
fill="white"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize={12}
|
||||
>
|
||||
{percent}%
|
||||
</text>
|
||||
<text
|
||||
x={x}
|
||||
y={y}
|
||||
fill="black"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize={12}
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export function ReportPieChart({ data }: ReportPieChartProps) {
|
||||
const { editMode } = useChartContext();
|
||||
const { series, setVisibleSeries } = useVisibleSeries(data);
|
||||
|
||||
const sum = series.reduce((acc, serie) => acc + serie.metrics.sum, 0);
|
||||
// Get max 10 series and than combine others into one
|
||||
const pieData = series.map((serie) => {
|
||||
return {
|
||||
id: serie.name,
|
||||
color: getChartColor(serie.index),
|
||||
index: serie.index,
|
||||
label: serie.name,
|
||||
count: serie.metrics.sum,
|
||||
percent: serie.metrics.sum / sum,
|
||||
};
|
||||
});
|
||||
const pieData = series.map((serie) => ({
|
||||
id: serie.name,
|
||||
color: getChartColor(serie.index),
|
||||
index: serie.index,
|
||||
label: serie.name,
|
||||
count: serie.metrics.sum,
|
||||
percent: serie.metrics.sum / sum,
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -127,3 +80,58 @@ export function ReportPieChart({ data }: ReportPieChartProps) {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const renderLabel = ({
|
||||
cx,
|
||||
cy,
|
||||
midAngle,
|
||||
innerRadius,
|
||||
outerRadius,
|
||||
fill,
|
||||
payload,
|
||||
}: {
|
||||
cx: number;
|
||||
cy: number;
|
||||
midAngle: number;
|
||||
innerRadius: number;
|
||||
outerRadius: number;
|
||||
fill: string;
|
||||
payload: { label: string; percent: number };
|
||||
}) => {
|
||||
const RADIAN = Math.PI / 180;
|
||||
const radius = 25 + innerRadius + (outerRadius - innerRadius);
|
||||
const radiusProcent = innerRadius + (outerRadius - innerRadius) * 0.5;
|
||||
const xProcent = cx + radiusProcent * Math.cos(-midAngle * RADIAN);
|
||||
const yProcent = cy + radiusProcent * Math.sin(-midAngle * RADIAN);
|
||||
const x = cx + radius * Math.cos(-midAngle * RADIAN);
|
||||
const y = cy + radius * Math.sin(-midAngle * RADIAN);
|
||||
const label = payload.label;
|
||||
const percent = round(payload.percent * 100, 1);
|
||||
|
||||
return (
|
||||
<>
|
||||
<text
|
||||
x={xProcent}
|
||||
y={yProcent}
|
||||
fill="white"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize={10}
|
||||
fontWeight={700}
|
||||
pointerEvents={'none'}
|
||||
>
|
||||
{percent}%
|
||||
</text>
|
||||
<text
|
||||
x={x}
|
||||
y={y}
|
||||
fill={fill}
|
||||
textAnchor={x > cx ? 'start' : 'end'}
|
||||
dominantBaseline="central"
|
||||
fontSize={10}
|
||||
>
|
||||
{truncate(label, 20)}
|
||||
</text>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { memo } from 'react';
|
||||
import type { RouterOutputs } from '@/app/_trpc/client';
|
||||
import { api } from '@/app/_trpc/client';
|
||||
import { useAppParams } from '@/hooks/useAppParams';
|
||||
import type { IChartInput } from '@/types';
|
||||
@@ -11,10 +12,13 @@ import { ReportAreaChart } from './ReportAreaChart';
|
||||
import { ReportBarChart } from './ReportBarChart';
|
||||
import { ReportHistogramChart } from './ReportHistogramChart';
|
||||
import { ReportLineChart } from './ReportLineChart';
|
||||
import { ReportMapChart } from './ReportMapChart';
|
||||
import { ReportMetricChart } from './ReportMetricChart';
|
||||
import { ReportPieChart } from './ReportPieChart';
|
||||
|
||||
export type ReportChartProps = IChartInput;
|
||||
export type ReportChartProps = IChartInput & {
|
||||
initialData?: RouterOutputs['chart']['chart'];
|
||||
};
|
||||
|
||||
export const Chart = memo(
|
||||
withChartProivder(function Chart({
|
||||
@@ -26,18 +30,23 @@ export const Chart = memo(
|
||||
range,
|
||||
lineType,
|
||||
previous,
|
||||
formula,
|
||||
unit,
|
||||
metric,
|
||||
initialData,
|
||||
}: ReportChartProps) {
|
||||
const params = useAppParams();
|
||||
const hasEmptyFilters = events.some((event) =>
|
||||
event.filters.some((filter) => filter.value.length === 0)
|
||||
);
|
||||
const enabled = events.length > 0 && !hasEmptyFilters;
|
||||
|
||||
const chart = api.chart.chart.useQuery(
|
||||
{
|
||||
interval,
|
||||
chartType,
|
||||
// dont send lineType since it does not need to be sent
|
||||
lineType: 'monotone',
|
||||
interval,
|
||||
chartType,
|
||||
events,
|
||||
breakdowns,
|
||||
name,
|
||||
@@ -46,10 +55,14 @@ export const Chart = memo(
|
||||
endDate: null,
|
||||
projectId: params.projectId,
|
||||
previous,
|
||||
formula,
|
||||
unit,
|
||||
metric,
|
||||
},
|
||||
{
|
||||
keepPreviousData: false,
|
||||
keepPreviousData: true,
|
||||
enabled,
|
||||
initialData,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -66,10 +79,10 @@ export const Chart = memo(
|
||||
);
|
||||
}
|
||||
|
||||
if (chart.isFetching) {
|
||||
if (chart.isLoading) {
|
||||
return (
|
||||
<ChartAnimationContainer>
|
||||
<ChartAnimation name="airplane" className="max-w-sm w-fill mx-auto" />
|
||||
{/* <ChartAnimation name="airplane" className="max-w-sm w-fill mx-auto" /> */}
|
||||
<p className="text-center font-medium">Loading...</p>
|
||||
</ChartAnimationContainer>
|
||||
);
|
||||
@@ -99,6 +112,10 @@ export const Chart = memo(
|
||||
);
|
||||
}
|
||||
|
||||
if (chartType === 'map') {
|
||||
return <ReportMapChart data={chart.data} />;
|
||||
}
|
||||
|
||||
if (chartType === 'histogram') {
|
||||
return <ReportHistogramChart interval={interval} data={chart.data} />;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ type InitialState = IChartInput & {
|
||||
const initialState: InitialState = {
|
||||
ready: false,
|
||||
dirty: false,
|
||||
// TODO: remove this
|
||||
projectId: '',
|
||||
name: 'Untitled',
|
||||
chartType: 'linear',
|
||||
lineType: 'monotone',
|
||||
@@ -37,6 +39,9 @@ const initialState: InitialState = {
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
previous: false,
|
||||
formula: undefined,
|
||||
unit: undefined,
|
||||
metric: 'sum',
|
||||
};
|
||||
|
||||
export const reportSlice = createSlice({
|
||||
@@ -100,6 +105,12 @@ export const reportSlice = createSlice({
|
||||
});
|
||||
},
|
||||
|
||||
// Previous
|
||||
changePrevious: (state, action: PayloadAction<boolean>) => {
|
||||
state.dirty = true;
|
||||
state.previous = action.payload;
|
||||
},
|
||||
|
||||
// Breakdowns
|
||||
addBreakdown: (
|
||||
state,
|
||||
@@ -181,6 +192,12 @@ export const reportSlice = createSlice({
|
||||
state.range = action.payload;
|
||||
state.interval = getDefaultIntervalByRange(action.payload);
|
||||
},
|
||||
|
||||
// Formula
|
||||
changeFormula: (state, action: PayloadAction<string>) => {
|
||||
state.dirty = true;
|
||||
state.formula = action.payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -201,6 +218,8 @@ export const {
|
||||
changeChartType,
|
||||
changeLineType,
|
||||
resetDirty,
|
||||
changeFormula,
|
||||
changePrevious,
|
||||
} = reportSlice.actions;
|
||||
|
||||
export default reportSlice.reducer;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -34,7 +34,7 @@ const labels = [
|
||||
];
|
||||
|
||||
export interface ReportEventMoreProps {
|
||||
onClick: (action: 'createFilter' | 'remove') => void;
|
||||
onClick: (action: 'remove') => void;
|
||||
}
|
||||
|
||||
export function ReportEventMore({ onClick }: ReportEventMoreProps) {
|
||||
@@ -49,10 +49,6 @@ export function ReportEventMore({ onClick }: ReportEventMoreProps) {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-[200px]">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={() => onClick('createFilter')}>
|
||||
<Filter className="mr-2 h-4 w-4" />
|
||||
Add filter
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-red-600"
|
||||
|
||||
@@ -1,25 +1,31 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { api } from '@/app/_trpc/client';
|
||||
import { ColorSquare } from '@/components/ColorSquare';
|
||||
import { Dropdown } from '@/components/Dropdown';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useAppParams } from '@/hooks/useAppParams';
|
||||
import { useDebounceFn } from '@/hooks/useDebounceFn';
|
||||
import { useDispatch, useSelector } from '@/redux';
|
||||
import type { IChartEvent } from '@/types';
|
||||
import { Filter, GanttChart, Users } from 'lucide-react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { GanttChart, Users } from 'lucide-react';
|
||||
|
||||
import { addEvent, changeEvent, removeEvent } from '../reportSlice';
|
||||
import { ReportEventFilters } from './ReportEventFilters';
|
||||
import {
|
||||
addEvent,
|
||||
changeEvent,
|
||||
changePrevious,
|
||||
removeEvent,
|
||||
} from '../reportSlice';
|
||||
import { EventPropertiesCombobox } from './EventPropertiesCombobox';
|
||||
import { FiltersCombobox } from './filters/FiltersCombobox';
|
||||
import { FiltersList } from './filters/FiltersList';
|
||||
import { ReportEventMore } from './ReportEventMore';
|
||||
import type { ReportEventMoreProps } from './ReportEventMore';
|
||||
|
||||
export function ReportEvents() {
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const previous = useSelector((state) => state.report.previous);
|
||||
const selectedEvents = useSelector((state) => state.report.events);
|
||||
const dispatch = useDispatch();
|
||||
const params = useAppParams();
|
||||
@@ -37,9 +43,6 @@ export function ReportEvents() {
|
||||
const handleMore = (event: IChartEvent) => {
|
||||
const callback: ReportEventMoreProps['onClick'] = (action) => {
|
||||
switch (action) {
|
||||
case 'createFilter': {
|
||||
return setIsCreating(true);
|
||||
}
|
||||
case 'remove': {
|
||||
return dispatch(removeEvent(event));
|
||||
}
|
||||
@@ -111,12 +114,20 @@ export function ReportEvents() {
|
||||
},
|
||||
{
|
||||
value: 'user_average',
|
||||
label: 'Unique users (average)',
|
||||
label: 'Average event per user',
|
||||
},
|
||||
{
|
||||
value: 'one_event_per_user',
|
||||
label: 'One event per user',
|
||||
},
|
||||
{
|
||||
value: 'property_sum',
|
||||
label: 'Sum of property',
|
||||
},
|
||||
{
|
||||
value: 'property_average',
|
||||
label: 'Average of property',
|
||||
},
|
||||
]}
|
||||
label="Segment"
|
||||
>
|
||||
@@ -127,12 +138,20 @@ export function ReportEvents() {
|
||||
</>
|
||||
) : event.segment === 'user_average' ? (
|
||||
<>
|
||||
<Users size={12} /> Unique users (average)
|
||||
<Users size={12} /> Average event per user
|
||||
</>
|
||||
) : event.segment === 'one_event_per_user' ? (
|
||||
<>
|
||||
<Users size={12} /> One event per user
|
||||
</>
|
||||
) : event.segment === 'property_sum' ? (
|
||||
<>
|
||||
<Users size={12} /> Sum of property
|
||||
</>
|
||||
) : event.segment === 'property_average' ? (
|
||||
<>
|
||||
<Users size={12} /> Average of property
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<GanttChart size={12} /> All events
|
||||
@@ -140,18 +159,17 @@ export function ReportEvents() {
|
||||
)}
|
||||
</button>
|
||||
</Dropdown>
|
||||
<button
|
||||
onClick={() => {
|
||||
handleMore(event)('createFilter');
|
||||
}}
|
||||
className="flex items-center gap-1 rounded-md border border-border p-1 px-2 font-medium leading-none text-xs"
|
||||
>
|
||||
<Filter size={12} /> Filter
|
||||
</button>
|
||||
{/* */}
|
||||
<FiltersCombobox event={event} />
|
||||
|
||||
{(event.segment === 'property_average' ||
|
||||
event.segment === 'property_sum') && (
|
||||
<EventPropertiesCombobox event={event} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<ReportEventFilters {...{ isCreating, setIsCreating, event }} />
|
||||
<FiltersList event={event} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -172,6 +190,17 @@ export function ReportEvents() {
|
||||
placeholder="Select event"
|
||||
/>
|
||||
</div>
|
||||
<label
|
||||
className="flex items-center gap-2 cursor-pointer select-none text-sm font-medium mt-4"
|
||||
htmlFor="previous"
|
||||
>
|
||||
<Checkbox
|
||||
id="previous"
|
||||
checked={previous}
|
||||
onCheckedChange={(val) => dispatch(changePrevious(!!val))}
|
||||
/>
|
||||
Show previous / Compare
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
26
apps/web/src/components/report/sidebar/ReportForumula.tsx
Normal file
26
apps/web/src/components/report/sidebar/ReportForumula.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -3,11 +3,13 @@ import { SheetClose } from '@/components/ui/sheet';
|
||||
|
||||
import { ReportBreakdowns } from './ReportBreakdowns';
|
||||
import { ReportEvents } from './ReportEvents';
|
||||
import { ReportForumula } from './ReportForumula';
|
||||
|
||||
export function ReportSidebar() {
|
||||
return (
|
||||
<div className="flex flex-col gap-8 pb-12">
|
||||
<ReportEvents />
|
||||
<ReportForumula />
|
||||
<ReportBreakdowns />
|
||||
<div className="absolute bottom-0 left-0 right-0 p-6 bg-gradient-to-t from-white/100 to-white/0">
|
||||
<SheetClose asChild>
|
||||
|
||||
@@ -1,18 +1,8 @@
|
||||
import type { Dispatch } from 'react';
|
||||
import { api } from '@/app/_trpc/client';
|
||||
import { ColorSquare } from '@/components/ColorSquare';
|
||||
import { Dropdown } from '@/components/Dropdown';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from '@/components/ui/command';
|
||||
import { RenderDots } from '@/components/ui/RenderDots';
|
||||
import { useAppParams } from '@/hooks/useAppParams';
|
||||
import { useMappings } from '@/hooks/useMappings';
|
||||
@@ -23,93 +13,24 @@ import type {
|
||||
IChartEventFilterValue,
|
||||
} from '@/types';
|
||||
import { operators } from '@/utils/constants';
|
||||
import { CreditCard, SlidersHorizontal, Trash } from 'lucide-react';
|
||||
import { SlidersHorizontal, Trash } from 'lucide-react';
|
||||
import { useParams } from 'next/navigation';
|
||||
|
||||
import { changeEvent } from '../reportSlice';
|
||||
|
||||
interface ReportEventFiltersProps {
|
||||
event: IChartEvent;
|
||||
isCreating: boolean;
|
||||
setIsCreating: Dispatch<boolean>;
|
||||
}
|
||||
|
||||
export function ReportEventFilters({
|
||||
event,
|
||||
isCreating,
|
||||
setIsCreating,
|
||||
}: ReportEventFiltersProps) {
|
||||
const params = useAppParams();
|
||||
const dispatch = useDispatch();
|
||||
const propertiesQuery = api.chart.properties.useQuery(
|
||||
{
|
||||
event: event.name,
|
||||
projectId: params.projectId,
|
||||
},
|
||||
{
|
||||
enabled: !!event.name,
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col divide-y bg-slate-50">
|
||||
{event.filters.map((filter) => {
|
||||
return <Filter key={filter.name} filter={filter} event={event} />;
|
||||
})}
|
||||
|
||||
<CommandDialog open={isCreating} onOpenChange={setIsCreating} modal>
|
||||
<CommandInput placeholder="Search properties" />
|
||||
<CommandList>
|
||||
<CommandEmpty>Such emptyness 🤨</CommandEmpty>
|
||||
<CommandGroup heading="Properties">
|
||||
{propertiesQuery.data?.map((item) => (
|
||||
<CommandItem
|
||||
key={item}
|
||||
onSelect={() => {
|
||||
setIsCreating(false);
|
||||
dispatch(
|
||||
changeEvent({
|
||||
...event,
|
||||
filters: [
|
||||
...event.filters,
|
||||
{
|
||||
id: (event.filters.length + 1).toString(),
|
||||
name: item,
|
||||
operator: 'is',
|
||||
value: [],
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
}}
|
||||
>
|
||||
<CreditCard className="mr-2 h-4 w-4" />
|
||||
<RenderDots className="text-sm">{item}</RenderDots>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { changeEvent } from '../../reportSlice';
|
||||
|
||||
interface FilterProps {
|
||||
event: IChartEvent;
|
||||
filter: IChartEvent['filters'][number];
|
||||
}
|
||||
|
||||
function Filter({ filter, event }: FilterProps) {
|
||||
const params = useParams<{ organizationId: string; projectId: string }>();
|
||||
export function FilterItem({ filter, event }: FilterProps) {
|
||||
const { projectId } = useAppParams();
|
||||
const getLabel = useMappings();
|
||||
const dispatch = useDispatch();
|
||||
const potentialValues = api.chart.values.useQuery({
|
||||
event: event.name,
|
||||
property: filter.name,
|
||||
projectId: params?.projectId!,
|
||||
projectId,
|
||||
});
|
||||
|
||||
const valuesCombobox =
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { cva } from 'class-variance-authority';
|
||||
import type { VariantProps } from 'class-variance-authority';
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
'inline-flex items-center rounded-full border px-1.5 h-[20px] text-[10px] font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@@ -16,6 +16,8 @@ const badgeVariants = cva(
|
||||
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
destructive:
|
||||
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
|
||||
success:
|
||||
'border-transparent bg-emerald-500 text-emerald-100 hover:bg-emerald-500/80',
|
||||
outline: 'text-foreground',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -38,7 +38,7 @@ const buttonVariants = cva(
|
||||
}
|
||||
);
|
||||
|
||||
interface ButtonProps
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import type { ButtonProps } from '@/components/ui/button';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Command,
|
||||
@@ -15,9 +16,10 @@ import {
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { cn } from '@/utils/cn';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { Check, ChevronsUpDown } from 'lucide-react';
|
||||
|
||||
interface ComboboxProps<T> {
|
||||
export interface ComboboxProps<T> {
|
||||
placeholder: string;
|
||||
items: {
|
||||
value: T;
|
||||
@@ -30,8 +32,18 @@ interface ComboboxProps<T> {
|
||||
onCreate?: (value: T) => void;
|
||||
className?: string;
|
||||
searchable?: boolean;
|
||||
icon?: LucideIcon;
|
||||
size?: ButtonProps['size'];
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export type ExtendedComboboxProps<T> = Omit<
|
||||
ComboboxProps<T>,
|
||||
'items' | 'placeholder'
|
||||
> & {
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
export function Combobox<T extends string>({
|
||||
placeholder,
|
||||
items,
|
||||
@@ -41,6 +53,9 @@ export function Combobox<T extends string>({
|
||||
onCreate,
|
||||
className,
|
||||
searchable,
|
||||
icon: Icon,
|
||||
size,
|
||||
label,
|
||||
}: ComboboxProps<T>) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [search, setSearch] = React.useState('');
|
||||
@@ -55,11 +70,13 @@ export function Combobox<T extends string>({
|
||||
<PopoverTrigger asChild>
|
||||
{children ?? (
|
||||
<Button
|
||||
size={size}
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className={cn('justify-between min-w-[150px]', className)}
|
||||
className={cn('justify-between', className)}
|
||||
>
|
||||
{Icon ? <Icon className="mr-2" size={16} /> : null}
|
||||
<span className="overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{value ? find(value)?.label ?? 'No match' : placeholder}
|
||||
</span>
|
||||
@@ -67,7 +84,7 @@ export function Combobox<T extends string>({
|
||||
</Button>
|
||||
)}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full min-w-0 p-0" align="start">
|
||||
<PopoverContent className="w-full max-w-md p-0" align="start">
|
||||
<Command>
|
||||
{searchable === true && (
|
||||
<CommandInput
|
||||
@@ -80,7 +97,7 @@ export function Combobox<T extends string>({
|
||||
<CommandEmpty className="p-2">
|
||||
<Button
|
||||
onClick={() => {
|
||||
onCreate(search);
|
||||
onCreate(search as T);
|
||||
setSearch('');
|
||||
setOpen(false);
|
||||
}}
|
||||
@@ -99,7 +116,7 @@ export function Combobox<T extends string>({
|
||||
value={item.value}
|
||||
onSelect={(currentValue) => {
|
||||
const value = find(currentValue)?.value ?? currentValue;
|
||||
onChange(value);
|
||||
onChange(value as T);
|
||||
setOpen(false);
|
||||
}}
|
||||
{...(item.disabled && { disabled: true })}
|
||||
|
||||
@@ -32,7 +32,7 @@ const SheetOverlay = React.forwardRef<
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
|
||||
|
||||
const sheetVariants = cva(
|
||||
'fixed z-50 gap-4 bg-background shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
|
||||
'fixed z-50 gap-4 bg-background shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-150 data-[state=open]:duration-150',
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
|
||||
@@ -5,10 +5,13 @@ import { cn } from '@/utils/cn';
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement> & { wrapper?: boolean }
|
||||
>(({ className, wrapper, ...props }, ref) => (
|
||||
React.HTMLAttributes<HTMLTableElement> & {
|
||||
wrapper?: boolean;
|
||||
overflow?: boolean;
|
||||
}
|
||||
>(({ className, wrapper, overflow = true, ...props }, ref) => (
|
||||
<div className={cn('border border-border rounded-md bg-white', className)}>
|
||||
<div className="relative w-full overflow-auto ">
|
||||
<div className={cn('relative w-full', overflow && 'overflow-auto')}>
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn(
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { isNil } from 'ramda';
|
||||
|
||||
export function useNumber() {
|
||||
const locale = 'en-gb';
|
||||
|
||||
return {
|
||||
format: (value: number) => {
|
||||
format: (value: number | null | undefined) => {
|
||||
if (isNil(value)) {
|
||||
return 'N/A';
|
||||
}
|
||||
return new Intl.NumberFormat(locale, {
|
||||
maximumSignificantDigits: 20,
|
||||
}).format(value);
|
||||
|
||||
@@ -4,20 +4,20 @@ import { getChartColor } from '@/utils/theme';
|
||||
|
||||
export type IRechartPayloadItem = IChartSerieDataItem & { color: string };
|
||||
|
||||
export function useRechartDataModel(data: IChartData) {
|
||||
export function useRechartDataModel(series: IChartData['series']) {
|
||||
return useMemo(() => {
|
||||
return (
|
||||
data.series[0]?.data.map(({ date }) => {
|
||||
series[0]?.data.map(({ date }) => {
|
||||
return {
|
||||
date,
|
||||
...data.series.reduce((acc, serie, idx) => {
|
||||
...series.reduce((acc, serie, idx) => {
|
||||
return {
|
||||
...acc,
|
||||
...serie.data.reduce(
|
||||
(acc2, item) => {
|
||||
if (item.date === date) {
|
||||
if (item.previous) {
|
||||
acc2[`${idx}:prev:count`] = item.previous.count;
|
||||
acc2[`${idx}:prev:count`] = item.previous.value;
|
||||
}
|
||||
acc2[`${idx}:count`] = item.count;
|
||||
acc2[`${idx}:payload`] = {
|
||||
@@ -34,5 +34,5 @@ export function useRechartDataModel(data: IChartData) {
|
||||
};
|
||||
}) ?? []
|
||||
);
|
||||
}, [data]);
|
||||
}, [series]);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { IChartData } from '@/app/_trpc/client';
|
||||
|
||||
export type IVisibleSeries = ReturnType<typeof useVisibleSeries>['series'];
|
||||
export function useVisibleSeries(data: IChartData, limit?: number | undefined) {
|
||||
const max = limit ?? 5;
|
||||
const [visibleSeries, setVisibleSeries] = useState<string[]>([]);
|
||||
|
||||
@@ -10,7 +10,7 @@ import { toast } from '@/components/ui/use-toast';
|
||||
import { useAppParams } from '@/hooks/useAppParams';
|
||||
import type { IChartInput } from '@/types';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useParams, useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
|
||||
264
apps/web/src/server/api/routers/chart.formula.ts
Normal file
264
apps/web/src/server/api/routers/chart.formula.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1,31 +1,56 @@
|
||||
import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
|
||||
import * as cache from '@/server/cache';
|
||||
import { getChartSql } from '@/server/chart-sql/getChartSql';
|
||||
import { isJsonPath, selectJsonPath } from '@/server/chart-sql/helpers';
|
||||
import { db } from '@/server/db';
|
||||
import { getUniqueEvents } from '@/server/services/event.service';
|
||||
import type {
|
||||
IChartEvent,
|
||||
IChartRange,
|
||||
IGetChartDataInput,
|
||||
IInterval,
|
||||
} from '@/types';
|
||||
import { alphabetIds } from '@/utils/constants';
|
||||
import type { IChartEvent, IChartInput, IChartRange } from '@/types';
|
||||
import { getDaysOldDate } from '@/utils/date';
|
||||
import { average, round, sum } from '@/utils/math';
|
||||
import { toDots } from '@/utils/object';
|
||||
import { zChartInputWithDates } from '@/utils/validation';
|
||||
import { pipe, sort, uniq } from 'ramda';
|
||||
import { average, max, min, round, sum } from '@/utils/math';
|
||||
import { zChartInput } from '@/utils/validation';
|
||||
import { flatten, map, pathOr, pipe, prop, sort, uniq } from 'ramda';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { chQuery } from '@mixan/db';
|
||||
|
||||
import { getChartData, withFormula } from './chart.formula';
|
||||
|
||||
type PreviousValue = {
|
||||
value: number;
|
||||
diff: number | null;
|
||||
state: 'positive' | 'negative' | 'neutral';
|
||||
} | null;
|
||||
|
||||
interface Metrics {
|
||||
sum: number;
|
||||
average: number;
|
||||
min: number;
|
||||
max: number;
|
||||
previous: {
|
||||
sum: PreviousValue;
|
||||
average: PreviousValue;
|
||||
min: PreviousValue;
|
||||
max: PreviousValue;
|
||||
};
|
||||
}
|
||||
|
||||
interface FinalChart {
|
||||
events: IChartInput['events'];
|
||||
series: {
|
||||
name: string;
|
||||
event: IChartEvent;
|
||||
metrics: Metrics;
|
||||
data: {
|
||||
date: string;
|
||||
count: number;
|
||||
label: string | null;
|
||||
previous: PreviousValue;
|
||||
}[];
|
||||
}[];
|
||||
metrics: Metrics;
|
||||
}
|
||||
|
||||
export const chartRouter = createTRPCRouter({
|
||||
events: protectedProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.query(async ({ input: { projectId } }) => {
|
||||
const events = await cache.getOr(
|
||||
`events_${projectId}`,
|
||||
1000 * 60 * 60 * 24,
|
||||
() => getUniqueEvents({ projectId: projectId })
|
||||
const events = await chQuery<{ name: string }>(
|
||||
`SELECT DISTINCT name FROM events WHERE project_id = '${projectId}'`
|
||||
);
|
||||
|
||||
return [
|
||||
@@ -39,32 +64,36 @@ export const chartRouter = createTRPCRouter({
|
||||
properties: protectedProcedure
|
||||
.input(z.object({ event: z.string().optional(), projectId: z.string() }))
|
||||
.query(async ({ input: { projectId, event } }) => {
|
||||
const events = await cache.getOr(
|
||||
`events_${projectId}_${event ?? 'all'}`,
|
||||
1000 * 60 * 60,
|
||||
() =>
|
||||
db.event.findMany({
|
||||
take: 500,
|
||||
distinct: 'name',
|
||||
where: {
|
||||
project_id: projectId,
|
||||
...(event
|
||||
? {
|
||||
name: event,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
})
|
||||
const events = await chQuery<{ keys: string[] }>(
|
||||
`SELECT distinct mapKeys(properties) as keys from events where ${
|
||||
event && event !== '*' ? `name = '${event}' AND ` : ''
|
||||
} project_id = '${projectId}';`
|
||||
);
|
||||
|
||||
const properties = events
|
||||
.reduce((acc, event) => {
|
||||
const properties = event as Record<string, unknown>;
|
||||
const dotNotation = toDots(properties);
|
||||
return [...acc, ...Object.keys(dotNotation)];
|
||||
}, [] as string[])
|
||||
.flatMap((event) => event.keys)
|
||||
.map((item) => item.replace(/\.([0-9]+)\./g, '.*.'))
|
||||
.map((item) => item.replace(/\.([0-9]+)/g, '[*]'));
|
||||
.map((item) => item.replace(/\.([0-9]+)/g, '[*]'))
|
||||
.map((item) => `properties.${item}`);
|
||||
|
||||
properties.push(
|
||||
'name',
|
||||
'path',
|
||||
'referrer',
|
||||
'referrer_name',
|
||||
'duration',
|
||||
'created_at',
|
||||
'country',
|
||||
'city',
|
||||
'region',
|
||||
'os',
|
||||
'os_version',
|
||||
'browser',
|
||||
'browser_version',
|
||||
'device',
|
||||
'brand',
|
||||
'model'
|
||||
);
|
||||
|
||||
return pipe(
|
||||
sort<string>((a, b) => a.length - b.length),
|
||||
@@ -81,162 +110,226 @@ export const chartRouter = createTRPCRouter({
|
||||
})
|
||||
)
|
||||
.query(async ({ input: { event, property, projectId } }) => {
|
||||
const intervalInDays = 180;
|
||||
if (isJsonPath(property)) {
|
||||
const events = await db.$queryRawUnsafe<{ value: string }[]>(
|
||||
`SELECT ${selectJsonPath(
|
||||
property
|
||||
)} AS value from events WHERE project_id = '${projectId}' AND name = '${event}' AND "createdAt" >= NOW() - INTERVAL '${intervalInDays} days'`
|
||||
);
|
||||
const sql = property.startsWith('properties.')
|
||||
? `SELECT distinct mapValues(mapExtractKeyLike(properties, '${property
|
||||
.replace(/^properties\./, '')
|
||||
.replace(
|
||||
'.*.',
|
||||
'.%.'
|
||||
)}')) as values from events where name = '${event}' AND project_id = '${projectId}';`
|
||||
: `SELECT ${property} as values from events where name = '${event}' AND project_id = '${projectId}';`;
|
||||
|
||||
const events = await chQuery<{ values: string[] }>(sql);
|
||||
|
||||
const values = pipe(
|
||||
(data: typeof events) => map(prop('values'), data),
|
||||
flatten,
|
||||
uniq,
|
||||
sort((a, b) => a.length - b.length)
|
||||
)(events);
|
||||
|
||||
return {
|
||||
values,
|
||||
};
|
||||
}),
|
||||
|
||||
chart: protectedProcedure.input(zChartInput).query(async ({ input }) => {
|
||||
const current = getDatesFromRange(input.range);
|
||||
let diff = 0;
|
||||
|
||||
switch (input.range) {
|
||||
case '30min': {
|
||||
diff = 1000 * 60 * 30;
|
||||
break;
|
||||
}
|
||||
case '1h': {
|
||||
diff = 1000 * 60 * 60;
|
||||
break;
|
||||
}
|
||||
case '24h':
|
||||
case 'today': {
|
||||
diff = 1000 * 60 * 60 * 24;
|
||||
break;
|
||||
}
|
||||
case '7d': {
|
||||
diff = 1000 * 60 * 60 * 24 * 7;
|
||||
break;
|
||||
}
|
||||
case '14d': {
|
||||
diff = 1000 * 60 * 60 * 24 * 14;
|
||||
break;
|
||||
}
|
||||
case '1m': {
|
||||
diff = 1000 * 60 * 60 * 24 * 30;
|
||||
break;
|
||||
}
|
||||
case '3m': {
|
||||
diff = 1000 * 60 * 60 * 24 * 90;
|
||||
break;
|
||||
}
|
||||
case '6m': {
|
||||
diff = 1000 * 60 * 60 * 24 * 180;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const promises = [getSeriesFromEvents(input)];
|
||||
|
||||
if (input.previous) {
|
||||
console.log('------->P R E V I O U S');
|
||||
console.log({
|
||||
startDate: new Date(
|
||||
new Date(current.startDate).getTime() - diff
|
||||
).toISOString(),
|
||||
endDate: new Date(
|
||||
new Date(current.endDate).getTime() - diff
|
||||
).toISOString(),
|
||||
});
|
||||
|
||||
promises.push(
|
||||
getSeriesFromEvents({
|
||||
...input,
|
||||
...{
|
||||
startDate: new Date(
|
||||
new Date(current.startDate).getTime() - diff
|
||||
).toISOString(),
|
||||
endDate: new Date(
|
||||
new Date(current.endDate).getTime() - diff
|
||||
).toISOString(),
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const result = await Promise.all(promises);
|
||||
const series = result[0]!;
|
||||
const previousSeries = result[1];
|
||||
|
||||
const final: FinalChart = {
|
||||
events: input.events,
|
||||
series: series.map((serie, index) => {
|
||||
const previousSerie = previousSeries?.[index];
|
||||
const metrics = {
|
||||
sum: sum(serie.data.map((item) => item.count)),
|
||||
average: round(average(serie.data.map((item) => item.count)), 2),
|
||||
min: min(serie.data.map((item) => item.count)),
|
||||
max: max(serie.data.map((item) => item.count)),
|
||||
};
|
||||
|
||||
return {
|
||||
values: uniq(events.map((item) => item.value)),
|
||||
};
|
||||
} else {
|
||||
const events = await db.event.findMany({
|
||||
where: {
|
||||
project_id: projectId,
|
||||
name: event,
|
||||
[property]: {
|
||||
not: null,
|
||||
},
|
||||
createdAt: {
|
||||
gte: new Date(
|
||||
new Date().getTime() - 1000 * 60 * 60 * 24 * intervalInDays
|
||||
name: serie.name,
|
||||
event: serie.event,
|
||||
metrics: {
|
||||
...metrics,
|
||||
previous: {
|
||||
sum: getPreviousMetric(
|
||||
metrics.sum,
|
||||
previousSerie
|
||||
? sum(previousSerie?.data.map((item) => item.count))
|
||||
: null
|
||||
),
|
||||
average: getPreviousMetric(
|
||||
metrics.average,
|
||||
previousSerie
|
||||
? round(
|
||||
average(previousSerie?.data.map((item) => item.count)),
|
||||
2
|
||||
)
|
||||
: null
|
||||
),
|
||||
min: getPreviousMetric(
|
||||
metrics.sum,
|
||||
previousSerie
|
||||
? min(previousSerie?.data.map((item) => item.count))
|
||||
: null
|
||||
),
|
||||
max: getPreviousMetric(
|
||||
metrics.sum,
|
||||
previousSerie
|
||||
? max(previousSerie?.data.map((item) => item.count))
|
||||
: null
|
||||
),
|
||||
},
|
||||
},
|
||||
distinct: property as any,
|
||||
select: {
|
||||
[property]: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
values: uniq(events.map((item) => item[property]!)),
|
||||
data: serie.data.map((item, index) => ({
|
||||
date: item.date,
|
||||
count: item.count ?? 0,
|
||||
label: item.label,
|
||||
previous: previousSerie?.data[index]
|
||||
? getPreviousMetric(
|
||||
item.count ?? 0,
|
||||
previousSerie?.data[index]?.count ?? null
|
||||
)
|
||||
: null,
|
||||
})),
|
||||
};
|
||||
}),
|
||||
metrics: {
|
||||
sum: 0,
|
||||
average: 0,
|
||||
min: 0,
|
||||
max: 0,
|
||||
previous: {
|
||||
sum: null,
|
||||
average: null,
|
||||
min: null,
|
||||
max: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
final.metrics.sum = sum(final.series.map((item) => item.metrics.sum));
|
||||
final.metrics.average = round(
|
||||
average(final.series.map((item) => item.metrics.average)),
|
||||
2
|
||||
);
|
||||
final.metrics.min = min(final.series.map((item) => item.metrics.min));
|
||||
final.metrics.max = max(final.series.map((item) => item.metrics.max));
|
||||
final.metrics.previous = {
|
||||
sum: getPreviousMetric(
|
||||
sum(final.series.map((item) => item.metrics.sum)),
|
||||
sum(final.series.map((item) => item.metrics.previous.sum?.value ?? 0))
|
||||
),
|
||||
average: getPreviousMetric(
|
||||
round(average(final.series.map((item) => item.metrics.average)), 2),
|
||||
round(
|
||||
average(
|
||||
final.series.map(
|
||||
(item) => item.metrics.previous.average?.value ?? 0
|
||||
)
|
||||
),
|
||||
2
|
||||
)
|
||||
),
|
||||
min: getPreviousMetric(
|
||||
min(final.series.map((item) => item.metrics.min)),
|
||||
min(final.series.map((item) => item.metrics.previous.min?.value ?? 0))
|
||||
),
|
||||
max: getPreviousMetric(
|
||||
max(final.series.map((item) => item.metrics.max)),
|
||||
max(final.series.map((item) => item.metrics.previous.max?.value ?? 0))
|
||||
),
|
||||
};
|
||||
|
||||
final.series = final.series.sort((a, b) => {
|
||||
if (input.chartType === 'linear') {
|
||||
const sumA = a.data.reduce((acc, item) => acc + (item.count ?? 0), 0);
|
||||
const sumB = b.data.reduce((acc, item) => acc + (item.count ?? 0), 0);
|
||||
return sumB - sumA;
|
||||
} else {
|
||||
return b.metrics[input.metric] - a.metrics[input.metric];
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
chart: protectedProcedure
|
||||
.input(zChartInputWithDates.merge(z.object({ projectId: z.string() })))
|
||||
.query(async ({ input }) => {
|
||||
const current = getDatesFromRange(input.range);
|
||||
let diff = 0;
|
||||
|
||||
switch (input.range) {
|
||||
case '30min': {
|
||||
diff = 1000 * 60 * 30;
|
||||
break;
|
||||
}
|
||||
case '1h': {
|
||||
diff = 1000 * 60 * 60;
|
||||
break;
|
||||
}
|
||||
case '24h':
|
||||
case 'today': {
|
||||
diff = 1000 * 60 * 60 * 24;
|
||||
break;
|
||||
}
|
||||
case '7d': {
|
||||
diff = 1000 * 60 * 60 * 24 * 17;
|
||||
break;
|
||||
}
|
||||
case '14d': {
|
||||
diff = 1000 * 60 * 60 * 24 * 14;
|
||||
break;
|
||||
}
|
||||
case '1m': {
|
||||
diff = 1000 * 60 * 60 * 24 * 30;
|
||||
break;
|
||||
}
|
||||
case '3m': {
|
||||
diff = 1000 * 60 * 60 * 24 * 90;
|
||||
break;
|
||||
}
|
||||
case '6m': {
|
||||
diff = 1000 * 60 * 60 * 24 * 180;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const promises = [wrapper(input)];
|
||||
|
||||
if (input.previous) {
|
||||
promises.push(
|
||||
wrapper({
|
||||
...input,
|
||||
...{
|
||||
startDate: new Date(
|
||||
new Date(current.startDate).getTime() - diff
|
||||
).toISOString(),
|
||||
endDate: new Date(
|
||||
new Date(current.endDate).getTime() - diff
|
||||
).toISOString(),
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const awaitedPromises = await Promise.all(promises);
|
||||
const data = awaitedPromises[0]!;
|
||||
const previousData = awaitedPromises[1];
|
||||
|
||||
return {
|
||||
...data,
|
||||
series: data.series.map((item, sIndex) => {
|
||||
function getPreviousDiff(key: keyof (typeof data)['metrics']) {
|
||||
const prev = previousData?.series?.[sIndex]?.metrics?.[key];
|
||||
const diff = getPreviousDataDiff(item.metrics[key], prev);
|
||||
|
||||
return diff && prev
|
||||
? {
|
||||
diff: diff?.diff,
|
||||
state: diff?.state,
|
||||
value: prev,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
metrics: {
|
||||
...item.metrics,
|
||||
previous: {
|
||||
sum: getPreviousDiff('sum'),
|
||||
average: getPreviousDiff('average'),
|
||||
},
|
||||
},
|
||||
data: item.data.map((item, dIndex) => {
|
||||
const diff = getPreviousDataDiff(
|
||||
item.count,
|
||||
previousData?.series?.[sIndex]?.data?.[dIndex]?.count
|
||||
);
|
||||
return {
|
||||
...item,
|
||||
previous:
|
||||
diff && previousData?.series?.[sIndex]?.data?.[dIndex]
|
||||
? Object.assign(
|
||||
{},
|
||||
previousData?.series?.[sIndex]?.data?.[dIndex],
|
||||
diff
|
||||
)
|
||||
: null,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}),
|
||||
return final;
|
||||
}),
|
||||
});
|
||||
|
||||
const chartValidator = zChartInputWithDates.merge(
|
||||
z.object({ projectId: z.string() })
|
||||
);
|
||||
type ChartInput = z.infer<typeof chartValidator>;
|
||||
|
||||
function getPreviousDataDiff(current: number, previous: number | undefined) {
|
||||
if (!previous) {
|
||||
function getPreviousMetric(
|
||||
current: number,
|
||||
previous: number | null
|
||||
): PreviousValue {
|
||||
if (previous === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -262,10 +355,11 @@ function getPreviousDataDiff(current: number, previous: number | undefined) {
|
||||
: current < previous
|
||||
? 'negative'
|
||||
: 'neutral',
|
||||
value: previous,
|
||||
};
|
||||
}
|
||||
|
||||
async function wrapper({ events, projectId, ...input }: ChartInput) {
|
||||
async function getSeriesFromEvents(input: IChartInput) {
|
||||
const { startDate, endDate } =
|
||||
input.startDate && input.endDate
|
||||
? {
|
||||
@@ -273,65 +367,30 @@ async function wrapper({ events, projectId, ...input }: ChartInput) {
|
||||
endDate: input.endDate,
|
||||
}
|
||||
: getDatesFromRange(input.range);
|
||||
const series: Awaited<ReturnType<typeof getChartData>> = [];
|
||||
for (const event of events) {
|
||||
const result = await getChartData({
|
||||
...input,
|
||||
startDate,
|
||||
endDate,
|
||||
event,
|
||||
projectId: projectId,
|
||||
});
|
||||
series.push(...result);
|
||||
}
|
||||
|
||||
const sorted = [...series].sort((a, b) => {
|
||||
if (input.chartType === 'linear') {
|
||||
const sumA = a.data.reduce((acc, item) => acc + item.count, 0);
|
||||
const sumB = b.data.reduce((acc, item) => acc + item.count, 0);
|
||||
return sumB - sumA;
|
||||
} else {
|
||||
return b.metrics.sum - a.metrics.sum;
|
||||
}
|
||||
});
|
||||
|
||||
const metrics = {
|
||||
max: Math.max(...sorted.map((item) => item.metrics.max)),
|
||||
min: Math.min(...sorted.map((item) => item.metrics.min)),
|
||||
sum: sum(sorted.map((item) => item.metrics.sum, 0)),
|
||||
average: round(average(sorted.map((item) => item.metrics.average, 0)), 2),
|
||||
};
|
||||
|
||||
return {
|
||||
events: Object.entries(
|
||||
series.reduce(
|
||||
(acc, item) => {
|
||||
if (acc[item.event.id]) {
|
||||
acc[item.event.id] += item.metrics.sum;
|
||||
} else {
|
||||
acc[item.event.id] = item.metrics.sum;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<(typeof series)[number]['event']['id'], number>
|
||||
const series = (
|
||||
await Promise.all(
|
||||
input.events.map(async (event) =>
|
||||
getChartData({
|
||||
...input,
|
||||
startDate,
|
||||
endDate,
|
||||
event,
|
||||
})
|
||||
)
|
||||
).map(([id, count]) => ({
|
||||
count,
|
||||
...events.find((event) => event.id === id)!,
|
||||
})),
|
||||
series: sorted,
|
||||
metrics,
|
||||
};
|
||||
}
|
||||
)
|
||||
).flat();
|
||||
|
||||
interface ResultItem {
|
||||
label: string | null;
|
||||
count: number;
|
||||
date: string;
|
||||
}
|
||||
|
||||
function getEventLegend(event: IChartEvent) {
|
||||
return event.displayName ?? `${event.name} (${event.id})`;
|
||||
return withFormula(input, series);
|
||||
// .sort((a, b) => {
|
||||
// if (input.chartType === 'linear') {
|
||||
// const sumA = a.data.reduce((acc, item) => acc + (item.count ?? 0), 0);
|
||||
// const sumB = b.data.reduce((acc, item) => acc + (item.count ?? 0), 0);
|
||||
// return sumB - sumA;
|
||||
// } else {
|
||||
// return b.metrics.sum - a.metrics.sum;
|
||||
// }
|
||||
// });
|
||||
}
|
||||
|
||||
function getDatesFromRange(range: IChartRange) {
|
||||
@@ -385,206 +444,3 @@ function getDatesFromRange(range: IChartRange) {
|
||||
endDate: endDate.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async function getChartData(payload: IGetChartDataInput) {
|
||||
let result = await db.$queryRawUnsafe<ResultItem[]>(getChartSql(payload));
|
||||
|
||||
if (result.length === 0 && payload.breakdowns.length > 0) {
|
||||
result = await db.$queryRawUnsafe<ResultItem[]>(
|
||||
getChartSql({
|
||||
...payload,
|
||||
breakdowns: [],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// group by sql label
|
||||
const series = result.reduce(
|
||||
(acc, item) => {
|
||||
// item.label can be null when using breakdowns on a property
|
||||
// that doesn't exist on all events
|
||||
const label = item.label?.trim() ?? payload.event.id;
|
||||
if (label) {
|
||||
if (acc[label]) {
|
||||
acc[label]?.push(item);
|
||||
} else {
|
||||
acc[label] = [item];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...acc,
|
||||
};
|
||||
},
|
||||
{} as Record<string, ResultItem[]>
|
||||
);
|
||||
|
||||
return Object.keys(series).map((key) => {
|
||||
// If we have breakdowns, we want to use the breakdown key as the legend
|
||||
// But only if it successfully broke it down, otherwise we use the getEventLabel
|
||||
const legend =
|
||||
payload.breakdowns.length && !alphabetIds.includes(key as 'A')
|
||||
? key
|
||||
: getEventLegend(payload.event);
|
||||
const data =
|
||||
payload.chartType === 'area' ||
|
||||
payload.chartType === 'linear' ||
|
||||
payload.chartType === 'histogram' ||
|
||||
payload.chartType === 'metric' ||
|
||||
payload.chartType === 'pie' ||
|
||||
payload.chartType === 'bar'
|
||||
? fillEmptySpotsInTimeline(
|
||||
series[key] ?? [],
|
||||
payload.interval,
|
||||
payload.startDate,
|
||||
payload.endDate
|
||||
).map((item) => {
|
||||
return {
|
||||
label: legend,
|
||||
count: round(item.count),
|
||||
date: new Date(item.date).toISOString(),
|
||||
};
|
||||
})
|
||||
: (series[key] ?? []).map((item) => ({
|
||||
label: item.label,
|
||||
count: round(item.count),
|
||||
date: new Date(item.date).toISOString(),
|
||||
}));
|
||||
|
||||
const counts = data.map((item) => item.count);
|
||||
|
||||
return {
|
||||
name: legend,
|
||||
event: payload.event,
|
||||
metrics: {
|
||||
sum: sum(counts),
|
||||
average: round(average(counts)),
|
||||
max: Math.max(...counts),
|
||||
min: Math.min(...counts),
|
||||
},
|
||||
data,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function fillEmptySpotsInTimeline(
|
||||
items: ResultItem[],
|
||||
interval: IInterval,
|
||||
startDate: string,
|
||||
endDate: string
|
||||
) {
|
||||
const result = [];
|
||||
const clonedStartDate = new Date(startDate);
|
||||
const clonedEndDate = new Date(endDate);
|
||||
const today = new Date();
|
||||
|
||||
if (interval === 'minute') {
|
||||
clonedStartDate.setUTCSeconds(0, 0);
|
||||
clonedEndDate.setUTCMinutes(clonedEndDate.getUTCMinutes() + 1, 0, 0);
|
||||
} else if (interval === 'hour') {
|
||||
clonedStartDate.setUTCMinutes(0, 0, 0);
|
||||
clonedEndDate.setUTCMinutes(0, 0, 0);
|
||||
} else {
|
||||
clonedStartDate.setUTCHours(0, 0, 0, 0);
|
||||
clonedEndDate.setUTCHours(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
if (interval === 'month') {
|
||||
clonedStartDate.setUTCDate(1);
|
||||
clonedEndDate.setUTCDate(1);
|
||||
}
|
||||
|
||||
// Force if interval is month and the start date is the same month as today
|
||||
const shouldForce = () =>
|
||||
interval === 'month' &&
|
||||
clonedStartDate.getUTCFullYear() === today.getUTCFullYear() &&
|
||||
clonedStartDate.getUTCMonth() === today.getUTCMonth();
|
||||
let prev = undefined;
|
||||
while (
|
||||
shouldForce() ||
|
||||
clonedStartDate.getTime() <= clonedEndDate.getTime()
|
||||
) {
|
||||
if (prev === clonedStartDate.getTime()) {
|
||||
console.log('GET OUT NOW!');
|
||||
break;
|
||||
}
|
||||
prev = clonedStartDate.getTime();
|
||||
|
||||
const getYear = (date: Date) => date.getUTCFullYear();
|
||||
const getMonth = (date: Date) => date.getUTCMonth();
|
||||
const getDay = (date: Date) => date.getUTCDate();
|
||||
const getHour = (date: Date) => date.getUTCHours();
|
||||
const getMinute = (date: Date) => date.getUTCMinutes();
|
||||
|
||||
const item = items.find((item) => {
|
||||
const date = new Date(item.date);
|
||||
|
||||
if (interval === 'month') {
|
||||
return (
|
||||
getYear(date) === getYear(clonedStartDate) &&
|
||||
getMonth(date) === getMonth(clonedStartDate)
|
||||
);
|
||||
}
|
||||
if (interval === 'day') {
|
||||
return (
|
||||
getYear(date) === getYear(clonedStartDate) &&
|
||||
getMonth(date) === getMonth(clonedStartDate) &&
|
||||
getDay(date) === getDay(clonedStartDate)
|
||||
);
|
||||
}
|
||||
if (interval === 'hour') {
|
||||
return (
|
||||
getYear(date) === getYear(clonedStartDate) &&
|
||||
getMonth(date) === getMonth(clonedStartDate) &&
|
||||
getDay(date) === getDay(clonedStartDate) &&
|
||||
getHour(date) === getHour(clonedStartDate)
|
||||
);
|
||||
}
|
||||
if (interval === 'minute') {
|
||||
return (
|
||||
getYear(date) === getYear(clonedStartDate) &&
|
||||
getMonth(date) === getMonth(clonedStartDate) &&
|
||||
getDay(date) === getDay(clonedStartDate) &&
|
||||
getHour(date) === getHour(clonedStartDate) &&
|
||||
getMinute(date) === getMinute(clonedStartDate)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if (item) {
|
||||
result.push({
|
||||
...item,
|
||||
date: clonedStartDate.toISOString(),
|
||||
});
|
||||
} else {
|
||||
result.push({
|
||||
date: clonedStartDate.toISOString(),
|
||||
count: 0,
|
||||
label: null,
|
||||
});
|
||||
}
|
||||
|
||||
switch (interval) {
|
||||
case 'day': {
|
||||
clonedStartDate.setUTCDate(clonedStartDate.getUTCDate() + 1);
|
||||
break;
|
||||
}
|
||||
case 'hour': {
|
||||
clonedStartDate.setUTCHours(clonedStartDate.getUTCHours() + 1);
|
||||
break;
|
||||
}
|
||||
case 'minute': {
|
||||
clonedStartDate.setUTCMinutes(clonedStartDate.getUTCMinutes() + 1);
|
||||
break;
|
||||
}
|
||||
case 'month': {
|
||||
clonedStartDate.setUTCMonth(clonedStartDate.getUTCMonth() + 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sort(function (a, b) {
|
||||
return new Date(a.date).getTime() - new Date(b.date).getTime();
|
||||
}, result);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,9 @@
|
||||
import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
|
||||
import { db } from '@/server/db';
|
||||
import { transformEvent } from '@/server/services/event.service';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { Event, Profile } from '@mixan/db';
|
||||
|
||||
function transformEvent(
|
||||
event: Event & {
|
||||
profile: Profile;
|
||||
}
|
||||
) {
|
||||
return {
|
||||
...event,
|
||||
properties: event.properties as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
import type { IDBEvent } from '@mixan/db';
|
||||
import { chQuery, createSqlBuilder } from '@mixan/db';
|
||||
|
||||
export const eventRouter = createTRPCRouter({
|
||||
list: protectedProcedure
|
||||
@@ -27,28 +17,18 @@ export const eventRouter = createTRPCRouter({
|
||||
})
|
||||
)
|
||||
.query(async ({ input: { take, skip, projectId, profileId, events } }) => {
|
||||
return db.event
|
||||
.findMany({
|
||||
take,
|
||||
skip,
|
||||
where: {
|
||||
project_id: projectId,
|
||||
profile_id: profileId,
|
||||
...(events && events.length > 0
|
||||
? {
|
||||
name: {
|
||||
in: events,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
include: {
|
||||
profile: true,
|
||||
},
|
||||
})
|
||||
.then((events) => events.map(transformEvent));
|
||||
const { sb, getSql } = createSqlBuilder();
|
||||
|
||||
sb.limit = take;
|
||||
sb.offset = skip;
|
||||
sb.where.projectId = `project_id = '${projectId}'`;
|
||||
if (profileId) {
|
||||
sb.where.profileId = `profile_id = '${profileId}'`;
|
||||
}
|
||||
if (events?.length) {
|
||||
sb.where.name = `name IN (${events.map((e) => `'${e}'`).join(',')})`;
|
||||
}
|
||||
|
||||
return (await chQuery<IDBEvent>(getSql())).map(transformEvent);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -53,7 +53,7 @@ export const reportRouter = createTRPCRouter({
|
||||
save: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
report: zChartInput,
|
||||
report: zChartInput.omit({ projectId: true }),
|
||||
dashboardId: z.string(),
|
||||
})
|
||||
)
|
||||
@@ -74,6 +74,7 @@ export const reportRouter = createTRPCRouter({
|
||||
chart_type: report.chartType,
|
||||
line_type: report.lineType,
|
||||
range: report.range,
|
||||
formula: report.formula,
|
||||
},
|
||||
});
|
||||
}),
|
||||
@@ -81,7 +82,7 @@ export const reportRouter = createTRPCRouter({
|
||||
.input(
|
||||
z.object({
|
||||
reportId: z.string(),
|
||||
report: zChartInput,
|
||||
report: zChartInput.omit({ projectId: true }),
|
||||
})
|
||||
)
|
||||
.mutation(({ input: { report, reportId } }) => {
|
||||
@@ -97,6 +98,7 @@ export const reportRouter = createTRPCRouter({
|
||||
chart_type: report.chartType,
|
||||
line_type: report.lineType,
|
||||
range: report.range,
|
||||
formula: report.formula,
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
@@ -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()}`
|
||||
);
|
||||
}
|
||||
@@ -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, ', ')
|
||||
: '',
|
||||
};
|
||||
}
|
||||
167
apps/web/src/server/services/chart.service.ts
Normal file
167
apps/web/src/server/services/chart.service.ts
Normal 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()}`
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,15 @@
|
||||
import type { IDBEvent } from '@mixan/db';
|
||||
|
||||
import { db } from '../db';
|
||||
|
||||
export function transformEvent({ created_at, ...event }: IDBEvent) {
|
||||
return {
|
||||
...event,
|
||||
profile: undefined,
|
||||
createdAt: new Date(created_at),
|
||||
};
|
||||
}
|
||||
|
||||
export function getUniqueEvents({ projectId }: { projectId: string }) {
|
||||
return db.event.findMany({
|
||||
take: 500,
|
||||
|
||||
@@ -23,3 +23,15 @@ export function getOrganizationById(id: string) {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function getOrganizationByProjectId(projectId: string) {
|
||||
return db.organization.findFirst({
|
||||
where: {
|
||||
projects: {
|
||||
some: {
|
||||
id: projectId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { unstable_cache } from 'next/cache';
|
||||
|
||||
import { chQuery } from '@mixan/db';
|
||||
|
||||
import { db } from '../db';
|
||||
|
||||
export type IServiceProject = Awaited<ReturnType<typeof getProjectById>>;
|
||||
@@ -20,6 +22,17 @@ export function getProjectsByOrganizationId(organizationId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function getProjectWithMostEvents(organizationId: string) {
|
||||
return db.project.findFirst({
|
||||
where: {
|
||||
organization_id: organizationId,
|
||||
},
|
||||
orderBy: {
|
||||
eventsCount: 'desc',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function getFirstProjectByOrganizationId(organizationId: string) {
|
||||
const tag = `getFirstProjectByOrganizationId_${organizationId}`;
|
||||
return unstable_cache(
|
||||
|
||||
@@ -26,7 +26,7 @@ export function transformFilter(
|
||||
};
|
||||
}
|
||||
|
||||
export function transformEvent(
|
||||
export function transformReportEvent(
|
||||
event: Partial<IChartEvent>,
|
||||
index: number
|
||||
): IChartEvent {
|
||||
@@ -36,6 +36,7 @@ export function transformEvent(
|
||||
id: event.id ?? alphabetIds[index]!,
|
||||
name: event.name || 'unknown_event',
|
||||
displayName: event.displayName,
|
||||
property: event.property,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -44,7 +45,8 @@ export function transformReport(
|
||||
): IChartInput & { id: string } {
|
||||
return {
|
||||
id: report.id,
|
||||
events: (report.events as IChartEvent[]).map(transformEvent),
|
||||
projectId: report.project_id,
|
||||
events: (report.events as IChartEvent[]).map(transformReportEvent),
|
||||
breakdowns: report.breakdowns as IChartBreakdown[],
|
||||
chartType: report.chart_type,
|
||||
lineType: (report.line_type ?? 'kuk') as IChartLineType,
|
||||
@@ -52,6 +54,9 @@ export function transformReport(
|
||||
name: report.name || 'Untitled',
|
||||
range: (report.range as IChartRange) ?? timeRanges['1m'],
|
||||
previous: report.previous ?? false,
|
||||
formula: report.formula ?? undefined,
|
||||
metric: report.metric ?? 'sum',
|
||||
unit: report.unit ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@ import type {
|
||||
zChartBreakdown,
|
||||
zChartEvent,
|
||||
zChartInput,
|
||||
zChartInputWithDates,
|
||||
zChartType,
|
||||
zLineType,
|
||||
zMetric,
|
||||
zTimeInterval,
|
||||
} from '@/utils/validation';
|
||||
import type { TooltipProps } from 'recharts';
|
||||
@@ -19,7 +19,6 @@ export type HtmlProps<T> = Omit<
|
||||
>;
|
||||
|
||||
export type IChartInput = z.infer<typeof zChartInput>;
|
||||
export type IChartInputWithDates = z.infer<typeof zChartInputWithDates>;
|
||||
export type IChartEvent = z.infer<typeof zChartEvent>;
|
||||
export type IChartEventFilter = IChartEvent['filters'][number];
|
||||
export type IChartEventFilterValue =
|
||||
@@ -27,6 +26,7 @@ export type IChartEventFilterValue =
|
||||
export type IChartBreakdown = z.infer<typeof zChartBreakdown>;
|
||||
export type IInterval = z.infer<typeof zTimeInterval>;
|
||||
export type IChartType = z.infer<typeof zChartType>;
|
||||
export type IChartMetric = z.infer<typeof zMetric>;
|
||||
export type IChartLineType = z.infer<typeof zLineType>;
|
||||
export type IChartRange = keyof typeof timeRanges;
|
||||
export type IToolTipProps<T> = Omit<TooltipProps<number, string>, 'payload'> & {
|
||||
@@ -43,7 +43,4 @@ export type IGetChartDataInput = {
|
||||
projectId: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
} & Omit<
|
||||
IChartInputWithDates,
|
||||
'events' | 'name' | 'startDate' | 'endDate' | 'range'
|
||||
>;
|
||||
} & Omit<IChartInput, 'events' | 'name' | 'startDate' | 'endDate' | 'range'>;
|
||||
|
||||
@@ -3,7 +3,7 @@ export const operators = {
|
||||
isNot: 'Is not',
|
||||
contains: 'Contains',
|
||||
doesNotContain: 'Not contains',
|
||||
};
|
||||
} as const;
|
||||
|
||||
export const chartTypes = {
|
||||
linear: 'Linear',
|
||||
@@ -12,7 +12,8 @@ export const chartTypes = {
|
||||
pie: 'Pie',
|
||||
metric: 'Metric',
|
||||
area: 'Area',
|
||||
};
|
||||
map: 'Map',
|
||||
} as const;
|
||||
|
||||
export const lineTypes = {
|
||||
monotone: 'Monotone',
|
||||
@@ -30,14 +31,14 @@ export const lineTypes = {
|
||||
bumpY: 'Bump Y',
|
||||
bump: 'Bump',
|
||||
linearClosed: 'Linear closed',
|
||||
};
|
||||
} as const;
|
||||
|
||||
export const intervals = {
|
||||
minute: 'Minute',
|
||||
day: 'Day',
|
||||
hour: 'Hour',
|
||||
month: 'Month',
|
||||
};
|
||||
minute: 'minute',
|
||||
day: 'day',
|
||||
hour: 'hour',
|
||||
month: 'month',
|
||||
} as const;
|
||||
|
||||
export const alphabetIds = [
|
||||
'A',
|
||||
@@ -65,6 +66,13 @@ export const timeRanges = {
|
||||
'1y': '1y',
|
||||
} as const;
|
||||
|
||||
export const metrics = {
|
||||
sum: 'sum',
|
||||
average: 'average',
|
||||
min: 'min',
|
||||
max: 'max',
|
||||
} as const;
|
||||
|
||||
export function isMinuteIntervalEnabledByRange(range: keyof typeof timeRanges) {
|
||||
return range === '30min' || range === '1h';
|
||||
}
|
||||
@@ -77,7 +85,9 @@ export function isHourIntervalEnabledByRange(range: keyof typeof timeRanges) {
|
||||
);
|
||||
}
|
||||
|
||||
export function getDefaultIntervalByRange(range: keyof typeof timeRanges) {
|
||||
export function getDefaultIntervalByRange(
|
||||
range: keyof typeof timeRanges
|
||||
): keyof typeof intervals {
|
||||
if (range === '30min' || range === '1h') {
|
||||
return 'minute';
|
||||
} else if (range === 'today' || range === '24h') {
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
import { isNumber } from 'mathjs';
|
||||
|
||||
export const round = (num: number, decimals = 2) => {
|
||||
const factor = Math.pow(10, decimals);
|
||||
return Math.round((num + Number.EPSILON) * factor) / factor;
|
||||
};
|
||||
|
||||
export const average = (arr: number[]) =>
|
||||
arr.reduce((p, c) => p + c, 0) / arr.length;
|
||||
export const average = (arr: (number | null)[]) => {
|
||||
const filtered = arr.filter(isNumber);
|
||||
return filtered.reduce((p, c) => p + c, 0) / filtered.length;
|
||||
};
|
||||
|
||||
export const sum = (arr: number[]) =>
|
||||
round(arr.reduce((acc, item) => acc + item, 0));
|
||||
export const sum = (arr: (number | null)[]): number =>
|
||||
round(arr.filter(isNumber).reduce((acc, item) => acc + item, 0));
|
||||
|
||||
export const min = (arr: (number | null)[]): number =>
|
||||
Math.min(...arr.filter(isNumber));
|
||||
|
||||
export const max = (arr: (number | null)[]): number =>
|
||||
Math.max(...arr.filter(isNumber));
|
||||
|
||||
export const isFloat = (n: number) => n % 1 !== 0;
|
||||
|
||||
6
apps/web/src/utils/truncate.ts
Normal file
6
apps/web/src/utils/truncate.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export function truncate(str: string, len: number) {
|
||||
if (str.length <= len) {
|
||||
return str;
|
||||
}
|
||||
return str.slice(0, len) + '...';
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
chartTypes,
|
||||
intervals,
|
||||
lineTypes,
|
||||
metrics,
|
||||
operators,
|
||||
timeRanges,
|
||||
} from './constants';
|
||||
@@ -15,11 +16,21 @@ export function objectToZodEnums<K extends string>(
|
||||
return [firstKey!, ...otherKeys];
|
||||
}
|
||||
|
||||
export const mapKeys = objectToZodEnums;
|
||||
|
||||
export const zChartEvent = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
displayName: z.string().optional(),
|
||||
segment: z.enum(['event', 'user', 'user_average', 'one_event_per_user']),
|
||||
property: z.string().optional(),
|
||||
segment: z.enum([
|
||||
'event',
|
||||
'user',
|
||||
'user_average',
|
||||
'one_event_per_user',
|
||||
'property_sum',
|
||||
'property_average',
|
||||
]),
|
||||
filters: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
@@ -43,6 +54,8 @@ export const zLineType = z.enum(objectToZodEnums(lineTypes));
|
||||
|
||||
export const zTimeInterval = z.enum(objectToZodEnums(intervals));
|
||||
|
||||
export const zMetric = z.enum(objectToZodEnums(metrics));
|
||||
|
||||
export const zChartInput = z.object({
|
||||
name: z.string(),
|
||||
chartType: zChartType,
|
||||
@@ -52,9 +65,11 @@ export const zChartInput = z.object({
|
||||
breakdowns: zChartBreakdowns,
|
||||
range: z.enum(objectToZodEnums(timeRanges)),
|
||||
previous: z.boolean(),
|
||||
});
|
||||
|
||||
export const zChartInputWithDates = zChartInput.extend({
|
||||
formula: z.string().optional(),
|
||||
metric: zMetric,
|
||||
unit: z.string().optional(),
|
||||
previousIndicatorInverted: z.boolean().optional(),
|
||||
projectId: z.string(),
|
||||
startDate: z.string().nullish(),
|
||||
endDate: z.string().nullable(),
|
||||
endDate: z.string().nullish(),
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"@bull-board/express": "^5.13.0",
|
||||
"@mixan/db": "workspace:*",
|
||||
"@mixan/queue": "workspace:*",
|
||||
"@mixan/common": "workspace:*",
|
||||
"bullmq": "^5.1.1",
|
||||
"express": "^4.18.2",
|
||||
"ramda": "^0.29.1"
|
||||
|
||||
@@ -5,10 +5,12 @@ import { Worker } from 'bullmq';
|
||||
import express from 'express';
|
||||
|
||||
import { connection, eventsQueue } from '@mixan/queue';
|
||||
import { cronQueue } from '@mixan/queue/src/queues';
|
||||
|
||||
import { cronJob } from './jobs/cron';
|
||||
import { eventsJob } from './jobs/events';
|
||||
|
||||
const PORT = process.env.PORT || 3001;
|
||||
const PORT = process.env.PORT || 3000;
|
||||
const serverAdapter = new ExpressAdapter();
|
||||
serverAdapter.setBasePath('/');
|
||||
const app = express();
|
||||
@@ -17,13 +19,43 @@ new Worker(eventsQueue.name, eventsJob, {
|
||||
connection,
|
||||
});
|
||||
|
||||
createBullBoard({
|
||||
queues: [new BullMQAdapter(eventsQueue)],
|
||||
serverAdapter: serverAdapter,
|
||||
new Worker(cronQueue.name, cronJob, {
|
||||
connection,
|
||||
});
|
||||
|
||||
app.use('/', serverAdapter.getRouter());
|
||||
async function start() {
|
||||
createBullBoard({
|
||||
queues: [new BullMQAdapter(eventsQueue), new BullMQAdapter(cronQueue)],
|
||||
serverAdapter: serverAdapter,
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`For the UI, open http://localhost:${PORT}/`);
|
||||
});
|
||||
app.use('/', serverAdapter.getRouter());
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`For the UI, open http://localhost:${PORT}/`);
|
||||
});
|
||||
|
||||
const repeatableJobs = await cronQueue.getRepeatableJobs();
|
||||
|
||||
console.log(repeatableJobs);
|
||||
|
||||
await cronQueue.add(
|
||||
'salt',
|
||||
{
|
||||
type: 'salt',
|
||||
payload: undefined,
|
||||
},
|
||||
{
|
||||
jobId: 'salt',
|
||||
repeat: {
|
||||
utc: true,
|
||||
pattern: '0 0 * * *',
|
||||
},
|
||||
}
|
||||
);
|
||||
// if (!repeatableJobs.find((job) => job.name === 'salt')) {
|
||||
// console.log('Add salt job to queue');
|
||||
// }
|
||||
}
|
||||
|
||||
start();
|
||||
|
||||
22
apps/worker/src/jobs/cron.salt.ts
Normal file
22
apps/worker/src/jobs/cron.salt.ts
Normal 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;
|
||||
}
|
||||
13
apps/worker/src/jobs/cron.ts
Normal file
13
apps/worker/src/jobs/cron.ts
Normal 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
Reference in New Issue
Block a user