26 Commits

Author SHA1 Message Date
Carl-Gerhard Lindesvärd
8cd3b89fa3 funnel 2025-11-25 22:33:58 +01:00
Carl-Gerhard Lindesvärd
95af86dc44 wip 2025-11-25 22:23:25 +01:00
Carl-Gerhard Lindesvärd
727a218e6b conversion wip 2025-11-25 10:18:26 +01:00
Carl-Gerhard Lindesvärd
958ba535d6 wip 2025-11-25 10:18:20 +01:00
Carl-Gerhard Lindesvärd
3bbeb927cc wip 2025-11-25 09:18:48 +01:00
Carl-Gerhard Lindesvärd
d99335e2f4 wip 2025-11-24 18:08:10 +01:00
Carl-Gerhard Lindesvärd
1fa61b1ae9 ts 2025-11-24 15:50:28 +01:00
Carl-Gerhard Lindesvärd
548747d826 fix typecheck events -> series 2025-11-24 13:17:01 +01:00
Carl-Gerhard Lindesvärd
7b18544085 fix report table 2025-11-24 13:06:46 +01:00
Carl-Gerhard Lindesvärd
57697a5a39 wip 2025-11-22 00:05:13 +01:00
Carl-Gerhard Lindesvärd
06fb6c4f3c wip 2025-11-21 11:21:17 +01:00
Carl-Gerhard Lindesvärd
dd71fd4e11 formulas 2025-11-20 13:56:58 +01:00
Carl-Gerhard Lindesvärd
00e25ed4b8 fix: lock file 2025-11-19 21:59:14 +01:00
Carl-Gerhard Lindesvärd
83e223a496 feat: sdks and docs (#239)
* init

* fix

* update docs

* bump: all sdks

* rename types test
2025-11-19 21:56:47 +01:00
Carl-Gerhard Lindesvärd
790801b728 feat: revenue tracking
* wip

* wip

* wip

* wip

* show revenue better on overview

* align realtime and overview counters

* update revenue docs

* always return device id

* add project settings, improve projects charts,

* fix: comments

* fixes

* fix migration

* ignore sql files

* fix comments
2025-11-19 14:27:34 +01:00
Carl-Gerhard Lindesvärd
d61cbf6f2c bump: nextjs 1.0.15 2025-11-17 22:48:15 +01:00
Carl-Gerhard Lindesvärd
887ed09388 bump: nextjs 1.0.13 2025-11-17 21:56:41 +01:00
Carl-Gerhard Lindesvärd
8ba714ce81 fix: only proxy the headers we need (nextjs) 2025-11-17 21:55:15 +01:00
Carl-Gerhard Lindesvärd
c8e3cf8552 test headers 2025-11-17 20:39:34 +01:00
Carl-Gerhard Lindesvärd
18c056f3ea fix: avoid private ips 2025-11-17 16:11:41 +01:00
Carl-Gerhard Lindesvärd
1562d49fd6 fix: ip header order again (add openpanel-client-ip) 2025-11-17 16:01:13 +01:00
Carl-Gerhard Lindesvärd
aa8765d627 tmp logging 2025-11-17 14:44:37 +01:00
Carl-Gerhard Lindesvärd
56c74e13ff fix: local logger 2025-11-17 13:57:01 +01:00
Carl-Gerhard Lindesvärd
10726bf373 fix: better support for custom timestamps 2025-11-17 13:56:55 +01:00
Carl-Gerhard Lindesvärd
dcc0d0df18 fix: funnel for milliseconds difference 2025-11-17 13:56:39 +01:00
Carl-Gerhard Lindesvärd
da59622dce fix: overall perf improvements
* fix: ignore private ips

* fix: performance related fixes

* fix: simply event buffer

* fix: default to 1 events queue shard

* add: cleanup scripts

* fix: comments

* fix comments

* fix

* fix: groupmq

* wip

* fix: sync cachable

* remove cluster names and add it behind env flag (if someone want to scale)

* fix

* wip

* better logger

* remove reqid and user agent

* fix lock

* remove wait_for_async_insert
2025-11-15 22:13:59 +01:00
225 changed files with 14983 additions and 29618 deletions

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
.secrets .secrets
packages/db/src/generated/prisma packages/db/src/generated/prisma
packages/db/code-migrations/*.sql
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore # Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
packages/sdk/profileId.txt packages/sdk/profileId.txt

View File

@@ -17,7 +17,7 @@
"editor.defaultFormatter": "biomejs.biome" "editor.defaultFormatter": "biomejs.biome"
}, },
"[json]": { "[json]": {
"editor.defaultFormatter": "biomejs.biome" "editor.defaultFormatter": "vscode.json-language-features"
}, },
"editor.formatOnSave": true, "editor.formatOnSave": true,
"tailwindCSS.experimental.classRegex": [ "tailwindCSS.experimental.classRegex": [

View File

@@ -13,11 +13,11 @@
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "^1.2.10", "@ai-sdk/anthropic": "^1.2.10",
"@ai-sdk/openai": "^1.3.12", "@ai-sdk/openai": "^1.3.12",
"@fastify/compress": "^8.0.1", "@fastify/compress": "^8.1.0",
"@fastify/cookie": "^11.0.2", "@fastify/cookie": "^11.0.2",
"@fastify/cors": "^11.0.0", "@fastify/cors": "^11.1.0",
"@fastify/rate-limit": "^10.2.2", "@fastify/rate-limit": "^10.3.0",
"@fastify/websocket": "^11.0.2", "@fastify/websocket": "^11.2.0",
"@node-rs/argon2": "^2.0.2", "@node-rs/argon2": "^2.0.2",
"@openpanel/auth": "workspace:^", "@openpanel/auth": "workspace:^",
"@openpanel/common": "workspace:*", "@openpanel/common": "workspace:*",
@@ -35,10 +35,10 @@
"@trpc/server": "^11.6.0", "@trpc/server": "^11.6.0",
"ai": "^4.2.10", "ai": "^4.2.10",
"fast-json-stable-hash": "^1.0.3", "fast-json-stable-hash": "^1.0.3",
"fastify": "^5.2.1", "fastify": "^5.6.1",
"fastify-metrics": "^12.1.0", "fastify-metrics": "^12.1.0",
"fastify-raw-body": "^5.0.0", "fastify-raw-body": "^5.0.0",
"groupmq": "1.0.0-next.19", "groupmq": "1.1.0-next.6",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"ramda": "^0.29.1", "ramda": "^0.29.1",
"sharp": "^0.33.5", "sharp": "^0.33.5",

View File

@@ -7,6 +7,23 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
import yaml from 'js-yaml'; import yaml from 'js-yaml';
// Regex special characters that indicate we need actual regex
const regexSpecialChars = /[|^$.*+?(){}\[\]\\]/;
function transformBots(bots: any[]): any[] {
return bots.map((bot) => {
const { regex, ...rest } = bot;
const hasRegexChars = regexSpecialChars.test(regex);
if (hasRegexChars) {
// Keep as regex
return { regex, ...rest };
}
// Convert to includes
return { includes: regex, ...rest };
});
}
async function main() { async function main() {
// Get document, or throw exception on error // Get document, or throw exception on error
try { try {
@@ -14,6 +31,9 @@ async function main() {
'https://raw.githubusercontent.com/matomo-org/device-detector/master/regexes/bots.yml', 'https://raw.githubusercontent.com/matomo-org/device-detector/master/regexes/bots.yml',
).then((res) => res.text()); ).then((res) => res.text());
const parsedData = yaml.load(data) as any[];
const transformedBots = transformBots(parsedData);
fs.writeFileSync( fs.writeFileSync(
path.resolve(__dirname, '../src/bots/bots.ts'), path.resolve(__dirname, '../src/bots/bots.ts'),
[ [
@@ -21,11 +41,20 @@ async function main() {
'', '',
'// The data is fetch from device-detector https://raw.githubusercontent.com/matomo-org/device-detector/master/regexes/bots.yml', '// The data is fetch from device-detector https://raw.githubusercontent.com/matomo-org/device-detector/master/regexes/bots.yml',
'', '',
`const bots = ${JSON.stringify(yaml.load(data))} as const;`, `const bots = ${JSON.stringify(transformedBots, null, 2)} as const;`,
'export default bots;', 'export default bots;',
'',
].join('\n'), ].join('\n'),
'utf-8', 'utf-8',
); );
console.log(
`✅ Generated bots.ts with ${transformedBots.length} bot entries`,
);
const regexCount = transformedBots.filter((b) => 'regex' in b).length;
const includesCount = transformedBots.filter((b) => 'includes' in b).length;
console.log(` - ${includesCount} simple string matches (includes)`);
console.log(` - ${regexCount} regex patterns`);
} catch (e) { } catch (e) {
console.log(e); console.log(e);
} }

View File

@@ -40,8 +40,6 @@ async function main() {
properties: { properties: {
hash: 'test-hash', hash: 'test-hash',
'query.utm_source': 'test', 'query.utm_source': 'test',
__reqId: `req_${Math.floor(Math.random() * 1000)}`,
__user_agent: 'Mozilla/5.0 (Test)',
}, },
created_at: formatClickhouseDate(eventTime), created_at: formatClickhouseDate(eventTime),
country: 'US', country: 'US',

File diff suppressed because it is too large Load Diff

View File

@@ -1,19 +1,47 @@
import { cacheable, cacheableLru } from '@openpanel/redis';
import bots from './bots'; import bots from './bots';
export function isBot(ua: string) { // Pre-compile regex patterns at module load time
const res = bots.find((bot) => { const compiledBots = bots.map((bot) => {
if (new RegExp(bot.regex).test(ua)) { if ('regex' in bot) {
return true; return {
} ...bot,
return false; compiledRegex: new RegExp(bot.regex),
}); };
if (!res) {
return null;
} }
return bot;
});
return { const regexBots = compiledBots.filter((bot) => 'compiledRegex' in bot);
name: res.name, const includesBots = compiledBots.filter((bot) => 'includes' in bot);
type: 'category' in res ? res.category : 'Unknown',
}; export const isBot = cacheableLru(
} 'is-bot',
(ua: string) => {
// Check simple string patterns first (fast)
for (const bot of includesBots) {
if (ua.includes(bot.includes)) {
return {
name: bot.name,
type: 'category' in bot ? bot.category : 'Unknown',
};
}
}
// Check regex patterns (slower)
for (const bot of regexBots) {
if (bot.compiledRegex.test(ua)) {
return {
name: bot.name,
type: 'category' in bot ? bot.category : 'Unknown',
};
}
}
return null;
},
{
maxSize: 1000,
ttl: 60 * 5,
},
);

View File

@@ -2,10 +2,9 @@ import type { FastifyReply, FastifyRequest } from 'fastify';
import { generateDeviceId, parseUserAgent } from '@openpanel/common/server'; import { generateDeviceId, parseUserAgent } from '@openpanel/common/server';
import { getSalts } from '@openpanel/db'; import { getSalts } from '@openpanel/db';
import { eventsGroupQueue } from '@openpanel/queue'; import { getEventsGroupQueueShard } from '@openpanel/queue';
import type { PostEventPayload } from '@openpanel/sdk'; import type { PostEventPayload } from '@openpanel/sdk';
import { checkDuplicatedEvent } from '@/utils/deduplicate';
import { generateId } from '@openpanel/common'; import { generateId } from '@openpanel/common';
import { getGeoLocation } from '@openpanel/geo'; import { getGeoLocation } from '@openpanel/geo';
import { getStringHeaders, getTimestamp } from './track.controller'; import { getStringHeaders, getTimestamp } from './track.controller';
@@ -21,7 +20,7 @@ export async function postEvent(
request.body, request.body,
); );
const ip = request.clientIp; const ip = request.clientIp;
const ua = request.headers['user-agent']!; const ua = request.headers['user-agent'];
const projectId = request.client?.projectId; const projectId = request.client?.projectId;
const headers = getStringHeaders(request.headers); const headers = getStringHeaders(request.headers);
@@ -31,33 +30,22 @@ export async function postEvent(
} }
const [salts, geo] = await Promise.all([getSalts(), getGeoLocation(ip)]); const [salts, geo] = await Promise.all([getSalts(), getGeoLocation(ip)]);
const currentDeviceId = generateDeviceId({ const currentDeviceId = ua
salt: salts.current, ? generateDeviceId({
origin: projectId, salt: salts.current,
ip, origin: projectId,
ua, ip,
}); ua,
const previousDeviceId = generateDeviceId({ })
salt: salts.previous, : '';
origin: projectId, const previousDeviceId = ua
ip, ? generateDeviceId({
ua, salt: salts.previous,
}); origin: projectId,
ip,
if ( ua,
await checkDuplicatedEvent({ })
reply, : '';
payload: {
...request.body,
timestamp,
previousDeviceId,
currentDeviceId,
},
projectId,
})
) {
return;
}
const uaInfo = parseUserAgent(ua, request.body?.properties); const uaInfo = parseUserAgent(ua, request.body?.properties);
const groupId = uaInfo.isServer const groupId = uaInfo.isServer
@@ -65,7 +53,16 @@ export async function postEvent(
? `${projectId}:${request.body?.profileId}` ? `${projectId}:${request.body?.profileId}`
: `${projectId}:${generateId()}` : `${projectId}:${generateId()}`
: currentDeviceId; : currentDeviceId;
await eventsGroupQueue.add({ const jobId = [
request.body.name,
timestamp,
projectId,
currentDeviceId,
groupId,
]
.filter(Boolean)
.join('-');
await getEventsGroupQueueShard(groupId).add({
orderMs: new Date(timestamp).getTime(), orderMs: new Date(timestamp).getTime(),
data: { data: {
projectId, projectId,
@@ -75,11 +72,13 @@ export async function postEvent(
timestamp, timestamp,
isTimestampFromThePast, isTimestampFromThePast,
}, },
uaInfo,
geo, geo,
currentDeviceId, currentDeviceId,
previousDeviceId, previousDeviceId,
}, },
groupId, groupId,
jobId,
}); });
reply.status(202).send('ok'); reply.status(202).send('ok');

View File

@@ -12,8 +12,8 @@ import {
getEventsCountCached, getEventsCountCached,
getSettingsForProject, getSettingsForProject,
} from '@openpanel/db'; } from '@openpanel/db';
import { getChart } from '@openpanel/trpc/src/routers/chart.helpers'; import { ChartEngine } from '@openpanel/db';
import { zChartEvent, zChartInput } from '@openpanel/validation'; import { zChartEvent, zChartInputBase } from '@openpanel/validation';
import { omit } from 'ramda'; import { omit } from 'ramda';
async function getProjectId( async function getProjectId(
@@ -139,7 +139,7 @@ export async function events(
}); });
} }
const chartSchemeFull = zChartInput const chartSchemeFull = zChartInputBase
.pick({ .pick({
breakdowns: true, breakdowns: true,
interval: true, interval: true,
@@ -151,14 +151,27 @@ const chartSchemeFull = zChartInput
.extend({ .extend({
project_id: z.string().optional(), project_id: z.string().optional(),
projectId: z.string().optional(), projectId: z.string().optional(),
events: z.array( series: z
z.object({ .array(
name: z.string(), z.object({
filters: zChartEvent.shape.filters.optional(), name: z.string(),
segment: zChartEvent.shape.segment.optional(), filters: zChartEvent.shape.filters.optional(),
property: zChartEvent.shape.property.optional(), segment: zChartEvent.shape.segment.optional(),
}), property: zChartEvent.shape.property.optional(),
), }),
)
.optional(),
// Backward compatibility - events will be migrated to series via preprocessing
events: z
.array(
z.object({
name: z.string(),
filters: zChartEvent.shape.filters.optional(),
segment: zChartEvent.shape.segment.optional(),
property: zChartEvent.shape.property.optional(),
}),
)
.optional(),
}); });
export async function charts( export async function charts(
@@ -179,9 +192,17 @@ export async function charts(
const projectId = await getProjectId(request, reply); const projectId = await getProjectId(request, reply);
const { timezone } = await getSettingsForProject(projectId); const { timezone } = await getSettingsForProject(projectId);
const { events, ...rest } = query.data; const { events, series, ...rest } = query.data;
return getChart({ // Use series if available, otherwise fall back to events (backward compat)
const eventSeries = (series ?? events ?? []).map((event: any) => ({
...event,
type: event.type ?? 'event',
segment: event.segment ?? 'event',
filters: event.filters ?? [],
}));
return ChartEngine.execute({
...rest, ...rest,
startDate: rest.startDate startDate: rest.startDate
? DateTime.fromISO(rest.startDate) ? DateTime.fromISO(rest.startDate)
@@ -194,11 +215,7 @@ export async function charts(
.toFormat('yyyy-MM-dd HH:mm:ss') .toFormat('yyyy-MM-dd HH:mm:ss')
: undefined, : undefined,
projectId, projectId,
events: events.map((event) => ({ series: eventSeries,
...event,
segment: event.segment ?? 'event',
filters: event.filters ?? [],
})),
chartType: 'linear', chartType: 'linear',
metric: 'sum', metric: 'sum',
}); });

View File

@@ -4,7 +4,7 @@ import superjson from 'superjson';
import type { WebSocket } from '@fastify/websocket'; import type { WebSocket } from '@fastify/websocket';
import { import {
eventBuffer, eventBuffer,
getProfileByIdCached, getProfileById,
transformMinimalEvent, transformMinimalEvent,
} from '@openpanel/db'; } from '@openpanel/db';
import { setSuperJson } from '@openpanel/json'; import { setSuperJson } from '@openpanel/json';
@@ -92,10 +92,7 @@ export async function wsProjectEvents(
type, type,
async (event) => { async (event) => {
if (event.projectId === params.projectId) { if (event.projectId === params.projectId) {
const profile = await getProfileByIdCached( const profile = await getProfileById(event.profileId, event.projectId);
event.profileId,
event.projectId,
);
socket.send( socket.send(
superjson.stringify( superjson.stringify(
access access

View File

@@ -5,7 +5,7 @@ import type { FastifyReply, FastifyRequest } from 'fastify';
import sharp from 'sharp'; import sharp from 'sharp';
import { import {
DEFAULT_HEADER_ORDER, DEFAULT_IP_HEADER_ORDER,
getClientIpFromHeaders, getClientIpFromHeaders,
} from '@openpanel/common/server/get-client-ip'; } from '@openpanel/common/server/get-client-ip';
import { TABLE_NAMES, ch, chQuery, formatClickhouseDate } from '@openpanel/db'; import { TABLE_NAMES, ch, chQuery, formatClickhouseDate } from '@openpanel/db';
@@ -132,7 +132,7 @@ async function processImage(
): Promise<Buffer> { ): Promise<Buffer> {
// If it's an ICO file, just return it as-is (no conversion needed) // If it's an ICO file, just return it as-is (no conversion needed)
if (originalUrl && isIcoFile(originalUrl, contentType)) { if (originalUrl && isIcoFile(originalUrl, contentType)) {
logger.info('Serving ICO file directly', { logger.debug('Serving ICO file directly', {
originalUrl, originalUrl,
bufferSize: buffer.length, bufferSize: buffer.length,
}); });
@@ -140,7 +140,7 @@ async function processImage(
} }
if (originalUrl && isSvgFile(originalUrl, contentType)) { if (originalUrl && isSvgFile(originalUrl, contentType)) {
logger.info('Serving SVG file directly', { logger.debug('Serving SVG file directly', {
originalUrl, originalUrl,
bufferSize: buffer.length, bufferSize: buffer.length,
}); });
@@ -149,7 +149,7 @@ async function processImage(
// If buffer isnt to big just return it as well // If buffer isnt to big just return it as well
if (buffer.length < 5000) { if (buffer.length < 5000) {
logger.info('Serving image directly without processing', { logger.debug('Serving image directly without processing', {
originalUrl, originalUrl,
bufferSize: buffer.length, bufferSize: buffer.length,
}); });
@@ -193,7 +193,7 @@ async function processOgImage(
): Promise<Buffer> { ): Promise<Buffer> {
// If buffer is small enough, return it as-is // If buffer is small enough, return it as-is
if (buffer.length < 10000) { if (buffer.length < 10000) {
logger.info('Serving OG image directly without processing', { logger.debug('Serving OG image directly without processing', {
originalUrl, originalUrl,
bufferSize: buffer.length, bufferSize: buffer.length,
}); });
@@ -397,10 +397,10 @@ export async function stats(request: FastifyRequest, reply: FastifyReply) {
} }
export async function getGeo(request: FastifyRequest, reply: FastifyReply) { export async function getGeo(request: FastifyRequest, reply: FastifyReply) {
const ip = getClientIpFromHeaders(request.headers); const { ip, header } = getClientIpFromHeaders(request.headers);
const others = await Promise.all( const others = await Promise.all(
DEFAULT_HEADER_ORDER.map(async (header) => { DEFAULT_IP_HEADER_ORDER.map(async (header) => {
const ip = getClientIpFromHeaders(request.headers, header); const { ip } = getClientIpFromHeaders(request.headers, header);
return { return {
header, header,
ip, ip,
@@ -417,13 +417,14 @@ export async function getGeo(request: FastifyRequest, reply: FastifyReply) {
selected: { selected: {
geo, geo,
ip, ip,
header,
}, },
...others.reduce( ...others.reduce(
(acc, other) => { (acc, other) => {
acc[other.header] = other; acc[other.header] = other;
return acc; return acc;
}, },
{} as Record<string, { ip: string; geo: GeoLocation }>, {} as Record<string, { ip: string; header: string; geo: GeoLocation }>,
), ),
}); });
} }

View File

@@ -1,7 +1,6 @@
import type { FastifyReply, FastifyRequest } from 'fastify'; import type { FastifyReply, FastifyRequest } from 'fastify';
import { assocPath, pathOr } from 'ramda'; import { assocPath, pathOr } from 'ramda';
import { checkDuplicatedEvent, isDuplicatedEvent } from '@/utils/deduplicate';
import { parseUserAgent } from '@openpanel/common/server'; import { parseUserAgent } from '@openpanel/common/server';
import { getProfileById, upsertProfile } from '@openpanel/db'; import { getProfileById, upsertProfile } from '@openpanel/db';
import { getGeoLocation } from '@openpanel/geo'; import { getGeoLocation } from '@openpanel/geo';
@@ -16,41 +15,39 @@ export async function updateProfile(
}>, }>,
reply: FastifyReply, reply: FastifyReply,
) { ) {
const { profileId, properties, ...rest } = request.body; const payload = request.body;
const projectId = request.client!.projectId; const projectId = request.client!.projectId;
if (!projectId) { if (!projectId) {
return reply.status(400).send('No projectId'); return reply.status(400).send('No projectId');
} }
const ip = request.clientIp; const ip = request.clientIp;
const ua = request.headers['user-agent']!; const ua = request.headers['user-agent'];
const uaInfo = parseUserAgent(ua, properties); const uaInfo = parseUserAgent(ua, payload.properties);
const geo = await getGeoLocation(ip); const geo = await getGeoLocation(ip);
if (
await checkDuplicatedEvent({
reply,
payload: {
...request.body,
},
projectId,
})
) {
return;
}
await upsertProfile({ await upsertProfile({
id: profileId, ...payload,
id: payload.profileId,
isExternal: true, isExternal: true,
projectId, projectId,
properties: { properties: {
...(properties ?? {}), ...(payload.properties ?? {}),
...(ip ? geo : {}), country: geo.country,
...uaInfo, city: geo.city,
region: geo.region,
longitude: geo.longitude,
latitude: geo.latitude,
os: uaInfo.os,
os_version: uaInfo.osVersion,
browser: uaInfo.browser,
browser_version: uaInfo.browserVersion,
device: uaInfo.device,
brand: uaInfo.brand,
model: uaInfo.model,
}, },
...rest,
}); });
reply.status(202).send(profileId); reply.status(202).send(payload.profileId);
} }
export async function incrementProfileProperty( export async function incrementProfileProperty(
@@ -65,18 +62,6 @@ export async function incrementProfileProperty(
return reply.status(400).send('No projectId'); return reply.status(400).send('No projectId');
} }
if (
await checkDuplicatedEvent({
reply,
payload: {
...request.body,
},
projectId,
})
) {
return;
}
const profile = await getProfileById(profileId, projectId); const profile = await getProfileById(profileId, projectId);
if (!profile) { if (!profile) {
return reply.status(404).send('Not found'); return reply.status(404).send('Not found');
@@ -119,18 +104,6 @@ export async function decrementProfileProperty(
return reply.status(400).send('No projectId'); return reply.status(400).send('No projectId');
} }
if (
await checkDuplicatedEvent({
reply,
payload: {
...request.body,
},
projectId,
})
) {
return;
}
const profile = await getProfileById(profileId, projectId); const profile = await getProfileById(profileId, projectId);
if (!profile) { if (!profile) {
return reply.status(404).send('Not found'); return reply.status(404).send('Not found');

View File

@@ -1,12 +1,12 @@
import type { FastifyReply, FastifyRequest } from 'fastify'; import type { FastifyReply, FastifyRequest } from 'fastify';
import { path, assocPath, pathOr, pick } from 'ramda'; import { assocPath, pathOr, pick } from 'ramda';
import { checkDuplicatedEvent } from '@/utils/deduplicate';
import { generateId } from '@openpanel/common'; import { generateId } from '@openpanel/common';
import { generateDeviceId, parseUserAgent } from '@openpanel/common/server'; import { generateDeviceId, parseUserAgent } from '@openpanel/common/server';
import { getProfileById, getSalts, upsertProfile } from '@openpanel/db'; import { getProfileById, getSalts, upsertProfile } from '@openpanel/db';
import { type GeoLocation, getGeoLocation } from '@openpanel/geo'; import { type GeoLocation, getGeoLocation } from '@openpanel/geo';
import { eventsGroupQueue } from '@openpanel/queue'; import { getEventsGroupQueueShard } from '@openpanel/queue';
import { getRedisCache } from '@openpanel/redis';
import type { import type {
DecrementPayload, DecrementPayload,
IdentifyPayload, IdentifyPayload,
@@ -37,10 +37,10 @@ export function getStringHeaders(headers: FastifyRequest['headers']) {
} }
function getIdentity(body: TrackHandlerPayload): IdentifyPayload | undefined { function getIdentity(body: TrackHandlerPayload): IdentifyPayload | undefined {
const identity = path<IdentifyPayload>( const identity =
['properties', '__identify'], 'properties' in body.payload
body.payload, ? (body.payload?.properties?.__identify as IdentifyPayload | undefined)
); : undefined;
return ( return (
identity || identity ||
@@ -56,28 +56,38 @@ export function getTimestamp(
timestamp: FastifyRequest['timestamp'], timestamp: FastifyRequest['timestamp'],
payload: TrackHandlerPayload['payload'], payload: TrackHandlerPayload['payload'],
) { ) {
const safeTimestamp = new Date(timestamp || Date.now()).toISOString(); const safeTimestamp = timestamp || Date.now();
const userDefinedTimestamp = path<string>( const userDefinedTimestamp =
['properties', '__timestamp'], 'properties' in payload
payload, ? (payload?.properties?.__timestamp as string | undefined)
); : undefined;
if (!userDefinedTimestamp) { if (!userDefinedTimestamp) {
return { timestamp: safeTimestamp, isTimestampFromThePast: false }; return { timestamp: safeTimestamp, isTimestampFromThePast: false };
} }
const clientTimestamp = new Date(userDefinedTimestamp); const clientTimestamp = new Date(userDefinedTimestamp);
const clientTimestampNumber = clientTimestamp.getTime();
// Constants for time validation
const ONE_MINUTE_MS = 60 * 1000;
const FIFTEEN_MINUTES_MS = 15 * ONE_MINUTE_MS;
// Use safeTimestamp if invalid or more than 1 minute in the future
if ( if (
Number.isNaN(clientTimestamp.getTime()) || Number.isNaN(clientTimestampNumber) ||
clientTimestamp > new Date(safeTimestamp) clientTimestampNumber > safeTimestamp + ONE_MINUTE_MS
) { ) {
return { timestamp: safeTimestamp, isTimestampFromThePast: false }; return { timestamp: safeTimestamp, isTimestampFromThePast: false };
} }
// isTimestampFromThePast is true only if timestamp is older than 1 hour
const isTimestampFromThePast =
clientTimestampNumber < safeTimestamp - FIFTEEN_MINUTES_MS;
return { return {
timestamp: clientTimestamp.toISOString(), timestamp: clientTimestampNumber,
isTimestampFromThePast: true, isTimestampFromThePast,
}; };
} }
@@ -89,22 +99,33 @@ export async function handler(
) { ) {
const timestamp = getTimestamp(request.timestamp, request.body.payload); const timestamp = getTimestamp(request.timestamp, request.body.payload);
const ip = const ip =
path<string>(['properties', '__ip'], request.body.payload) || 'properties' in request.body.payload &&
request.clientIp; request.body.payload.properties?.__ip
const ua = request.headers['user-agent']!; ? (request.body.payload.properties.__ip as string)
: request.clientIp;
const ua = request.headers['user-agent'];
const projectId = request.client?.projectId; const projectId = request.client?.projectId;
if (!projectId) { if (!projectId) {
reply.status(400).send({ return reply.status(400).send({
status: 400, status: 400,
error: 'Bad Request', error: 'Bad Request',
message: 'Missing projectId', message: 'Missing projectId',
}); });
return;
} }
const identity = getIdentity(request.body); const identity = getIdentity(request.body);
const profileId = identity?.profileId; const profileId = identity?.profileId;
const overrideDeviceId = (() => {
const deviceId =
'properties' in request.body.payload
? request.body.payload.properties?.__deviceId
: undefined;
if (typeof deviceId === 'string') {
return deviceId;
}
return undefined;
})();
// We might get a profileId from the alias table // We might get a profileId from the alias table
// If we do, we should use that instead of the one from the payload // If we do, we should use that instead of the one from the payload
@@ -115,14 +136,16 @@ export async function handler(
switch (request.body.type) { switch (request.body.type) {
case 'track': { case 'track': {
const [salts, geo] = await Promise.all([getSalts(), getGeoLocation(ip)]); const [salts, geo] = await Promise.all([getSalts(), getGeoLocation(ip)]);
const currentDeviceId = ua const currentDeviceId =
? generateDeviceId({ overrideDeviceId ||
salt: salts.current, (ua
origin: projectId, ? generateDeviceId({
ip, salt: salts.current,
ua, origin: projectId,
}) ip,
: ''; ua,
})
: '');
const previousDeviceId = ua const previousDeviceId = ua
? generateDeviceId({ ? generateDeviceId({
salt: salts.previous, salt: salts.previous,
@@ -132,33 +155,7 @@ export async function handler(
}) })
: ''; : '';
if ( const promises = [];
await checkDuplicatedEvent({
reply,
payload: {
...request.body,
timestamp,
previousDeviceId,
currentDeviceId,
},
projectId,
})
) {
return;
}
const promises = [
track({
payload: request.body.payload,
currentDeviceId,
previousDeviceId,
projectId,
geo,
headers: getStringHeaders(request.headers),
timestamp: timestamp.timestamp,
isTimestampFromThePast: timestamp.isTimestampFromThePast,
}),
];
// If we have more than one property in the identity object, we should identify the user // If we have more than one property in the identity object, we should identify the user
// Otherwise its only a profileId and we should not identify the user // Otherwise its only a profileId and we should not identify the user
@@ -173,23 +170,23 @@ export async function handler(
); );
} }
promises.push(
track({
payload: request.body.payload,
currentDeviceId,
previousDeviceId,
projectId,
geo,
headers: getStringHeaders(request.headers),
timestamp: timestamp.timestamp,
isTimestampFromThePast: timestamp.isTimestampFromThePast,
}),
);
await Promise.all(promises); await Promise.all(promises);
break; break;
} }
case 'identify': { case 'identify': {
if (
await checkDuplicatedEvent({
reply,
payload: {
...request.body,
timestamp,
},
projectId,
})
) {
return;
}
const geo = await getGeoLocation(ip); const geo = await getGeoLocation(ip);
await identify({ await identify({
payload: request.body.payload, payload: request.body.payload,
@@ -200,27 +197,13 @@ export async function handler(
break; break;
} }
case 'alias': { case 'alias': {
reply.status(400).send({ return reply.status(400).send({
status: 400, status: 400,
error: 'Bad Request', error: 'Bad Request',
message: 'Alias is not supported', message: 'Alias is not supported',
}); });
break;
} }
case 'increment': { case 'increment': {
if (
await checkDuplicatedEvent({
reply,
payload: {
...request.body,
timestamp,
},
projectId,
})
) {
return;
}
await increment({ await increment({
payload: request.body.payload, payload: request.body.payload,
projectId, projectId,
@@ -228,19 +211,6 @@ export async function handler(
break; break;
} }
case 'decrement': { case 'decrement': {
if (
await checkDuplicatedEvent({
reply,
payload: {
...request.body,
timestamp,
},
projectId,
})
) {
return;
}
await decrement({ await decrement({
payload: request.body.payload, payload: request.body.payload,
projectId, projectId,
@@ -248,12 +218,11 @@ export async function handler(
break; break;
} }
default: { default: {
reply.status(400).send({ return reply.status(400).send({
status: 400, status: 400,
error: 'Bad Request', error: 'Bad Request',
message: 'Invalid type', message: 'Invalid type',
}); });
break;
} }
} }
@@ -276,7 +245,7 @@ async function track({
projectId: string; projectId: string;
geo: GeoLocation; geo: GeoLocation;
headers: Record<string, string | undefined>; headers: Record<string, string | undefined>;
timestamp: string; timestamp: number;
isTimestampFromThePast: boolean; isTimestampFromThePast: boolean;
}) { }) {
const uaInfo = parseUserAgent(headers['user-agent'], payload.properties); const uaInfo = parseUserAgent(headers['user-agent'], payload.properties);
@@ -285,8 +254,11 @@ async function track({
? `${projectId}:${payload.profileId}` ? `${projectId}:${payload.profileId}`
: `${projectId}:${generateId()}` : `${projectId}:${generateId()}`
: currentDeviceId; : currentDeviceId;
await eventsGroupQueue.add({ const jobId = [payload.name, timestamp, projectId, currentDeviceId, groupId]
orderMs: new Date(timestamp).getTime(), .filter(Boolean)
.join('-');
await getEventsGroupQueueShard(groupId).add({
orderMs: timestamp,
data: { data: {
projectId, projectId,
headers, headers,
@@ -295,11 +267,13 @@ async function track({
timestamp, timestamp,
isTimestampFromThePast, isTimestampFromThePast,
}, },
uaInfo,
geo, geo,
currentDeviceId, currentDeviceId,
previousDeviceId, previousDeviceId,
}, },
groupId, groupId,
jobId,
}); });
} }
@@ -322,8 +296,18 @@ async function identify({
projectId, projectId,
properties: { properties: {
...(payload.properties ?? {}), ...(payload.properties ?? {}),
...(geo ?? {}), country: geo.country,
...uaInfo, city: geo.city,
region: geo.region,
longitude: geo.longitude,
latitude: geo.latitude,
os: uaInfo.os,
os_version: uaInfo.osVersion,
browser: uaInfo.browser,
browser_version: uaInfo.browserVersion,
device: uaInfo.device,
brand: uaInfo.brand,
model: uaInfo.model,
}, },
}); });
} }
@@ -399,3 +383,65 @@ async function decrement({
isExternal: true, isExternal: true,
}); });
} }
export async function fetchDeviceId(
request: FastifyRequest,
reply: FastifyReply,
) {
const salts = await getSalts();
const projectId = request.client?.projectId;
if (!projectId) {
return reply.status(400).send('No projectId');
}
const ip = request.clientIp;
if (!ip) {
return reply.status(400).send('Missing ip address');
}
const ua = request.headers['user-agent'];
if (!ua) {
return reply.status(400).send('Missing header: user-agent');
}
const currentDeviceId = generateDeviceId({
salt: salts.current,
origin: projectId,
ip,
ua,
});
const previousDeviceId = generateDeviceId({
salt: salts.previous,
origin: projectId,
ip,
ua,
});
try {
const multi = getRedisCache().multi();
multi.exists(`bull:sessions:sessionEnd:${projectId}:${currentDeviceId}`);
multi.exists(`bull:sessions:sessionEnd:${projectId}:${previousDeviceId}`);
const res = await multi.exec();
if (res?.[0]?.[1]) {
return reply.status(200).send({
deviceId: currentDeviceId,
message: 'current session exists for this device id',
});
}
if (res?.[1]?.[1]) {
return reply.status(200).send({
deviceId: previousDeviceId,
message: 'previous session exists for this device id',
});
}
} catch (error) {
request.log.error('Error getting session end GET /track/device-id', error);
}
return reply.status(200).send({
deviceId: currentDeviceId,
message: 'No session exists for this device id',
});
}

View File

@@ -0,0 +1,28 @@
import { isDuplicatedEvent } from '@/utils/deduplicate';
import type { PostEventPayload, TrackHandlerPayload } from '@openpanel/sdk';
import type { FastifyReply, FastifyRequest } from 'fastify';
export async function duplicateHook(
req: FastifyRequest<{
Body: PostEventPayload | TrackHandlerPayload;
}>,
reply: FastifyReply,
) {
const ip = req.clientIp;
const origin = req.headers.origin;
const clientId = req.headers['openpanel-client-id'];
const shouldCheck = ip && origin && clientId;
const isDuplicate = shouldCheck
? await isDuplicatedEvent({
ip,
origin,
payload: req.body,
projectId: clientId as string,
})
: false;
if (isDuplicate) {
return reply.status(200).send('Duplicate event');
}
}

View File

@@ -1,16 +0,0 @@
import type { FastifyRequest } from 'fastify';
export async function fixHook(request: FastifyRequest) {
const ua = request.headers['user-agent'];
// Swift SDK issue: https://github.com/Openpanel-dev/swift-sdk/commit/d588fa761a36a33f3b78eb79d83bfd524e3c7144
if (ua) {
const regex = /OpenPanel\/(\d+\.\d+\.\d+)\sOpenPanel\/(\d+\.\d+\.\d+)/;
const match = ua.match(regex);
if (match) {
request.headers['user-agent'] = ua.replace(
regex,
`OpenPanel/${match[1]}`,
);
}
}
}

View File

@@ -2,11 +2,13 @@ import { getClientIpFromHeaders } from '@openpanel/common/server/get-client-ip';
import type { FastifyRequest } from 'fastify'; import type { FastifyRequest } from 'fastify';
export async function ipHook(request: FastifyRequest) { export async function ipHook(request: FastifyRequest) {
const ip = getClientIpFromHeaders(request.headers); const { ip, header } = getClientIpFromHeaders(request.headers);
if (ip) { if (ip) {
request.clientIp = ip; request.clientIp = ip;
request.clientIpHeader = header;
} else { } else {
request.clientIp = ''; request.clientIp = '';
request.clientIpHeader = '';
} }
} }

View File

@@ -1,3 +1,4 @@
import { DEFAULT_IP_HEADER_ORDER } from '@openpanel/common';
import type { FastifyReply, FastifyRequest } from 'fastify'; import type { FastifyReply, FastifyRequest } from 'fastify';
import { path, pick } from 'ramda'; import { path, pick } from 'ramda';
@@ -37,12 +38,15 @@ export async function requestLoggingHook(
url: request.url, url: request.url,
method: request.method, method: request.method,
elapsed: reply.elapsedTime, elapsed: reply.elapsedTime,
clientIp: request.clientIp,
clientIpHeader: request.clientIpHeader,
headers: pick( headers: pick(
[ [
'openpanel-client-id', 'openpanel-client-id',
'openpanel-sdk-name', 'openpanel-sdk-name',
'openpanel-sdk-version', 'openpanel-sdk-version',
'user-agent', 'user-agent',
...DEFAULT_IP_HEADER_ORDER,
], ],
request.headers, request.headers,
), ),

View File

@@ -28,7 +28,6 @@ import {
liveness, liveness,
readiness, readiness,
} from './controllers/healthcheck.controller'; } from './controllers/healthcheck.controller';
import { fixHook } from './hooks/fix.hook';
import { ipHook } from './hooks/ip.hook'; import { ipHook } from './hooks/ip.hook';
import { requestIdHook } from './hooks/request-id.hook'; import { requestIdHook } from './hooks/request-id.hook';
import { requestLoggingHook } from './hooks/request-logging.hook'; import { requestLoggingHook } from './hooks/request-logging.hook';
@@ -56,6 +55,7 @@ declare module 'fastify' {
interface FastifyRequest { interface FastifyRequest {
client: IServiceClientWithProject | null; client: IServiceClientWithProject | null;
clientIp: string; clientIp: string;
clientIpHeader: string;
timestamp?: number; timestamp?: number;
session: SessionValidationResult; session: SessionValidationResult;
} }
@@ -125,7 +125,6 @@ const startServer = async () => {
fastify.addHook('onRequest', requestIdHook); fastify.addHook('onRequest', requestIdHook);
fastify.addHook('onRequest', timestampHook); fastify.addHook('onRequest', timestampHook);
fastify.addHook('onRequest', ipHook); fastify.addHook('onRequest', ipHook);
fastify.addHook('onRequest', fixHook);
fastify.addHook('onResponse', requestLoggingHook); fastify.addHook('onResponse', requestLoggingHook);
fastify.register(compress, { fastify.register(compress, {

View File

@@ -2,9 +2,11 @@ import * as controller from '@/controllers/event.controller';
import type { FastifyPluginCallback } from 'fastify'; import type { FastifyPluginCallback } from 'fastify';
import { clientHook } from '@/hooks/client.hook'; import { clientHook } from '@/hooks/client.hook';
import { duplicateHook } from '@/hooks/duplicate.hook';
import { isBotHook } from '@/hooks/is-bot.hook'; import { isBotHook } from '@/hooks/is-bot.hook';
const eventRouter: FastifyPluginCallback = async (fastify) => { const eventRouter: FastifyPluginCallback = async (fastify) => {
fastify.addHook('preValidation', duplicateHook);
fastify.addHook('preHandler', clientHook); fastify.addHook('preHandler', clientHook);
fastify.addHook('preHandler', isBotHook); fastify.addHook('preHandler', isBotHook);

View File

@@ -1,10 +1,12 @@
import { handler } from '@/controllers/track.controller'; import { fetchDeviceId, handler } from '@/controllers/track.controller';
import type { FastifyPluginCallback } from 'fastify'; import type { FastifyPluginCallback } from 'fastify';
import { clientHook } from '@/hooks/client.hook'; import { clientHook } from '@/hooks/client.hook';
import { duplicateHook } from '@/hooks/duplicate.hook';
import { isBotHook } from '@/hooks/is-bot.hook'; import { isBotHook } from '@/hooks/is-bot.hook';
const trackRouter: FastifyPluginCallback = async (fastify) => { const trackRouter: FastifyPluginCallback = async (fastify) => {
fastify.addHook('preValidation', duplicateHook);
fastify.addHook('preHandler', clientHook); fastify.addHook('preHandler', clientHook);
fastify.addHook('preHandler', isBotHook); fastify.addHook('preHandler', isBotHook);
@@ -29,6 +31,23 @@ const trackRouter: FastifyPluginCallback = async (fastify) => {
}, },
}, },
}); });
fastify.route({
method: 'GET',
url: '/device-id',
handler: fetchDeviceId,
schema: {
response: {
200: {
type: 'object',
properties: {
deviceId: { type: 'string' },
message: { type: 'string', optional: true },
},
},
},
},
});
}; };
export default trackRouter; export default trackRouter;

View File

@@ -7,8 +7,8 @@ import {
ch, ch,
clix, clix,
} from '@openpanel/db'; } from '@openpanel/db';
import { ChartEngine } from '@openpanel/db';
import { getCache } from '@openpanel/redis'; import { getCache } from '@openpanel/redis';
import { getChart } from '@openpanel/trpc/src/routers/chart.helpers';
import { zChartInputAI } from '@openpanel/validation'; import { zChartInputAI } from '@openpanel/validation';
import { tool } from 'ai'; import { tool } from 'ai';
import { z } from 'zod'; import { z } from 'zod';

View File

@@ -3,6 +3,7 @@ import type { FastifyRequest, RawRequestDefaultExpression } from 'fastify';
import { verifyPassword } from '@openpanel/common/server'; import { verifyPassword } from '@openpanel/common/server';
import type { IServiceClientWithProject } from '@openpanel/db'; import type { IServiceClientWithProject } from '@openpanel/db';
import { ClientType, getClientByIdCached } from '@openpanel/db'; import { ClientType, getClientByIdCached } from '@openpanel/db';
import { getCache } from '@openpanel/redis';
import type { PostEventPayload, TrackHandlerPayload } from '@openpanel/sdk'; import type { PostEventPayload, TrackHandlerPayload } from '@openpanel/sdk';
import type { import type {
IProjectFilterIp, IProjectFilterIp,
@@ -104,6 +105,22 @@ export async function validateSdkRequest(
throw createError('Ingestion: Profile id is blocked by project filter'); throw createError('Ingestion: Profile id is blocked by project filter');
} }
const revenue =
path(['payload', 'properties', '__revenue'], req.body) ??
path(['properties', '__revenue'], req.body);
// Only allow revenue tracking if it was sent with a client secret
// or if the project has allowUnsafeRevenueTracking enabled
if (
!client.project.allowUnsafeRevenueTracking &&
!clientSecret &&
typeof revenue !== 'undefined'
) {
throw createError(
'Ingestion: Revenue tracking is not allowed without a client secret',
);
}
if (client.ignoreCorsAndSecret) { if (client.ignoreCorsAndSecret) {
return client; return client;
} }
@@ -135,7 +152,13 @@ export async function validateSdkRequest(
} }
if (client.secret && clientSecret) { if (client.secret && clientSecret) {
if (await verifyPassword(clientSecret, client.secret)) { const isVerified = await getCache(
`client:auth:${clientId}:${Buffer.from(clientSecret).toString('base64')}`,
60 * 5,
async () => await verifyPassword(clientSecret, client.secret!),
true,
);
if (isVerified) {
return client; return client;
} }
} }

View File

@@ -1,11 +1,14 @@
import { getLock } from '@openpanel/redis'; import { getLock } from '@openpanel/redis';
import fastJsonStableHash from 'fast-json-stable-hash'; import fastJsonStableHash from 'fast-json-stable-hash';
import type { FastifyReply } from 'fastify';
export async function isDuplicatedEvent({ export async function isDuplicatedEvent({
ip,
origin,
payload, payload,
projectId, projectId,
}: { }: {
ip: string;
origin: string;
payload: Record<string, any>; payload: Record<string, any>;
projectId: string; projectId: string;
}) { }) {
@@ -13,6 +16,8 @@ export async function isDuplicatedEvent({
`fastify:deduplicate:${fastJsonStableHash.hash( `fastify:deduplicate:${fastJsonStableHash.hash(
{ {
...payload, ...payload,
ip,
origin,
projectId, projectId,
}, },
'md5', 'md5',
@@ -27,24 +32,3 @@ export async function isDuplicatedEvent({
return true; return true;
} }
export async function checkDuplicatedEvent({
reply,
payload,
projectId,
}: {
reply: FastifyReply;
payload: Record<string, any>;
projectId: string;
}) {
if (await isDuplicatedEvent({ payload, projectId })) {
reply.log.info('duplicated event', {
payload,
projectId,
});
reply.status(200).send('duplicated');
return true;
}
return false;
}

View File

@@ -1,7 +1,7 @@
import { ch, db } from '@openpanel/db'; import { ch, db } from '@openpanel/db';
import { import {
cronQueue, cronQueue,
eventsGroupQueue, eventsGroupQueues,
miscQueue, miscQueue,
notificationQueue, notificationQueue,
sessionsQueue, sessionsQueue,
@@ -71,7 +71,7 @@ export async function shutdown(
// Step 6: Close Bull queues (graceful shutdown of queue state) // Step 6: Close Bull queues (graceful shutdown of queue state)
try { try {
await Promise.all([ await Promise.all([
eventsGroupQueue.close(), ...eventsGroupQueues.map((queue) => queue.close()),
sessionsQueue.close(), sessionsQueue.close(),
cronQueue.close(), cronQueue.close(),
miscQueue.close(), miscQueue.close(),

View File

@@ -0,0 +1,29 @@
import { NextResponse } from 'next/server';
const IP_HEADER_ORDER = [
'cf-connecting-ip',
'true-client-ip',
'x-vercel-forwarded-for', // Vercel-specific, most reliable on Vercel
'x-forwarded-for', // Standard proxy header (first IP in chain)
'x-real-ip', // Alternative header
'x-client-ip',
'fastly-client-ip',
'do-connecting-ip',
'x-cluster-client-ip',
];
export const GET = function POST(req: Request) {
return NextResponse.json({
headers: Object.fromEntries(req.headers),
ips: IP_HEADER_ORDER.reduce(
(acc, header) => {
const value = req.headers.get(header);
if (value) {
acc[header] = value;
}
return acc;
},
{} as Record<string, string>,
),
});
};

View File

@@ -0,0 +1,79 @@
import { CheckCircle, CreditCard, Globe, Server, User } from 'lucide-react';
import type { ReactNode } from 'react';
interface FlowStepProps {
step: number;
actor: string;
description: string;
children?: ReactNode;
icon?: 'visitor' | 'website' | 'backend' | 'payment' | 'success';
isLast?: boolean;
}
const iconMap = {
visitor: User,
website: Globe,
backend: Server,
payment: CreditCard,
success: CheckCircle,
};
const iconColorMap = {
visitor: 'text-blue-500',
website: 'text-green-500',
backend: 'text-purple-500',
payment: 'text-yellow-500',
success: 'text-green-600',
};
const iconBorderColorMap = {
visitor: 'border-blue-500',
website: 'border-green-500',
backend: 'border-purple-500',
payment: 'border-yellow-500',
success: 'border-green-600',
};
export function FlowStep({
step,
actor,
description,
children,
icon = 'visitor',
isLast = false,
}: FlowStepProps) {
const Icon = iconMap[icon];
return (
<div className="relative flex gap-4 mb-4 min-w-0">
{/* Step number and icon */}
<div className="flex flex-col items-center flex-shrink-0">
<div className="relative z-10 bg-background">
<div className="flex items-center justify-center size-10 rounded-full bg-primary text-primary-foreground font-semibold text-sm shadow-sm">
{step}
</div>
<div
className={`absolute -bottom-2 -right-2 flex items-center justify-center w-6 h-6 rounded-full bg-background border shadow-sm ${iconBorderColorMap[icon] || 'border-primary'}`}
>
<Icon
className={`size-3.5 ${iconColorMap[icon] || 'text-primary'}`}
/>
</div>
</div>
{/* Connector line - extends from badge through content to next step */}
{!isLast && (
<div className="w-0.5 bg-border mt-2 flex-1 min-h-[2rem]" />
)}
</div>
{/* Content */}
<div className="flex-1 pt-1 min-w-0">
<div className="mb-2">
<span className="font-semibold text-foreground mr-2">{actor}:</span>{' '}
<span className="text-muted-foreground">{description}</span>
</div>
{children && <div className="mt-3 min-w-0">{children}</div>}
</div>
</div>
);
}

View File

@@ -88,9 +88,7 @@ We built OpenPanel from the ground up with privacy at its heart—and with featu
```html ```html
<script> <script>
window.op = window.op || function(...args) { window.op=window.op||function(){var n=[],o=new Proxy((function(){arguments.length>0&&n.push(Array.prototype.slice.call(arguments))}),{get:function(o,t){return"q"===t?n:function(){n.push([t].concat(Array.prototype.slice.call(arguments)))}}});return o}();
(window.op.q = window.op.q || []).push(args);
};
window.op('init', { window.op('init', {
clientId: 'YOUR_CLIENT_ID', clientId: 'YOUR_CLIENT_ID',
trackScreenViews: true, trackScreenViews: true,

View File

@@ -0,0 +1,104 @@
---
title: Avoid adblockers with proxy
description: Learn why adblockers block analytics and how to avoid it by proxying events.
---
In this article we need to talk about adblockers, why they exist, how they work, and how to avoid them.
Adblockers' main purpose was initially to block ads, but they have since started to block tracking scripts as well. This is primarily for privacy reasons, and while we respect that, there are legitimate use cases for understanding your visitors. OpenPanel is designed to be a privacy-friendly, cookieless analytics tool that doesn't track users across sites, but generic blocklists often catch all analytics tools indiscriminately.
The best way to avoid adblockers is to proxy events via your own domain name. Adblockers generally cannot block requests to your own domain (first-party requests) without breaking the functionality of the site itself.
## Built-in Support
Today, our Next.js SDK and WordPress plugin have built-in support for proxying:
- **WordPress**: Does it automatically.
- **Next.js**: Easy to setup with a route handler.
## Implementing Proxying for Any Framework
If you are not using Next.js or WordPress, you can implement proxying in any backend framework. The key is to set up an API endpoint on your domain (e.g., `api.domain.com` or `domain.com/api`) that forwards requests to OpenPanel.
Below is an example of how to set up a proxy using a [Hono](https://hono.dev/) server. This implementation mimics the logic used in our Next.js SDK.
> You can always see how our Next.js implementation looks like in our [repository](https://github.com/Openpanel-dev/openpanel/blob/main/packages/sdks/nextjs/createNextRouteHandler.ts).
### Hono Example
```typescript
import { Hono } from 'hono'
const app = new Hono()
// 1. Proxy the script file
app.get('/op1.js', async (c) => {
const scriptUrl = 'https://openpanel.dev/op1.js'
try {
const res = await fetch(scriptUrl)
const text = await res.text()
c.header('Content-Type', 'text/javascript')
// Optional caching for 24 hours
c.header('Cache-Control', 'public, max-age=86400, stale-while-revalidate=86400')
return c.body(text)
} catch (e) {
return c.json({ error: 'Failed to fetch script' }, 500)
}
})
// 2. Proxy the track event
app.post('/track', async (c) => {
const body = await c.req.json()
// Forward the client's IP address (be sure to pick correct IP based on your infra)
const ip = c.req.header('cf-connecting-ip') ??
c.req.header('x-forwarded-for')?.split(',')[0]
const headers = new Headers()
headers.set('Content-Type', 'application/json')
headers.set('Origin', c.req.header('origin') ?? '')
headers.set('User-Agent', c.req.header('user-agent') ?? '')
headers.set('openpanel-client-id', c.req.header('openpanel-client-id') ?? '')
if (ip) {
headers.set('openpanel-client-ip', ip)
}
try {
const res = await fetch('https://api.openpanel.dev/track', {
method: 'POST',
headers,
body: JSON.stringify(body),
})
return c.json(await res.text(), res.status)
} catch (e) {
return c.json(e, 500)
}
})
export default app
```
This script sets up two endpoints:
1. `GET /op1.js`: Fetches the OpenPanel script and serves it from your domain.
2. `POST /track`: Receives events from the frontend, adds necessary headers (User-Agent, Origin, Content-Type, openpanel-client-id, openpanel-client-ip), and forwards them to OpenPanel's API.
## Frontend Configuration
Once your proxy is running, you need to configure the OpenPanel script on your frontend to use your proxy endpoints instead of the default ones.
```html
<script>
window.op=window.op||function(){var n=[],o=new Proxy((function(){arguments.length>0&&n.push(Array.prototype.slice.call(arguments))}),{get:function(o,t){return"q"===t?n:function(){n.push([t].concat(Array.prototype.slice.call(arguments)))}}});return o}();
window.op('init', {
apiUrl: 'https://api.domain.com'
clientId: 'YOUR_CLIENT_ID',
trackScreenViews: true,
trackOutgoingLinks: true,
trackAttributes: true,
});
</script>
<script src="https://api.domain.com/op1.js" defer async></script>
```
By doing this, all requests are sent to your domain first, bypassing adblockers that look for third-party tracking domains.

View File

@@ -0,0 +1,190 @@
---
title: How it works
description: Understanding device IDs, session IDs, profile IDs, and event tracking
---
## Device ID
A **device ID** is a unique identifier generated for each device/browser combination. It's calculated using a hash function that combines:
- **User Agent** (browser/client information)
- **IP Address**
- **Origin** (project ID)
- **Salt** (a rotating secret key)
```typescript:packages/common/server/profileId.ts
export function generateDeviceId({
salt,
ua,
ip,
origin,
}: GenerateDeviceIdOptions) {
return createHash(`${ua}:${ip}:${origin}:${salt}`, 16);
}
```
### Salt Rotation
The salt used for device ID generation rotates **daily at midnight** (UTC). This means:
- Device IDs remain consistent throughout a single day
- Device IDs reset each day for privacy purposes
- The system maintains both the current and previous day's salt to handle events that may arrive slightly after midnight
```typescript:apps/worker/src/jobs/cron.salt.ts
// Salt rotation happens daily at midnight (pattern: '0 0 * * *')
```
When the salt rotates, all device IDs change, effectively anonymizing tracking data on a daily basis while still allowing session continuity within a 24-hour period.
## Session ID
A **session** represents a continuous period of user activity. Sessions are used to group related events together and understand user behavior patterns.
### Session Duration
Sessions have a **30-minute timeout**. If no events are received for 30 minutes, the session automatically ends. Each new event resets this 30-minute timer.
```typescript:apps/worker/src/utils/session-handler.ts
export const SESSION_TIMEOUT = 1000 * 60 * 30; // 30 minutes
```
### Session Creation Rules
Sessions are **only created for client events**, not server events. This means:
- Events sent from browsers, mobile apps, or client-side SDKs will create sessions
- Events sent from backend servers, scripts, or server-side SDKs will **not** create sessions
- If you only track events from your backend, no sessions will be created
Additionally, sessions are **not created for events older than 15 minutes**. This prevents historical data imports from creating artificial sessions.
```typescript:apps/worker/src/jobs/events.incoming-event.ts
// Sessions are not created if:
// 1. The event is from a server (uaInfo.isServer === true)
// 2. The timestamp is from the past (isTimestampFromThePast === true)
if (uaInfo.isServer || isTimestampFromThePast) {
// Event is attached to existing session or no session
}
```
## Profile ID
A **profile ID** is a persistent identifier for a user across multiple devices and sessions. It allows you to track the same user across different browsers, devices, and time periods.
### Profile ID Assignment
If a `profileId` is provided when tracking an event, it will be used to identify the user. However, **if no `profileId` is provided, it defaults to the `deviceId`**.
This means:
- Anonymous users (without a profile ID) are tracked by their device ID
- Once you identify a user (by providing a profile ID), all their events will be associated with that profile
- The same user can be tracked across multiple devices by using the same profile ID
```typescript:packages/db/src/services/event.service.ts
// If no profileId is provided, it defaults to deviceId
if (!payload.profileId && payload.deviceId) {
payload.profileId = payload.deviceId;
}
```
## Client Events vs Server Events
OpenPanel distinguishes between **client events** and **server events** based on the User-Agent header.
### Client Events
Client events are sent from:
- Web browsers (Chrome, Firefox, Safari, etc.)
- Mobile apps using client-side SDKs
- Any client that sends a browser-like User-Agent
Client events:
- Create sessions
- Generate device IDs
- Support full session tracking
### Server Events
Server events are detected when the User-Agent matches server patterns, such as:
- `Go-http-client/1.0`
- `node-fetch/1.0`
- Other single-name/version patterns (e.g., `LibraryName/1.0`)
Server events:
- Do **not** create sessions
- Are attached to existing sessions if available
- Are useful for backend tracking without session management
```typescript:packages/common/server/parser-user-agent.ts
// Server events are detected by patterns like "Go-http-client/1.0"
function isServer(res: UAParser.IResult) {
if (SINGLE_NAME_VERSION_REGEX.test(res.ua)) {
return true;
}
// ... additional checks
}
```
The distinction is made in the event processing pipeline:
```typescript:apps/worker/src/jobs/events.incoming-event.ts
const uaInfo = parseUserAgent(userAgent, properties);
// Only client events create sessions
if (uaInfo.isServer || isTimestampFromThePast) {
// Server events or old events don't create new sessions
}
```
## Timestamps
Events can include custom timestamps to track when events actually occurred, rather than when they were received by the server.
### Setting Custom Timestamps
You can provide a custom timestamp using the `__timestamp` property in your event properties:
```javascript
track('page_view', {
__timestamp: '2024-01-15T10:30:00Z'
});
```
### Timestamp Validation
The system validates timestamps to prevent abuse and ensure data quality:
1. **Future timestamps**: If a timestamp is more than **1 minute in the future**, the server timestamp is used instead
2. **Past timestamps**: If a timestamp is older than **15 minutes**, it's marked as `isTimestampFromThePast: true`
```typescript:apps/api/src/controllers/track.controller.ts
// Timestamp validation logic
const ONE_MINUTE_MS = 60 * 1000;
const FIFTEEN_MINUTES_MS = 15 * ONE_MINUTE_MS;
// Future check: more than 1 minute ahead
if (clientTimestampNumber > safeTimestamp + ONE_MINUTE_MS) {
return { timestamp: safeTimestamp, isTimestampFromThePast: false };
}
// Past check: older than 15 minutes
const isTimestampFromThePast =
clientTimestampNumber < safeTimestamp - FIFTEEN_MINUTES_MS;
```
### Timestamp Impact on Sessions
**Important**: Events with timestamps older than 15 minutes (`isTimestampFromThePast: true`) will **not create new sessions**. This prevents historical data imports from creating artificial sessions in your analytics.
```typescript:apps/worker/src/jobs/events.incoming-event.ts
// Events from the past don't create sessions
if (uaInfo.isServer || isTimestampFromThePast) {
// Attach to existing session or track without session
}
```
This ensures that:
- Real-time tracking creates proper sessions
- Historical data imports don't interfere with session analytics
- Backdated events are still tracked but don't affect session metrics

View File

@@ -0,0 +1,3 @@
{
"pages": ["sdks", "how-it-works", "..."]
}

View File

@@ -0,0 +1,364 @@
---
title: Revenue tracking
description: Learn how to easily track your revenue with OpenPanel and how to get it shown directly in your dashboard.
---
import { FlowStep } from '@/components/flow-step';
Revenue tracking is a great way to get a better understanding of what your best revenue source is. On this page we'll break down how to get started.
Before we start, we need to know some fundamentals about how OpenPanel and your payment provider work and how we can link a payment to a visitor.
### Payment providers
Usually, you create your checkout from your backend, which then returns a payment link that your visitor will be redirected to. When creating the checkout link, you usually add additional fields such as metadata, customer information, or order details. We'll add the device ID information in this metadata field to be able to link your payment to a visitor.
### OpenPanel
OpenPanel is a cookieless analytics tool that identifies visitors using a `device_id`. To link a payment to a visitor, you need to capture their `device_id` before they complete checkout. This `device_id` will be stored in your payment provider's metadata, and when the payment webhook arrives, you'll use it to associate the revenue with the correct visitor.
## Some typical flows
- [Revenue tracking from your backend (not identified)](#revenue-tracking-from-your-backend-webhook)
- [Revenue tracking from your backend (identified)](#revenue-tracking-from-your-backend-webhook-identified)
- [Revenue tracking from your frontend](#revenue-tracking-from-your-frontend)
- [Revenue tracking without linking it to a identity or device](#revenue-tracking-without-linking-it-to-an-identity-or-device)
### Revenue tracking from your backend (webhook)
This is the most common flow and most secure one. Your backend receives webhooks from your payment provider, and here is the best opportunity to do revenue tracking.
<FlowStep step={1} actor="Visitor" description="Visits your website" icon="visitor" />
<FlowStep step={2} actor="Visitor" description="Makes a purchase" icon="visitor" />
<FlowStep step={3} actor="Your website" description="Does a POST request to get the checkout URL" icon="website">
When you create the checkout, you should first call `op.fetchDeviceId()`, which will return your visitor's current `deviceId`. Pass this to your checkout endpoint.
```javascript
fetch('https://domain.com/api/checkout', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
deviceId: await op.fetchDeviceId(), // ✅ since deviceId is here we can link the payment now
// ... other checkout data
}),
})
.then(response => response.json())
.then(data => {
// Handle checkout response, e.g., redirect to payment link
window.location.href = data.paymentUrl;
})
```
</FlowStep>
<FlowStep step={4} actor="Your backend" description="Will generate and return the checkout URL" icon="backend">
```javascript
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
export async function POST(req: Request) {
const { deviceId, amount, currency } = await req.json();
const session = await stripe.checkout.sessions.create({
payment_method_types: ['card'],
line_items: [
{
price_data: {
currency: currency,
product_data: { name: 'Product Name' },
unit_amount: amount * 100, // Convert to cents
},
quantity: 1,
},
],
mode: 'payment',
metadata: {
deviceId: deviceId, // ✅ since deviceId is here we can link the payment now
},
success_url: 'https://domain.com/success',
cancel_url: 'https://domain.com/cancel',
});
return Response.json({
paymentUrl: session.url,
});
}
```
</FlowStep>
<FlowStep step={5} actor="Visitor" description="Gets redirected to payment link" icon="visitor" />
<FlowStep step={6} actor="Visitor" description="Pays on your payment provider" icon="payment" />
<FlowStep step={7} actor="Your backend" description="Receives a webhook for a successful payment" icon="backend">
```javascript
export async function POST(req: Request) {
const event = await req.json();
// Stripe sends events with type and data.object structure
if (event.type === 'checkout.session.completed') {
const session = event.data.object;
const deviceId = session.metadata.deviceId;
const amount = session.amount_total;
op.revenue(amount, { deviceId }); // ✅ since deviceId is here we can link the payment now
}
return Response.json({ received: true });
}
```
</FlowStep>
<FlowStep step={8} actor="Visitor" description="Redirected to your website with payment confirmation" icon="success" isLast />
---
### Revenue tracking from your backend (webhook) - Identified users
If your visitors are identified (meaning you have called `identify` with a `profileId`), this process gets a bit easier. You don't need to pass the `deviceId` when creating your checkout, and you only need to provide the `profileId` (in backend) to the revenue call.
<FlowStep step={1} actor="Visitor" description="Visits your website" icon="visitor" />
<FlowStep step={2} actor="Your website" description="Identifies the visitor" icon="website">
When a visitor logs in or is identified, call `op.identify()` with their unique `profileId`.
```javascript
op.identify({
profileId: 'user-123', // Unique identifier for this user
email: 'user@example.com',
firstName: 'John',
lastName: 'Doe',
});
```
</FlowStep>
<FlowStep step={3} actor="Visitor" description="Makes a purchase" icon="visitor" />
<FlowStep step={4} actor="Your website" description="Does a POST request to get the checkout URL" icon="website">
Since the visitor is already identified, you don't need to fetch or pass the `deviceId`. Just send the checkout data.
```javascript
fetch('https://domain.com/api/checkout', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
// ✅ No deviceId needed - user is already identified
// ... other checkout data
}),
})
.then(response => response.json())
.then(data => {
// Handle checkout response, e.g., redirect to payment link
window.location.href = data.paymentUrl;
})
```
</FlowStep>
<FlowStep step={5} actor="Your backend" description="Will generate and return the checkout URL" icon="backend">
Since the user is authenticated, you can get their `profileId` from the session and store it in metadata for easy retrieval in the webhook.
```javascript
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
export async function POST(req: Request) {
const { amount, currency } = await req.json();
// Get profileId from authenticated session
const profileId = req.session.userId; // or however you get the user ID
const session = await stripe.checkout.sessions.create({
payment_method_types: ['card'],
line_items: [
{
price_data: {
currency: currency,
product_data: { name: 'Product Name' },
unit_amount: amount * 100, // Convert to cents
},
quantity: 1,
},
],
mode: 'payment',
metadata: {
profileId: profileId, // ✅ Store profileId instead of deviceId
},
success_url: 'https://domain.com/success',
cancel_url: 'https://domain.com/cancel',
});
return Response.json({
paymentUrl: session.url,
});
}
```
</FlowStep>
<FlowStep step={6} actor="Visitor" description="Gets redirected to payment link" icon="visitor" />
<FlowStep step={7} actor="Visitor" description="Pays on your payment provider" icon="payment" />
<FlowStep step={8} actor="Your backend" description="Receives a webhook for a successful payment" icon="backend">
In the webhook handler, retrieve the `profileId` from the session metadata.
```javascript
export async function POST(req: Request) {
const event = await req.json();
// Stripe sends events with type and data.object structure
if (event.type === 'checkout.session.completed') {
const session = event.data.object;
const profileId = session.metadata.profileId;
const amount = session.amount_total;
op.revenue(amount, { profileId }); // ✅ Use profileId instead of deviceId
}
return Response.json({ received: true });
}
```
</FlowStep>
<FlowStep step={9} actor="Visitor" description="Redirected to your website with payment confirmation" icon="success" isLast />
---
### Revenue tracking from your frontend
This flow tracks revenue directly from your frontend. Since the success page doesn't have access to the payment amount (payment happens on Stripe's side), we track revenue when checkout is initiated and then confirm it on the success page.
<FlowStep step={1} actor="Visitor" description="Visits your website" icon="visitor" />
<FlowStep step={2} actor="Visitor" description="Clicks to purchase" icon="visitor" />
<FlowStep step={3} actor="Your website" description="Track revenue when checkout is initiated" icon="website">
When the visitor clicks the checkout button, track the revenue with the amount.
```javascript
async function handleCheckout() {
const amount = 2000; // Amount in cents
// Create a pending revenue (stored in sessionStorage)
op.pendingRevenue(amount, {
productId: '123',
// ... other properties
});
// Redirect to Stripe checkout
window.location.href = 'https://checkout.stripe.com/...';
}
```
</FlowStep>
<FlowStep step={4} actor="Visitor" description="Gets redirected to payment link" icon="visitor" />
<FlowStep step={5} actor="Visitor" description="Pays on your payment provider" icon="payment" />
<FlowStep step={6} actor="Visitor" description="Redirected back to your success page" icon="visitor" />
<FlowStep step={7} actor="Your website" description="Confirm/flush the revenue on success page" icon="website" isLast>
On your success page, flush all pending revenue events. This will send all pending revenues tracked during checkout and clear them from sessionStorage.
```javascript
// Flush all pending revenues
await op.flushRevenue();
// Or if you want to clear without sending (e.g., payment was cancelled)
op.clearRevenue();
```
</FlowStep>
#### Pros:
- Quick way to get going
- No backend required
- Can track revenue immediately when checkout starts
#### Cons:
- Less accurate (visitor might not complete payment)
- Less "secure" meaning anyone could post revenue data
---
### Revenue tracking without linking it to an identity or device
If you simply want to track revenue totals without linking payments to specific visitors or devices, you can call `op.revenue()` directly from your backend without providing a `deviceId` or `profileId`. This is the simplest approach and works well when you only need aggregate revenue data.
<FlowStep step={1} actor="Visitor" description="Makes a purchase" icon="visitor" />
<FlowStep step={2} actor="Visitor" description="Pays on your payment provider" icon="payment" />
<FlowStep step={3} actor="Your backend" description="Receives a webhook for a successful payment" icon="backend" isLast>
Simply call `op.revenue()` with the amount. No `deviceId` or `profileId` is needed.
```javascript
export async function POST(req: Request) {
const event = await req.json();
// Stripe sends events with type and data.object structure
if (event.type === 'checkout.session.completed') {
const session = event.data.object;
const amount = session.amount_total;
op.revenue(amount); // ✅ Simple revenue tracking without linking to a visitor
}
return Response.json({ received: true });
}
```
</FlowStep>
#### Pros:
- Simplest implementation
- No need to capture or pass device IDs
- Works well for aggregate revenue tracking
#### Cons:
- **You can't dive deeper into where this revenue came from.** For instance, you won't be able to see which source generates the best revenue, which campaigns are most profitable, or which visitors are your highest-value customers.
- Revenue events won't be linked to specific user journeys or sessions
## Available methods
### Revenue
The revenue method will create a revenue event. It's important to know that this method will not work if your OpenPanel instance didn't receive a client secret (for security reasons). You can enable frontend revenue tracking within your project settings.
```javascript
op.revenue(amount: number, properties: Record<string, unknown>): Promise<void>
```
### Add a pending revenue
This method will create a pending revenue item and store it in sessionStorage. It will not be sent to OpenPanel until you call `flushRevenue()`. Pending revenues are automatically restored from sessionStorage when the SDK initializes.
```javascript
op.pendingRevenue(amount: number, properties?: Record<string, unknown>): void
```
### Send all pending revenues
This method will send all pending revenues to OpenPanel and then clear them from sessionStorage. Returns a Promise that resolves when all revenues have been sent.
```javascript
await op.flushRevenue(): Promise<void>
```
### Clear any pending revenue
This method will clear all pending revenues from memory and sessionStorage without sending them to OpenPanel. Useful if a payment was cancelled or you want to discard pending revenues.
```javascript
op.clearRevenue(): void
```
### Fetch your current users device id
```javascript
op.fetchDeviceId(): Promise<string>
```

View File

@@ -0,0 +1,20 @@
{
"title": "SDKs",
"pages": [
"script",
"web",
"javascript",
"nextjs",
"react",
"vue",
"astro",
"remix",
"express",
"python",
"react-native",
"swift",
"kotlin",
"..."
],
"defaultOpen": false
}

View File

@@ -15,7 +15,7 @@ Just insert this snippet and replace `YOUR_CLIENT_ID` with your client id.
```html title="index.html" /clientId: 'YOUR_CLIENT_ID'/ ```html title="index.html" /clientId: 'YOUR_CLIENT_ID'/
<script> <script>
window.op = window.op||function(...args){(window.op.q=window.op.q||[]).push(args);}; window.op=window.op||function(){var n=[],o=new Proxy((function(){arguments.length>0&&n.push(Array.prototype.slice.call(arguments))}),{get:function(o,t){return"q"===t?n:function(){n.push([t].concat(Array.prototype.slice.call(arguments)))}}});return o}();
window.op('init', { window.op('init', {
clientId: 'YOUR_CLIENT_ID', clientId: 'YOUR_CLIENT_ID',
trackScreenViews: true, trackScreenViews: true,

View File

@@ -0,0 +1,47 @@
---
title: Identify Users
description: Connect anonymous events to specific users.
---
By default, OpenPanel tracks visitors anonymously. To connect these events to a specific user in your database, you need to identify them.
## How it works
When a user logs in or signs up, you should call the `identify` method. This associates their current session and all future events with their unique ID from your system.
```javascript
op.identify({
profileId: 'user_123'
});
```
## Adding user traits
You can also pass user traits (like name, email, or plan type) when you identify them. These traits will appear in the user's profile in your dashboard.
```javascript
op.identify({
profileId: 'user_123',
firstName: 'Jane',
lastName: 'Doe',
email: 'jane@example.com',
company: 'Acme Inc'
});
```
### Standard traits
We recommend using these standard keys for common user information so they display correctly in the OpenPanel dashboard:
- `firstName`
- `lastName`
- `email`
- `phone`
- `avatar`
## Best Practices
1. **Call on login**: Always identify the user immediately after they log in.
2. **Call on update**: If a user updates their profile, call identify again with the new information.
3. **Unique IDs**: Use a stable, unique ID from your database (like a UUID) rather than an email address or username that might change.

View File

@@ -0,0 +1,81 @@
---
title: Install OpenPanel
description: Get started with OpenPanel in less than 2 minutes.
---
import { Cards, Card } from 'fumadocs-ui/components/card';
import { Code, Globe, Layout, Smartphone, FileJson } from 'lucide-react';
The quickest way to get started with OpenPanel is to use our Web SDK. It works with any website.
## Quick Start
Simply add this script tag to your website's `<head>` section.
```html title="index.html"
<script>
window.op=window.op||function(){var n=[],o=new Proxy((function(){arguments.length>0&&n.push(Array.prototype.slice.call(arguments))}),{get:function(o,t){return"q"===t?n:function(){n.push([t].concat(Array.prototype.slice.call(arguments)))}}});return o}();
window.op('init', {
clientId: 'YOUR_CLIENT_ID',
trackScreenViews: true,
trackOutgoingLinks: true,
trackAttributes: true,
});
</script>
<script src="https://openpanel.dev/op1.js" defer async></script>
```
That's it! OpenPanel will now automatically track:
- Page views
- Visit duration
- Referrers
- Device and browser information
- Location
## Using a Framework?
If you are using a specific framework or platform, we have dedicated SDKs that provide a better developer experience.
<Cards>
<Card
href="/docs/sdks/nextjs"
title="Next.js"
icon={<Globe />}
description="Optimized for App Router and Server Components"
/>
<Card
href="/docs/sdks/react"
title="React"
icon={<Layout />}
description="Components and hooks for React applications"
/>
<Card
href="/docs/sdks/vue"
title="Vue"
icon={<Layout />}
description="Integration for Vue.js applications"
/>
<Card
href="/docs/sdks/javascript"
title="JavaScript"
icon={<FileJson />}
description="Universal JavaScript/TypeScript SDK"
/>
<Card
href="/docs/sdks/react-native"
title="React Native"
icon={<Smartphone />}
description="Track mobile apps with React Native"
/>
<Card
href="/docs/sdks/python"
title="Python"
icon={<Code />}
description="Server-side tracking for Python"
/>
</Cards>
## Explore all SDKs
We support many more platforms. Check out our [SDKs Overview](/docs/sdks) for the full list.

View File

@@ -0,0 +1,8 @@
{
"pages": [
"install-openpanel",
"track-events",
"identify-users",
"revenue-tracking"
]
}

View File

@@ -0,0 +1,48 @@
---
title: Track Events
description: Learn how to track custom events to measure user actions.
---
Events are the core of OpenPanel. They allow you to measure specific actions users take on your site, like clicking a button, submitting a form, or completing a purchase.
## Tracking an event
To track an event, simply call the `track` method with an event name.
```javascript
op.track('button_clicked');
```
## Adding properties
You can add additional context to your events by passing a properties object. This helps you understand the details of the interaction.
```javascript
op.track('signup_button_clicked', {
location: 'header',
color: 'blue',
variant: 'primary'
});
```
### Common property types
- **Strings**: Text values like names, categories, or IDs.
- **Numbers**: Numeric values like price, quantity, or score.
- **Booleans**: True or false values.
## Using Data Attributes
If you prefer not to write JavaScript, you can use data attributes to track clicks automatically.
```html
<button
data-track="signup_clicked"
data-location="header"
>
Sign Up
</button>
```
When a user clicks this button, OpenPanel will automatically track a `signup_clicked` event with the property `location: 'header'`.

View File

@@ -1,111 +1,83 @@
--- ---
title: Introduction to OpenPanel title: What is OpenPanel?
description: Get started with OpenPanel's powerful analytics platform that combines the best of product and web analytics in one simple solution. description: OpenPanel is an open-source web and product analytics platform that combines the power of Mixpanel with the ease of Plausible. Whether you're tracking website visitors or analyzing user behavior in your app, OpenPanel provides the insights you need without the complexity.
--- ---
## What is OpenPanel? import { UserIcon,HardDriveIcon } from 'lucide-react'
OpenPanel is an open-source analytics platform that combines product analytics (like Mixpanel) with web analytics (like Plausible) into one simple solution. Whether you're tracking website visitors or analyzing user behavior in your app, OpenPanel provides the insights you need without the complexity. ## ✨ Key Features
## Key Features - **🔍 Advanced Analytics**: Funnels, cohorts, user profiles, and session history
- **📊 Real-time Dashboards**: Live data updates and interactive charts
- **🎯 A/B Testing**: Built-in variant testing with detailed breakdowns
- **🔔 Smart Notifications**: Event and funnel-based alerts
- **🌍 Privacy-First**: Cookieless tracking and GDPR compliance
- **🚀 Developer-Friendly**: Comprehensive SDKs and API access
- **📦 Self-Hosted**: Full control over your data and infrastructure
- **💸 Transparent Pricing**: No hidden costs
- **🛠️ Custom Dashboards**: Flexible chart creation and data visualization
- **📱 Multi-Platform**: Web, mobile (iOS/Android), and server-side tracking
### Web Analytics ## 📊 Analytics Platform Comparison
- **Real-time data**: See visitor activity as it happens
- **Traffic sources**: Understand where your visitors come from
- **Geographic insights**: Track visitor locations and trends
- **Device analytics**: Monitor usage across different devices
- **Page performance**: Analyze your most visited pages
### Product Analytics | Feature | OpenPanel | Mixpanel | GA4 | Plausible |
- **Event tracking**: Monitor user actions and interactions |----------------------------------------|-----------|----------|-----------|-----------|
- **User profiles**: Build detailed user journey insights | ✅ Open-source | ✅ | ❌ | ❌ | ✅ |
- **Funnels**: Analyze conversion paths | 🧩 Self-hosting supported | ✅ | ❌ | ❌ | ✅ |
- **Retention**: Track user engagement over time | 🔒 Cookieless by default | ✅ | ❌ | ❌ | ✅ |
- **Custom properties**: Add context to your events | 🔁 Real-time dashboards | ✅ | ✅ | ❌ | ✅ |
| 🔍 Funnels & cohort analysis | ✅ | ✅ | ✅* | ✅*** |
| 👤 User profiles & session history | ✅ | ✅ | ❌ | ❌ |
| 📈 Custom dashboards & charts | ✅ | ✅ | ✅ | ❌ |
| 💬 Event & funnel notifications | ✅ | ✅ | ❌ | ❌ |
| 🌍 GDPR-compliant tracking | ✅ | ✅ | ❌** | ✅ |
| 📦 SDKs (Web, Swift, Kotlin, ReactNative) | ✅ | ✅ | ✅ | ❌ |
| 💸 Transparent pricing | ✅ | ❌ | ✅* | ✅ |
| 🚀 Built for developers | ✅ | ✅ | ❌ | ✅ |
| 🔧 A/B testing & variant breakdowns | ✅ | ✅ | ❌ | ❌ |
## Getting Started ✅* GA4 has a free tier but often requires BigQuery (paid) for raw data access.
❌** GA4 has faced GDPR bans in several EU countries due to data transfers to US-based servers.
✅*** Plausible has simple goals
1. **Installation**: Choose your preferred method: ## 🚀 Quick Start
- [Script tag](/docs/sdks/script) - Quickest way to get started
- [Web SDK](/docs/sdks/web) - For more control and TypeScript support
- [React](/docs/sdks/react) - Native React integration
- [Next.js](/docs/sdks/nextjs) - Optimized for Next.js apps
2. **Core Methods**: Before you can start tracking your events you'll need to create an account or spin up your own instance of OpenPanel.
```js
// Track an event
track('button_clicked', {
buttonId: 'signup',
location: 'header'
});
// Identify a user <Cards>
identify({ <Card
profileId: 'user123', href="https://dashboard.openpanel.dev/onboarding"
email: 'user@example.com', title="Create an account"
firstName: 'John' icon={<UserIcon />}
}); description="Create your account and workspace"
``` />
<Card
href="/docs/self-hosting/self-hosting"
title="Self-hosted OpenPanel"
icon={<HardDriveIcon />}
description="Get full control and start self-host"
/>
</Cards>
## Privacy First 1. **[Install OpenPanel](/docs/get-started/install-openpanel)** - Add the script tag or use one of our SDKs
2. **[Track Events](/docs/get-started/track-events)** - Start measuring user actions
3. **[Identify Users](/docs/get-started/identify-users)** - Connect events to specific users
4. **[Track Revenue](/docs/get-started/revenue-tracking)** - Monitor purchases and subscriptions
## 🔒 Privacy First
OpenPanel is built with privacy in mind: OpenPanel is built with privacy in mind:
- No cookies required - **No cookies required** - Cookieless tracking by default
- GDPR and CCPA compliant - **GDPR and CCPA compliant** - Built for privacy regulations
- Self-hosting option available - **Self-hosting option** - Full control over your data
- Full control over your data - **Transparent data handling** - You own your data
## Open Source ## 🌐 Open Source
OpenPanel is fully open-source and available on [GitHub](https://github.com/Openpanel-dev/openpanel). We believe in transparency and community-driven development. OpenPanel is fully open-source and available on [GitHub](https://github.com/Openpanel-dev/openpanel). We believe in transparency and community-driven development.
## Need Help? ## 💬 Need Help?
- Join our [Discord community](https://go.openpanel.dev/discord) - Join our [Discord community](https://go.openpanel.dev/discord)
- Check our [GitHub issues](https://github.com/Openpanel-dev/openpanel/issues) - Check our [GitHub issues](https://github.com/Openpanel-dev/openpanel/issues)
- Email us at [hello@openpanel.dev](mailto:hello@openpanel.dev) - Email us at [hello@openpanel.dev](mailto:hello@openpanel.dev)
## Core Methods
### Set global properties
Sets global properties that will be included with every subsequent event.
### Track
Tracks a custom event with the given name and optional properties.
#### Tips
You can identify the user directly with this method.
```js title="Example shown in JavaScript"
track('your_event_name', {
foo: 'bar',
baz: 'qux',
// reserved property name
__identify: {
profileId: 'your_user_id', // required
email: 'your_user_email',
firstName: 'your_user_name',
lastName: 'your_user_name',
avatar: 'your_user_avatar',
}
});
```
### Identify
Associates the current user with a unique identifier and optional traits.
### Increment
Increments a numeric property for a user.
### Decrement
Decrements a numeric property for a user.
### Clear
Clears the current user identifier and ends the session.

View File

@@ -0,0 +1,16 @@
{
"pages": [
"---Introduction---",
"index",
"---Get started---",
"...get-started",
"---Tracking---",
"...(tracking)",
"---API---",
"...api",
"---Self-hosting---",
"...self-hosting",
"---Migration---",
"...migration"
]
}

View File

@@ -1,5 +0,0 @@
{
"title": "SDKs",
"pages": ["script", "web", "javascript", "nextjs", "react", "vue", "astro", "remix", "express", "python", "react-native", "swift", "kotlin"],
"defaultOpen": true
}

View File

@@ -76,7 +76,7 @@ The path should be `/api` and the domain should be your domain.
```html title="index.html" ```html title="index.html"
<script> <script>
window.op = window.op||function(...args){(window.op.q=window.op.q||[]).push(args);}; window.op=window.op||function(){var n=[],o=new Proxy((function(){arguments.length>0&&n.push(Array.prototype.slice.call(arguments))}),{get:function(o,t){return"q"===t?n:function(){n.push([t].concat(Array.prototype.slice.call(arguments)))}}});return o}();
window.op('init', { window.op('init', {
apiUrl: 'https://your-domain.com/api', // [!code highlight] apiUrl: 'https://your-domain.com/api', // [!code highlight]
clientId: 'YOUR_CLIENT_ID', clientId: 'YOUR_CLIENT_ID',

View File

@@ -3,7 +3,7 @@
"version": "0.0.0", "version": "0.0.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "pnpm with-env next dev", "dev": "pnpm with-env next dev --port 3001",
"build": "pnpm with-env next build", "build": "pnpm with-env next build",
"start": "next start", "start": "next start",
"postinstall": "fumadocs-mdx", "postinstall": "fumadocs-mdx",
@@ -14,7 +14,7 @@
"@hyperdx/node-opentelemetry": "^0.8.1", "@hyperdx/node-opentelemetry": "^0.8.1",
"@number-flow/react": "0.3.5", "@number-flow/react": "0.3.5",
"@openpanel/common": "workspace:*", "@openpanel/common": "workspace:*",
"@openpanel/nextjs": "^1.0.12", "@openpanel/nextjs": "^1.0.15",
"@openpanel/payments": "workspace:^", "@openpanel/payments": "workspace:^",
"@openpanel/sdk-info": "workspace:^", "@openpanel/sdk-info": "workspace:^",
"@openstatus/react": "0.0.3", "@openstatus/react": "0.0.3",

File diff suppressed because one or more lines are too long

View File

@@ -19,7 +19,6 @@
}, },
"dependencies": { "dependencies": {
"@ai-sdk/react": "^1.2.5", "@ai-sdk/react": "^1.2.5",
"@clickhouse/client": "^1.2.0",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
@@ -104,7 +103,6 @@
"lodash.throttle": "^4.1.1", "lodash.throttle": "^4.1.1",
"lottie-react": "^2.4.0", "lottie-react": "^2.4.0",
"lucide-react": "^0.476.0", "lucide-react": "^0.476.0",
"mathjs": "^12.3.2",
"mitt": "^3.0.1", "mitt": "^3.0.1",
"nuqs": "^2.5.2", "nuqs": "^2.5.2",
"prisma-error-enum": "^0.1.3", "prisma-error-enum": "^0.1.3",

View File

@@ -1,112 +0,0 @@
import * as d3 from 'd3';
export function ChartSSR({
data,
dots = false,
color = 'blue',
}: {
dots?: boolean;
color?: 'blue' | 'green' | 'red';
data: { value: number; date: Date }[];
}) {
if (data.length === 0) {
return null;
}
const xScale = d3
.scaleTime()
.domain([data[0]!.date, data[data.length - 1]!.date])
.range([0, 100]);
const yScale = d3
.scaleLinear()
.domain([0, d3.max(data.map((d) => d.value)) ?? 0])
.range([100, 0]);
const line = d3
.line<(typeof data)[number]>()
.curve(d3.curveMonotoneX)
.x((d) => xScale(d.date))
.y((d) => yScale(d.value));
const area = d3
.area<(typeof data)[number]>()
.curve(d3.curveMonotoneX)
.x((d) => xScale(d.date))
.y0(yScale(0))
.y1((d) => yScale(d.value));
const pathLine = line(data);
const pathArea = area(data);
if (!pathLine) {
return null;
}
const gradientId = `gradient-${color}`;
return (
<div className="relative h-full w-full">
{/* Chart area */}
<svg className="absolute inset-0 h-full w-full overflow-visible">
<svg
viewBox="0 0 100 100"
className="overflow-visible"
preserveAspectRatio="none"
>
<defs>
<linearGradient
id={gradientId}
x1="0"
y1="0"
x2="0"
y2="100%"
gradientUnits="userSpaceOnUse"
>
<stop offset="0%" stopColor={color} stopOpacity={0.2} />
<stop offset="50%" stopColor={color} stopOpacity={0.05} />
<stop offset="100%" stopColor={color} stopOpacity={0} />
</linearGradient>
</defs>
{/* Gradient area */}
{pathArea && (
<path
d={pathArea}
fill={`url(#${gradientId})`}
vectorEffect="non-scaling-stroke"
/>
)}
{/* Line */}
<path
d={pathLine}
fill="none"
className={
color === 'green'
? 'text-green-600'
: color === 'red'
? 'text-red-600'
: 'text-highlight'
}
stroke="currentColor"
strokeWidth="2"
vectorEffect="non-scaling-stroke"
/>
{/* Circles */}
{dots &&
data.map((d) => (
<path
key={d.date.toString()}
d={`M ${xScale(d.date)} ${yScale(d.value)} l 0.0001 0`}
vectorEffect="non-scaling-stroke"
strokeWidth="8"
strokeLinecap="round"
fill="none"
stroke="currentColor"
className="text-gray-400"
/>
))}
</svg>
</svg>
</div>
);
}

View File

@@ -1,3 +1,4 @@
import { cn } from '@/utils/cn';
import { createContext, useContext as useBaseContext } from 'react'; import { createContext, useContext as useBaseContext } from 'react';
import { Tooltip as RechartsTooltip, type TooltipProps } from 'recharts'; import { Tooltip as RechartsTooltip, type TooltipProps } from 'recharts';
@@ -21,11 +22,18 @@ export const ChartTooltipHeader = ({
export const ChartTooltipItem = ({ export const ChartTooltipItem = ({
children, children,
color, color,
}: { children: React.ReactNode; color: string }) => { className,
innerClassName,
}: {
children: React.ReactNode;
color: string;
className?: string;
innerClassName?: string;
}) => {
return ( return (
<div className="flex gap-2"> <div className={cn('flex gap-2', className)}>
<div className="w-[3px] rounded-full" style={{ background: color }} /> <div className="w-[3px] rounded-full" style={{ background: color }} />
<div className="col flex-1 gap-1">{children}</div> <div className={cn('col flex-1 gap-1', innerClassName)}>{children}</div>
</div> </div>
); );
}; };

View File

@@ -77,6 +77,15 @@ export const BarShapeBlue = BarWithBorder({
fill: 'rgba(59, 121, 255, 0.4)', fill: 'rgba(59, 121, 255, 0.4)',
}, },
}); });
export const BarShapeGreen = BarWithBorder({
borderHeight: 2,
border: 'rgba(59, 169, 116, 1)',
fill: 'rgba(59, 169, 116, 0.3)',
active: {
border: 'rgba(59, 169, 116, 1)',
fill: 'rgba(59, 169, 116, 0.4)',
},
});
export const BarShapeProps = BarWithBorder({ export const BarShapeProps = BarWithBorder({
borderHeight: 2, borderHeight: 2,
border: 'props', border: 'props',

View File

@@ -48,6 +48,10 @@ export const EventIconRecords: Record<
icon: 'ExternalLinkIcon', icon: 'ExternalLinkIcon',
color: 'indigo', color: 'indigo',
}, },
revenue: {
icon: 'DollarSignIcon',
color: 'green',
},
}; };
export const EventIconMapper: Record<string, LucideIcon> = { export const EventIconMapper: Record<string, LucideIcon> = {

View File

@@ -54,7 +54,7 @@ export const EventItem = memo<EventItemProps>(
}} }}
data-slot="inner" data-slot="inner"
className={cn( className={cn(
'col gap-2 flex-1 p-2', 'col gap-1 flex-1 p-2',
// Desktop // Desktop
'@lg:row @lg:items-center', '@lg:row @lg:items-center',
'cursor-pointer', 'cursor-pointer',
@@ -63,7 +63,7 @@ export const EventItem = memo<EventItemProps>(
: 'hover:bg-def-200', : 'hover:bg-def-200',
)} )}
> >
<div className="min-w-0 flex-1 row items-center gap-4"> <div className="min-w-0 flex-1 row items-center gap-2">
<button <button
type="button" type="button"
className="transition-transform hover:scale-105" className="transition-transform hover:scale-105"
@@ -77,7 +77,7 @@ export const EventItem = memo<EventItemProps>(
> >
<EventIcon name={event.name} size="sm" meta={event.meta} /> <EventIcon name={event.name} size="sm" meta={event.meta} />
</button> </button>
<span className="min-w-0 whitespace-break-spaces wrap-break-word break-all"> <span className="min-w-0 whitespace-break-spaces wrap-break-word break-all text-sm leading-normal">
{event.name === 'screen_view' ? ( {event.name === 'screen_view' ? (
<> <>
<span className="text-muted-foreground mr-2">Visit:</span> <span className="text-muted-foreground mr-2">Visit:</span>
@@ -87,13 +87,12 @@ export const EventItem = memo<EventItemProps>(
</> </>
) : ( ) : (
<> <>
<span className="text-muted-foreground mr-2">Event:</span>
<span className="font-medium">{event.name}</span> <span className="font-medium">{event.name}</span>
</> </>
)} )}
</span> </span>
</div> </div>
<div className="row gap-2 items-center @max-lg:pl-10"> <div className="row gap-2 items-center @max-lg:pl-8">
{event.referrerName && viewOptions.referrerName !== false && ( {event.referrerName && viewOptions.referrerName !== false && (
<Pill <Pill
icon={<SerieIcon className="mr-2" name={event.referrerName} />} icon={<SerieIcon className="mr-2" name={event.referrerName} />}

View File

@@ -24,7 +24,7 @@ const ConnectWeb = ({ client }: Props) => {
<Syntax <Syntax
className="border" className="border"
code={`<script> code={`<script>
window.op = window.op||function(...args){(window.op.q=window.op.q||[]).push(args);}; window.op=window.op||function(){var n=[],o=new Proxy((function(){arguments.length>0&&n.push(Array.prototype.slice.call(arguments))}),{get:function(o,t){return"q"===t?n:function(){n.push([t].concat(Array.prototype.slice.call(arguments)))}}});return o}();
window.op('init', { window.op('init', {
clientId: '${client?.id ?? 'YOUR_CLIENT_ID'}', clientId: '${client?.id ?? 'YOUR_CLIENT_ID'}',
trackScreenViews: true, trackScreenViews: true,

View File

@@ -108,8 +108,8 @@ function Wrapper({ children, count, icons }: WrapperProps) {
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<div className="row gap-2 justify-between"> <div className="row gap-2 justify-between">
<div className="relative mb-1 text-sm font-medium text-muted-foreground"> <div className="relative mb-1 text-xs font-medium text-muted-foreground">
{count} sessions last 30 minutes {count} sessions last 30 min
</div> </div>
<div>{icons}</div> <div>{icons}</div>
</div> </div>

View File

@@ -6,7 +6,7 @@ import { Area, AreaChart, Tooltip } from 'recharts';
import { formatDate, timeAgo } from '@/utils/date'; import { formatDate, timeAgo } from '@/utils/date';
import { getChartColor } from '@/utils/theme'; import { getChartColor } from '@/utils/theme';
import { getPreviousMetric } from '@openpanel/common'; import { getPreviousMetric } from '@openpanel/common';
import { useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { import {
ChartTooltipContainer, ChartTooltipContainer,
ChartTooltipHeader, ChartTooltipHeader,
@@ -24,12 +24,13 @@ interface MetricCardProps {
data: { data: {
current: number; current: number;
previous?: number; previous?: number;
date: string;
}[]; }[];
metric: { metric: {
current: number; current: number;
previous?: number | null; previous?: number | null;
}; };
unit?: '' | 'date' | 'timeAgo' | 'min' | '%'; unit?: '' | 'date' | 'timeAgo' | 'min' | '%' | 'currency';
label: string; label: string;
onClick?: () => void; onClick?: () => void;
active?: boolean; active?: boolean;
@@ -48,9 +49,28 @@ export function OverviewMetricCard({
inverted = false, inverted = false,
isLoading = false, isLoading = false,
}: MetricCardProps) { }: MetricCardProps) {
const [value, setValue] = useState(metric.current); const [currentIndex, setCurrentIndex] = useState<number | null>(null);
const number = useNumber(); const number = useNumber();
const { current, previous } = metric; const { current, previous } = metric;
const timer = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
if (timer.current) {
clearTimeout(timer.current);
}
if (currentIndex) {
timer.current = setTimeout(() => {
setCurrentIndex(null);
}, 1000);
}
return () => {
if (timer.current) {
clearTimeout(timer.current);
}
};
}, [currentIndex]);
const renderValue = (value: number, unitClassName?: string, short = true) => { const renderValue = (value: number, unitClassName?: string, short = true) => {
if (unit === 'date') { if (unit === 'date') {
@@ -65,6 +85,11 @@ export function OverviewMetricCard({
return <>{fancyMinutes(value)}</>; return <>{fancyMinutes(value)}</>;
} }
if (unit === 'currency') {
// Revenue is stored in cents, convert to dollars
return <>{number.currency(value / 100)}</>;
}
return ( return (
<> <>
{short ? number.short(value) : number.format(value)} {short ? number.short(value) : number.format(value)}
@@ -81,19 +106,33 @@ export function OverviewMetricCard({
'#93c5fd', // blue '#93c5fd', // blue
); );
return ( const renderTooltip = () => {
<Tooltiper if (currentIndex) {
content={ return (
<span> <span>
{label}:{' '} {formatDate(new Date(data[currentIndex]?.date))}:{' '}
<span className="font-semibold"> <span className="font-semibold">
{renderValue(value, 'ml-1 font-light text-xl', false)} {renderValue(
data[currentIndex].current,
'ml-1 font-light text-xl',
false,
)}
</span> </span>
</span> </span>
} );
asChild }
sideOffset={-20}
> return (
<span>
{label}:{' '}
<span className="font-semibold">
{renderValue(metric.current, 'ml-1 font-light text-xl', false)}
</span>
</span>
);
};
return (
<Tooltiper content={renderTooltip()} asChild sideOffset={-20}>
<button <button
type="button" type="button"
className={cn( className={cn(
@@ -116,9 +155,7 @@ export function OverviewMetricCard({
data={data} data={data}
style={{ marginTop: (height / 4) * 3 }} style={{ marginTop: (height / 4) * 3 }}
onMouseMove={(event) => { onMouseMove={(event) => {
setValue( setCurrentIndex(event.activeTooltipIndex ?? null);
event.activePayload?.[0]?.payload?.current ?? current,
);
}} }}
> >
<defs> <defs>

View File

@@ -2,7 +2,6 @@ import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { useEventQueryFilters } from '@/hooks/use-event-query-filters'; import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import { useCookieStore } from '@/hooks/use-cookie-store';
import { useDashedStroke } from '@/hooks/use-dashed-stroke'; import { useDashedStroke } from '@/hooks/use-dashed-stroke';
import { useFormatDateInterval } from '@/hooks/use-format-date-interval'; import { useFormatDateInterval } from '@/hooks/use-format-date-interval';
import { useNumber } from '@/hooks/use-numer-formatter'; import { useNumber } from '@/hooks/use-numer-formatter';
@@ -13,24 +12,22 @@ import { getPreviousMetric } from '@openpanel/common';
import type { IInterval } from '@openpanel/validation'; import type { IInterval } from '@openpanel/validation';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { isSameDay, isSameHour, isSameMonth, isSameWeek } from 'date-fns'; import { isSameDay, isSameHour, isSameMonth, isSameWeek } from 'date-fns';
import { last, omit } from 'ramda'; import { last } from 'ramda';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { import {
Area,
Bar, Bar,
BarChart,
CartesianGrid, CartesianGrid,
Cell,
ComposedChart, ComposedChart,
Customized, Customized,
Line, Line,
LineChart,
ReferenceLine, ReferenceLine,
ResponsiveContainer, ResponsiveContainer,
XAxis, XAxis,
YAxis, YAxis,
} from 'recharts'; } from 'recharts';
import { useLocalStorage } from 'usehooks-ts';
import { createChartTooltip } from '../charts/chart-tooltip'; import { createChartTooltip } from '../charts/chart-tooltip';
import { BarShapeBlue, BarShapeGrey } from '../charts/common-bar';
import { useXAxisProps, useYAxisProps } from '../report-chart/common/axis'; import { useXAxisProps, useYAxisProps } from '../report-chart/common/axis';
import { PreviousDiffIndicatorPure } from '../report-chart/common/previous-diff-indicator'; import { PreviousDiffIndicatorPure } from '../report-chart/common/previous-diff-indicator';
import { Skeleton } from '../skeleton'; import { Skeleton } from '../skeleton';
@@ -78,6 +75,12 @@ const TITLES = [
unit: 'min', unit: 'min',
inverted: false, inverted: false,
}, },
{
title: 'Revenue',
key: 'total_revenue',
unit: 'currency',
inverted: false,
},
] as const; ] as const;
export default function OverviewMetrics({ projectId }: OverviewMetricsProps) { export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
@@ -86,11 +89,6 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
const [filters] = useEventQueryFilters(); const [filters] = useEventQueryFilters();
const trpc = useTRPC(); const trpc = useTRPC();
const [chartType, setChartType] = useCookieStore<'bars' | 'lines'>(
'chartType',
'bars',
);
const activeMetric = TITLES[metric]!; const activeMetric = TITLES[metric]!;
const overviewQuery = useQuery( const overviewQuery = useQuery(
trpc.overview.stats.queryOptions({ trpc.overview.stats.queryOptions({
@@ -125,6 +123,7 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
}} }}
unit={title.unit} unit={title.unit}
data={data.map((item) => ({ data={data.map((item) => ({
date: item.date,
current: item[title.key], current: item[title.key],
previous: item[`prev_${title.key}`], previous: item[`prev_${title.key}`],
}))} }))}
@@ -136,7 +135,7 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
<div <div
className={cn( className={cn(
'col-span-4 min-h-16 flex-1 p-4 pb-0 shadow-[0_0_0_0.5px] shadow-border max-md:row-start-1 md:col-span-2', 'col-span-4 min-h-16 flex-1 p-4 pb-0 shadow-[0_0_0_0.5px] shadow-border max-md:row-start-1 md:col-span-1',
)} )}
> >
<OverviewLiveHistogram projectId={projectId} /> <OverviewLiveHistogram projectId={projectId} />
@@ -148,32 +147,6 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
<div className="text-sm font-medium text-muted-foreground"> <div className="text-sm font-medium text-muted-foreground">
{activeMetric.title} {activeMetric.title}
</div> </div>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => setChartType('bars')}
className={cn(
'px-2 py-1 text-xs rounded transition-colors',
chartType === 'bars'
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground',
)}
>
Bars
</button>
<button
type="button"
onClick={() => setChartType('lines')}
className={cn(
'px-2 py-1 text-xs rounded transition-colors',
chartType === 'lines'
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground',
)}
>
Lines
</button>
</div>
</div> </div>
<div className="w-full h-[150px]"> <div className="w-full h-[150px]">
{overviewQuery.isLoading && <Skeleton className="h-full w-full" />} {overviewQuery.isLoading && <Skeleton className="h-full w-full" />}
@@ -181,7 +154,6 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
activeMetric={activeMetric} activeMetric={activeMetric}
interval={interval} interval={interval}
data={data} data={data}
chartType={chartType}
projectId={projectId} projectId={projectId}
/> />
</div> </div>
@@ -194,18 +166,25 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
const { Tooltip, TooltipProvider } = createChartTooltip< const { Tooltip, TooltipProvider } = createChartTooltip<
RouterOutputs['overview']['stats']['series'][number], RouterOutputs['overview']['stats']['series'][number],
{ {
anyMetric?: boolean;
metric: (typeof TITLES)[number]; metric: (typeof TITLES)[number];
interval: IInterval; interval: IInterval;
} }
>(({ context: { metric, interval }, data: dataArray }) => { >(({ context: { metric, interval, anyMetric }, data: dataArray }) => {
const data = dataArray[0]; const data = dataArray[0];
const formatDate = useFormatDateInterval(interval); const formatDate = useFormatDateInterval({
interval,
short: false,
});
const number = useNumber(); const number = useNumber();
if (!data) { if (!data) {
return null; return null;
} }
const revenue = data.total_revenue ?? 0;
const prevRevenue = data.prev_total_revenue ?? 0;
return ( return (
<> <>
<div className="flex justify-between gap-8 text-muted-foreground"> <div className="flex justify-between gap-8 text-muted-foreground">
@@ -215,16 +194,25 @@ const { Tooltip, TooltipProvider } = createChartTooltip<
<div className="flex gap-2"> <div className="flex gap-2">
<div <div
className="w-[3px] rounded-full" className="w-[3px] rounded-full"
style={{ background: getChartColor(0) }} style={{ background: anyMetric ? getChartColor(0) : '#3ba974' }}
/> />
<div className="col flex-1 gap-1"> <div className="col flex-1 gap-1">
<div className="flex items-center gap-1">{metric.title}</div> <div className="flex items-center gap-1">{metric.title}</div>
<div className="flex justify-between gap-8 font-mono font-medium"> <div className="flex justify-between gap-8 font-mono font-medium">
<div className="row gap-1"> <div className="row gap-1">
{number.formatWithUnit(data[metric.key])} {metric.unit === 'currency'
? number.currency((data[metric.key] ?? 0) / 100)
: number.formatWithUnit(data[metric.key], metric.unit)}
{!!data[`prev_${metric.key}`] && ( {!!data[`prev_${metric.key}`] && (
<span className="text-muted-foreground"> <span className="text-muted-foreground">
({number.formatWithUnit(data[`prev_${metric.key}`])}) (
{metric.unit === 'currency'
? number.currency((data[`prev_${metric.key}`] ?? 0) / 100)
: number.formatWithUnit(
data[`prev_${metric.key}`],
metric.unit,
)}
)
</span> </span>
)} )}
</div> </div>
@@ -238,6 +226,32 @@ const { Tooltip, TooltipProvider } = createChartTooltip<
</div> </div>
</div> </div>
</div> </div>
{anyMetric && revenue > 0 && (
<div className="flex gap-2 mt-2">
<div
className="w-[3px] rounded-full"
style={{ background: '#3ba974' }}
/>
<div className="col flex-1 gap-1">
<div className="flex items-center gap-1">Revenue</div>
<div className="flex justify-between gap-8 font-mono font-medium">
<div className="row gap-1">
{number.currency(revenue / 100)}
{prevRevenue > 0 && (
<span className="text-muted-foreground">
({number.currency(prevRevenue / 100)})
</span>
)}
</div>
{prevRevenue > 0 && (
<PreviousDiffIndicatorPure
{...getPreviousMetric(revenue, prevRevenue)}
/>
)}
</div>
</div>
</div>
)}
</React.Fragment> </React.Fragment>
</> </>
); );
@@ -247,17 +261,19 @@ function Chart({
activeMetric, activeMetric,
interval, interval,
data, data,
chartType,
projectId, projectId,
}: { }: {
activeMetric: (typeof TITLES)[number]; activeMetric: (typeof TITLES)[number];
interval: IInterval; interval: IInterval;
data: RouterOutputs['overview']['stats']['series']; data: RouterOutputs['overview']['stats']['series'];
chartType: 'bars' | 'lines';
projectId: string; projectId: string;
}) { }) {
const xAxisProps = useXAxisProps({ interval }); const xAxisProps = useXAxisProps({ interval });
const yAxisProps = useYAxisProps(); const yAxisProps = useYAxisProps();
const number = useNumber();
const revenueYAxisProps = useYAxisProps({
tickFormatter: (value) => number.short(value / 100),
});
const [activeBar, setActiveBar] = useState(-1); const [activeBar, setActiveBar] = useState(-1);
const { range, startDate, endDate } = useOverviewOptions(); const { range, startDate, endDate } = useOverviewOptions();
@@ -278,13 +294,11 @@ function Chart({
// Line chart specific logic // Line chart specific logic
let dotIndex = undefined; let dotIndex = undefined;
if (chartType === 'lines') { if (interval === 'hour') {
if (interval === 'hour') { // Find closest index based on times
// Find closest index based on times dotIndex = data.findIndex((item) => {
dotIndex = data.findIndex((item) => { return isSameHour(item.date, new Date());
return isSameHour(item.date, new Date()); });
});
}
} }
const { calcStrokeDasharray, handleAnimationEnd, getStrokeDasharray } = const { calcStrokeDasharray, handleAnimationEnd, getStrokeDasharray } =
@@ -294,6 +308,10 @@ function Chart({
const lastSerieDataItem = last(data)?.date || new Date(); const lastSerieDataItem = last(data)?.date || new Date();
const useDashedLastLine = (() => { const useDashedLastLine = (() => {
if (range === 'today') {
return true;
}
if (interval === 'hour') { if (interval === 'hour') {
return isSameHour(lastSerieDataItem, new Date()); return isSameHour(lastSerieDataItem, new Date());
} }
@@ -313,11 +331,11 @@ function Chart({
return false; return false;
})(); })();
if (chartType === 'lines') { if (activeMetric.key === 'total_revenue') {
return ( return (
<TooltipProvider metric={activeMetric} interval={interval}> <TooltipProvider metric={activeMetric} interval={interval}>
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<LineChart data={data}> <ComposedChart data={data}>
<Customized component={calcStrokeDasharray} /> <Customized component={calcStrokeDasharray} />
<Line <Line
dataKey="calcStrokeDasharray" dataKey="calcStrokeDasharray"
@@ -326,13 +344,8 @@ function Chart({
onAnimationEnd={handleAnimationEnd} onAnimationEnd={handleAnimationEnd}
/> />
<Tooltip /> <Tooltip />
<YAxis <YAxis {...yAxisProps} domain={[0, 'dataMax']} width={25} />
{...yAxisProps}
domain={[0, activeMetric.key === 'bounce_rate' ? 100 : 'dataMax']}
width={25}
/>
<XAxis {...xAxisProps} /> <XAxis {...xAxisProps} />
<CartesianGrid <CartesianGrid
strokeDasharray="3 3" strokeDasharray="3 3"
horizontal={true} horizontal={true}
@@ -340,10 +353,30 @@ function Chart({
className="stroke-border" className="stroke-border"
/> />
<defs>
<filter
id="rainbow-line-glow"
x="-20%"
y="-20%"
width="140%"
height="140%"
>
<feGaussianBlur stdDeviation="5" result="blur" />
<feComponentTransfer in="blur" result="dimmedBlur">
<feFuncA type="linear" slope="0.5" />
</feComponentTransfer>
<feComposite
in="SourceGraphic"
in2="dimmedBlur"
operator="over"
/>
</filter>
</defs>
<Line <Line
key={`prev_${activeMetric.key}`} key={'prev_total_revenue'}
type="linear" type="monotone"
dataKey={`prev_${activeMetric.key}`} dataKey={'prev_total_revenue'}
stroke={'oklch(from var(--foreground) l c h / 0.1)'} stroke={'oklch(from var(--foreground) l c h / 0.1)'}
strokeWidth={2} strokeWidth={2}
isAnimationActive={false} isAnimationActive={false}
@@ -352,24 +385,26 @@ function Chart({
? false ? false
: { : {
stroke: 'oklch(from var(--foreground) l c h / 0.1)', stroke: 'oklch(from var(--foreground) l c h / 0.1)',
fill: 'var(--def-100)', fill: 'transparent',
strokeWidth: 1.5, strokeWidth: 1.5,
r: 2, r: 2,
} }
} }
activeDot={{ activeDot={{
stroke: 'oklch(from var(--foreground) l c h / 0.2)', stroke: 'oklch(from var(--foreground) l c h / 0.2)',
fill: 'var(--def-100)', fill: 'transparent',
strokeWidth: 1.5, strokeWidth: 1.5,
r: 3, r: 3,
}} }}
/> />
<Line <Area
key={activeMetric.key} key={'total_revenue'}
type="linear" type="monotone"
dataKey={activeMetric.key} dataKey={'total_revenue'}
stroke={getChartColor(0)} stroke={'#3ba974'}
fill={'#3ba974'}
fillOpacity={0.05}
strokeWidth={2} strokeWidth={2}
strokeDasharray={ strokeDasharray={
useDashedLastLine useDashedLastLine
@@ -381,18 +416,19 @@ function Chart({
data.length > 90 data.length > 90
? false ? false
: { : {
stroke: getChartColor(0), stroke: '#3ba974',
fill: 'var(--def-100)', fill: '#3ba974',
strokeWidth: 1.5, strokeWidth: 1.5,
r: 3, r: 3,
} }
} }
activeDot={{ activeDot={{
stroke: getChartColor(0), stroke: '#3ba974',
fill: 'var(--def-100)', fill: 'var(--def-100)',
strokeWidth: 2, strokeWidth: 2,
r: 4, r: 4,
}} }}
filter="url(#rainbow-line-glow)"
/> />
{references.data?.map((ref) => ( {references.data?.map((ref) => (
@@ -410,36 +446,48 @@ function Chart({
fontSize={10} fontSize={10}
/> />
))} ))}
</LineChart> </ComposedChart>
</ResponsiveContainer> </ResponsiveContainer>
</TooltipProvider> </TooltipProvider>
); );
} }
// Bar chart (default)
return ( return (
<TooltipProvider metric={activeMetric} interval={interval}> <TooltipProvider metric={activeMetric} interval={interval} anyMetric={true}>
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<BarChart <ComposedChart
data={data} data={data}
margin={{ top: 0, right: 0, left: 0, bottom: 10 }}
onMouseMove={(e) => { onMouseMove={(e) => {
setActiveBar(e.activeTooltipIndex ?? -1); setActiveBar(e.activeTooltipIndex ?? -1);
}} }}
barCategoryGap={2}
> >
<Tooltip <Customized component={calcStrokeDasharray} />
cursor={{ <Line
stroke: 'var(--def-200)', dataKey="calcStrokeDasharray"
fill: 'var(--def-200)', legendType="none"
}} animationDuration={0}
onAnimationEnd={handleAnimationEnd}
/> />
<Tooltip />
<YAxis <YAxis
{...yAxisProps} {...yAxisProps}
domain={[0, activeMetric.key === 'bounce_rate' ? 100 : 'auto']} domain={[0, activeMetric.key === 'bounce_rate' ? 100 : 'dataMax']}
width={25} width={25}
/> />
<XAxis {...omit(['scale', 'type'], xAxisProps)} /> <YAxis
{...revenueYAxisProps}
yAxisId="right"
orientation="right"
domain={[
0,
data.reduce(
(max, item) => Math.max(max, item.total_revenue ?? 0),
0,
) * 2,
]}
width={30}
/>
<XAxis {...xAxisProps} />
<CartesianGrid <CartesianGrid
strokeDasharray="3 3" strokeDasharray="3 3"
@@ -448,21 +496,103 @@ function Chart({
className="stroke-border" className="stroke-border"
/> />
<Bar <defs>
<filter
id="rainbow-line-glow"
x="-20%"
y="-20%"
width="140%"
height="140%"
>
<feGaussianBlur stdDeviation="5" result="blur" />
<feComponentTransfer in="blur" result="dimmedBlur">
<feFuncA type="linear" slope="0.5" />
</feComponentTransfer>
<feComposite
in="SourceGraphic"
in2="dimmedBlur"
operator="over"
/>
</filter>
</defs>
<Line
key={`prev_${activeMetric.key}`} key={`prev_${activeMetric.key}`}
type="monotone"
dataKey={`prev_${activeMetric.key}`} dataKey={`prev_${activeMetric.key}`}
stroke={'oklch(from var(--foreground) l c h / 0.1)'}
strokeWidth={2}
isAnimationActive={false} isAnimationActive={false}
shape={(props: any) => ( dot={
<BarShapeGrey isActive={activeBar === props.index} {...props} /> data.length > 90
)} ? false
: {
stroke: 'oklch(from var(--foreground) l c h / 0.1)',
fill: 'transparent',
strokeWidth: 1.5,
r: 2,
}
}
activeDot={{
stroke: 'oklch(from var(--foreground) l c h / 0.2)',
fill: 'transparent',
strokeWidth: 1.5,
r: 3,
}}
/> />
<Bar <Bar
key={activeMetric.key} key="total_revenue"
dataKey={activeMetric.key} dataKey="total_revenue"
yAxisId="right"
stackId="revenue"
isAnimationActive={false} isAnimationActive={false}
shape={(props: any) => ( radius={5}
<BarShapeBlue isActive={activeBar === props.index} {...props} /> maxBarSize={20}
)} >
{data.map((item, index) => {
return (
<Cell
key={item.date}
className={cn(
index === activeBar
? 'fill-emerald-700/100'
: 'fill-emerald-700/80',
)}
/>
);
})}
</Bar>
<Area
key={activeMetric.key}
type="monotone"
dataKey={activeMetric.key}
stroke={getChartColor(0)}
fill={getChartColor(0)}
fillOpacity={0.05}
strokeWidth={2}
strokeDasharray={
useDashedLastLine
? getStrokeDasharray(activeMetric.key)
: undefined
}
isAnimationActive={false}
dot={
data.length > 90
? false
: {
stroke: getChartColor(0),
fill: 'transparent',
strokeWidth: 1.5,
r: 3,
}
}
activeDot={{
stroke: getChartColor(0),
fill: 'var(--def-100)',
strokeWidth: 2,
r: 4,
}}
filter="url(#rainbow-line-glow)"
/> />
{references.data?.map((ref) => ( {references.data?.map((ref) => (
@@ -480,7 +610,7 @@ function Chart({
fontSize={10} fontSize={10}
/> />
))} ))}
</BarChart> </ComposedChart>
</ResponsiveContainer> </ResponsiveContainer>
</TooltipProvider> </TooltipProvider>
); );

View File

@@ -45,8 +45,9 @@ export default function OverviewTopDevices({
projectId, projectId,
startDate, startDate,
endDate, endDate,
events: [ series: [
{ {
type: 'event',
segment: 'user', segment: 'user',
filters, filters,
id: 'A', id: 'A',
@@ -81,8 +82,9 @@ export default function OverviewTopDevices({
projectId, projectId,
startDate, startDate,
endDate, endDate,
events: [ series: [
{ {
type: 'event',
segment: 'user', segment: 'user',
filters, filters,
id: 'A', id: 'A',
@@ -120,8 +122,9 @@ export default function OverviewTopDevices({
projectId, projectId,
startDate, startDate,
endDate, endDate,
events: [ series: [
{ {
type: 'event',
segment: 'user', segment: 'user',
filters, filters,
id: 'A', id: 'A',
@@ -160,8 +163,9 @@ export default function OverviewTopDevices({
projectId, projectId,
startDate, startDate,
endDate, endDate,
events: [ series: [
{ {
type: 'event',
segment: 'user', segment: 'user',
filters, filters,
id: 'A', id: 'A',
@@ -199,8 +203,9 @@ export default function OverviewTopDevices({
projectId, projectId,
startDate, startDate,
endDate, endDate,
events: [ series: [
{ {
type: 'event',
segment: 'user', segment: 'user',
filters, filters,
id: 'A', id: 'A',
@@ -239,8 +244,9 @@ export default function OverviewTopDevices({
projectId, projectId,
startDate, startDate,
endDate, endDate,
events: [ series: [
{ {
type: 'event',
segment: 'user', segment: 'user',
filters, filters,
id: 'A', id: 'A',
@@ -278,8 +284,9 @@ export default function OverviewTopDevices({
projectId, projectId,
startDate, startDate,
endDate, endDate,
events: [ series: [
{ {
type: 'event',
segment: 'user', segment: 'user',
filters, filters,
id: 'A', id: 'A',

View File

@@ -37,8 +37,9 @@ export default function OverviewTopEvents({
projectId, projectId,
startDate, startDate,
endDate, endDate,
events: [ series: [
{ {
type: 'event',
segment: 'event', segment: 'event',
filters: [ filters: [
...filters, ...filters,
@@ -78,8 +79,9 @@ export default function OverviewTopEvents({
projectId, projectId,
startDate, startDate,
endDate, endDate,
events: [ series: [
{ {
type: 'event',
segment: 'event', segment: 'event',
filters: [...filters], filters: [...filters],
id: 'A', id: 'A',
@@ -112,8 +114,9 @@ export default function OverviewTopEvents({
projectId, projectId,
startDate, startDate,
endDate, endDate,
events: [ series: [
{ {
type: 'event',
segment: 'event', segment: 'event',
filters: [ filters: [
...filters, ...filters,

View File

@@ -146,8 +146,9 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
projectId, projectId,
startDate, startDate,
endDate, endDate,
events: [ series: [
{ {
type: 'event',
segment: 'event', segment: 'event',
filters, filters,
id: 'A', id: 'A',

View File

@@ -8,6 +8,43 @@ import { Skeleton } from '../skeleton';
import { Tooltiper } from '../ui/tooltip'; import { Tooltiper } from '../ui/tooltip';
import { WidgetTable, type Props as WidgetTableProps } from '../widget-table'; import { WidgetTable, type Props as WidgetTableProps } from '../widget-table';
function RevenuePieChart({ percentage }: { percentage: number }) {
const size = 16;
const strokeWidth = 2;
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const offset = circumference - percentage * circumference;
return (
<svg width={size} height={size} className="flex-shrink-0">
{/* Background circle */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
className="text-def-200"
/>
{/* Revenue arc */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="#3ba974"
strokeWidth={strokeWidth}
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap="round"
transform={`rotate(-90 ${size / 2} ${size / 2})`}
className="transition-all"
/>
</svg>
);
}
type Props<T> = WidgetTableProps<T> & { type Props<T> = WidgetTableProps<T> & {
getColumnPercentage: (item: T) => number; getColumnPercentage: (item: T) => number;
}; };
@@ -45,9 +82,7 @@ export const OverviewWidgetTable = <T,>({
index === 0 index === 0
? 'text-left w-full font-medium min-w-0' ? 'text-left w-full font-medium min-w-0'
: 'text-right font-mono', : 'text-right font-mono',
index !== 0 && // Remove old responsive logic - now handled by responsive prop
index !== columns.length - 1 &&
'hidden @[310px]:table-cell',
column.className, column.className,
), ),
}; };
@@ -119,12 +154,15 @@ export function OverviewWidgetTablePages({
avg_duration: number; avg_duration: number;
bounce_rate: number; bounce_rate: number;
sessions: number; sessions: number;
revenue: number;
}[]; }[];
showDomain?: boolean; showDomain?: boolean;
}) { }) {
const [_filters, setFilter] = useEventQueryFilters(); const [_filters, setFilter] = useEventQueryFilters();
const number = useNumber(); const number = useNumber();
const maxSessions = Math.max(...data.map((item) => item.sessions)); const maxSessions = Math.max(...data.map((item) => item.sessions));
const totalRevenue = data.reduce((sum, item) => sum + item.revenue, 0);
const hasRevenue = data.some((item) => item.revenue > 0);
return ( return (
<OverviewWidgetTable <OverviewWidgetTable
className={className} className={className}
@@ -135,6 +173,7 @@ export function OverviewWidgetTablePages({
{ {
name: 'Path', name: 'Path',
width: 'w-full', width: 'w-full',
responsive: { priority: 1 }, // Always visible
render(item) { render(item) {
return ( return (
<Tooltiper asChild content={item.origin + item.path} side="left"> <Tooltiper asChild content={item.origin + item.path} side="left">
@@ -178,6 +217,7 @@ export function OverviewWidgetTablePages({
{ {
name: 'BR', name: 'BR',
width: '60px', width: '60px',
responsive: { priority: 6 }, // Hidden when space is tight
render(item) { render(item) {
return number.shortWithUnit(item.bounce_rate, '%'); return number.shortWithUnit(item.bounce_rate, '%');
}, },
@@ -185,13 +225,41 @@ export function OverviewWidgetTablePages({
{ {
name: 'Duration', name: 'Duration',
width: '75px', width: '75px',
responsive: { priority: 7 }, // Hidden when space is tight
render(item) { render(item) {
return number.shortWithUnit(item.avg_duration, 'min'); return number.shortWithUnit(item.avg_duration, 'min');
}, },
}, },
...(hasRevenue
? [
{
name: 'Revenue',
width: '100px',
responsive: { priority: 3 }, // Always show if possible
render(item: (typeof data)[number]) {
const revenuePercentage =
totalRevenue > 0 ? item.revenue / totalRevenue : 0;
return (
<div className="row gap-2 items-center justify-end">
<span
className="font-semibold"
style={{ color: '#3ba974' }}
>
{item.revenue > 0
? number.currency(item.revenue / 100)
: '-'}
</span>
<RevenuePieChart percentage={revenuePercentage} />
</div>
);
},
} as const,
]
: []),
{ {
name: lastColumnName, name: lastColumnName,
width: '84px', width: '84px',
responsive: { priority: 2 }, // Always show if possible
render(item) { render(item) {
return ( return (
<div className="row gap-2 justify-end"> <div className="row gap-2 justify-end">
@@ -303,20 +371,24 @@ export function OverviewWidgetTableGeneric({
}) { }) {
const number = useNumber(); const number = useNumber();
const maxSessions = Math.max(...data.map((item) => item.sessions)); const maxSessions = Math.max(...data.map((item) => item.sessions));
const totalRevenue = data.reduce((sum, item) => sum + (item.revenue ?? 0), 0);
const hasRevenue = data.some((item) => (item.revenue ?? 0) > 0);
return ( return (
<OverviewWidgetTable <OverviewWidgetTable
className={className} className={className}
data={data ?? []} data={data ?? []}
keyExtractor={(item) => item.name} keyExtractor={(item) => item.prefix + item.name}
getColumnPercentage={(item) => item.sessions / maxSessions} getColumnPercentage={(item) => item.sessions / maxSessions}
columns={[ columns={[
{ {
...column, ...column,
width: 'w-full', width: 'w-full',
responsive: { priority: 1 }, // Always visible
}, },
{ {
name: 'BR', name: 'BR',
width: '60px', width: '60px',
responsive: { priority: 6 }, // Hidden when space is tight
render(item) { render(item) {
return number.shortWithUnit(item.bounce_rate, '%'); return number.shortWithUnit(item.bounce_rate, '%');
}, },
@@ -327,9 +399,38 @@ export function OverviewWidgetTableGeneric({
// return number.shortWithUnit(item.avg_session_duration, 'min'); // return number.shortWithUnit(item.avg_session_duration, 'min');
// }, // },
// }, // },
...(hasRevenue
? [
{
name: 'Revenue',
width: '100px',
responsive: { priority: 3 }, // Always show if possible
render(item: RouterOutputs['overview']['topGeneric'][number]) {
const revenue = item.revenue ?? 0;
const revenuePercentage =
totalRevenue > 0 ? revenue / totalRevenue : 0;
return (
<div className="row gap-2 items-center justify-end">
<span
className="font-semibold"
style={{ color: '#3ba974' }}
>
{revenue > 0
? number.currency(revenue / 100, { short: true })
: '-'}
</span>
<RevenuePieChart percentage={revenuePercentage} />
</div>
);
},
} as const,
]
: []),
{ {
name: 'Sessions', name: 'Sessions',
width: '84px', width: '84px',
responsive: { priority: 2 }, // Always show if possible
render(item) { render(item) {
return ( return (
<div className="row gap-2 justify-end"> <div className="row gap-2 justify-end">

View File

@@ -15,8 +15,9 @@ export const ProfileCharts = memo(
const pageViewsChart: IChartProps = { const pageViewsChart: IChartProps = {
projectId, projectId,
chartType: 'linear', chartType: 'linear',
events: [ series: [
{ {
type: 'event',
segment: 'event', segment: 'event',
filters: [ filters: [
{ {
@@ -48,8 +49,9 @@ export const ProfileCharts = memo(
const eventsChart: IChartProps = { const eventsChart: IChartProps = {
projectId, projectId,
chartType: 'linear', chartType: 'linear',
events: [ series: [
{ {
type: 'event',
segment: 'event', segment: 'event',
filters: [ filters: [
{ {

View File

@@ -12,72 +12,91 @@ const PROFILE_METRICS = [
key: 'totalEvents', key: 'totalEvents',
unit: '', unit: '',
inverted: false, inverted: false,
hideOnZero: false,
}, },
{ {
title: 'Sessions', title: 'Sessions',
key: 'sessions', key: 'sessions',
unit: '', unit: '',
inverted: false, inverted: false,
hideOnZero: false,
}, },
{ {
title: 'Page Views', title: 'Page Views',
key: 'screenViews', key: 'screenViews',
unit: '', unit: '',
inverted: false, inverted: false,
hideOnZero: false,
}, },
{ {
title: 'Avg Events/Session', title: 'Avg Events/Session',
key: 'avgEventsPerSession', key: 'avgEventsPerSession',
unit: '', unit: '',
inverted: false, inverted: false,
hideOnZero: false,
}, },
{ {
title: 'Bounce Rate', title: 'Bounce Rate',
key: 'bounceRate', key: 'bounceRate',
unit: '%', unit: '%',
inverted: true, inverted: true,
hideOnZero: false,
}, },
{ {
title: 'Session Duration (Avg)', title: 'Session Duration (Avg)',
key: 'durationAvg', key: 'durationAvg',
unit: 'min', unit: 'min',
inverted: false, inverted: false,
hideOnZero: false,
}, },
{ {
title: 'Session Duration (P90)', title: 'Session Duration (P90)',
key: 'durationP90', key: 'durationP90',
unit: 'min', unit: 'min',
inverted: false, inverted: false,
hideOnZero: false,
}, },
{ {
title: 'First seen', title: 'First seen',
key: 'firstSeen', key: 'firstSeen',
unit: 'timeAgo', unit: 'timeAgo',
inverted: false, inverted: false,
hideOnZero: false,
}, },
{ {
title: 'Last seen', title: 'Last seen',
key: 'lastSeen', key: 'lastSeen',
unit: 'timeAgo', unit: 'timeAgo',
inverted: false, inverted: false,
hideOnZero: false,
}, },
{ {
title: 'Days Active', title: 'Days Active',
key: 'uniqueDaysActive', key: 'uniqueDaysActive',
unit: '', unit: '',
inverted: false, inverted: false,
hideOnZero: false,
}, },
{ {
title: 'Conversion Events', title: 'Conversion Events',
key: 'conversionEvents', key: 'conversionEvents',
unit: '', unit: '',
inverted: false, inverted: false,
hideOnZero: false,
}, },
{ {
title: 'Avg Time Between Sessions (h)', title: 'Avg Time Between Sessions (h)',
key: 'avgTimeBetweenSessions', key: 'avgTimeBetweenSessions',
unit: 'min', unit: 'min',
inverted: false, inverted: false,
hideOnZero: false,
},
{
title: 'Revenue',
key: 'revenue',
unit: 'currency',
inverted: false,
hideOnZero: true,
}, },
] as const; ] as const;
@@ -85,7 +104,12 @@ export const ProfileMetrics = ({ data }: Props) => {
return ( return (
<div className="relative col-span-6 -m-4 mb-0 mt-0 md:m-0"> <div className="relative col-span-6 -m-4 mb-0 mt-0 md:m-0">
<div className="card grid grid-cols-2 overflow-hidden rounded-md md:grid-cols-4 lg:grid-cols-6"> <div className="card grid grid-cols-2 overflow-hidden rounded-md md:grid-cols-4 lg:grid-cols-6">
{PROFILE_METRICS.map((metric) => ( {PROFILE_METRICS.filter((metric) => {
if (metric.hideOnZero && data[metric.key] === 0) {
return false;
}
return true;
}).map((metric) => (
<OverviewMetricCard <OverviewMetricCard
key={metric.key} key={metric.key}
id={metric.key} id={metric.key}

View File

@@ -1,4 +1,4 @@
import { shortNumber } from '@/hooks/use-numer-formatter'; import { useNumber } from '@/hooks/use-numer-formatter';
import { useTRPC } from '@/integrations/trpc/react'; import { useTRPC } from '@/integrations/trpc/react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router'; import { Link } from '@tanstack/react-router';
@@ -7,11 +7,11 @@ import type { IServiceProject } from '@openpanel/db';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import { SettingsIcon, TrendingDownIcon, TrendingUpIcon } from 'lucide-react'; import { SettingsIcon, TrendingDownIcon, TrendingUpIcon } from 'lucide-react';
import { ChartSSR } from '../chart-ssr';
import { FadeIn } from '../fade-in'; import { FadeIn } from '../fade-in';
import { SerieIcon } from '../report-chart/common/serie-icon'; import { SerieIcon } from '../report-chart/common/serie-icon';
import { Skeleton } from '../skeleton'; import { Skeleton } from '../skeleton';
import { LinkButton } from '../ui/button'; import { LinkButton } from '../ui/button';
import { ProjectChart } from './project-chart';
export function ProjectCardRoot({ export function ProjectCardRoot({
children, children,
@@ -60,7 +60,7 @@ function ProjectCard({ id, domain, name, organizationId }: IServiceProject) {
</div> </div>
</div> </div>
<div className="-mx-4 aspect-[8/1] mb-4"> <div className="-mx-4 aspect-[8/1] mb-4">
<ProjectChart id={id} /> <ProjectChartOuter id={id} />
</div> </div>
<div className="flex flex-1 gap-4 h-9 md:h-4"> <div className="flex flex-1 gap-4 h-9 md:h-4">
<ProjectMetrics id={id} /> <ProjectMetrics id={id} />
@@ -77,7 +77,7 @@ function ProjectCard({ id, domain, name, organizationId }: IServiceProject) {
); );
} }
function ProjectChart({ id }: { id: string }) { function ProjectChartOuter({ id }: { id: string }) {
const trpc = useTRPC(); const trpc = useTRPC();
const { data } = useQuery( const { data } = useQuery(
trpc.chart.projectCard.queryOptions({ trpc.chart.projectCard.queryOptions({
@@ -87,7 +87,7 @@ function ProjectChart({ id }: { id: string }) {
return ( return (
<FadeIn className="h-full w-full"> <FadeIn className="h-full w-full">
<ChartSSR data={data?.chart || []} color={'blue'} /> <ProjectChart data={data?.chart || []} color={'blue'} />
</FadeIn> </FadeIn>
); );
} }
@@ -102,6 +102,7 @@ function Metric({ value, label }: { value: React.ReactNode; label: string }) {
} }
function ProjectMetrics({ id }: { id: string }) { function ProjectMetrics({ id }: { id: string }) {
const number = useNumber();
const trpc = useTRPC(); const trpc = useTRPC();
const { data } = useQuery( const { data } = useQuery(
trpc.chart.projectCard.queryOptions({ trpc.chart.projectCard.queryOptions({
@@ -138,16 +139,18 @@ function ProjectMetrics({ id }: { id: string }) {
} }
/> />
)} )}
{!!data?.metrics?.revenue && (
<Metric
label="Revenue"
value={number.currency(data?.metrics?.revenue / 100, {
short: true,
})}
/>
)}
</div> </div>
<Metric <Metric label="3M" value={number.short(data?.metrics?.months_3 ?? 0)} />
label="3M" <Metric label="30D" value={number.short(data?.metrics?.month ?? 0)} />
value={shortNumber('en')(data?.metrics?.months_3 ?? 0)} <Metric label="24H" value={number.short(data?.metrics?.day ?? 0)} />
/>
<Metric
label="30D"
value={shortNumber('en')(data?.metrics?.month ?? 0)}
/>
<Metric label="24H" value={shortNumber('en')(data?.metrics?.day ?? 0)} />
</FadeIn> </FadeIn>
); );
} }

View File

@@ -0,0 +1,215 @@
import {
ChartTooltipHeader,
ChartTooltipItem,
createChartTooltip,
} from '@/components/charts/chart-tooltip';
import { useNumber } from '@/hooks/use-numer-formatter';
import { cn } from '@/utils/cn';
import { getChartColor } from '@/utils/theme';
import { useState } from 'react';
import {
Bar,
Cell,
ComposedChart,
Line,
ResponsiveContainer,
XAxis,
YAxis,
} from 'recharts';
type ChartDataItem = {
value: number;
date: Date;
revenue: number;
timestamp: number;
};
const { Tooltip, TooltipProvider } = createChartTooltip<
ChartDataItem,
{
color: 'blue' | 'green' | 'red';
}
>(
({
context,
data: dataArray,
}: {
context: { color: 'blue' | 'green' | 'red' };
data: ChartDataItem[];
}) => {
const { color } = context;
const data = dataArray[0];
const number = useNumber();
if (!data) {
return null;
}
const getColorValue = () => {
if (color === 'green') return '#16a34a';
if (color === 'red') return '#dc2626';
return getChartColor(0);
};
const formatDate = (date: Date) => {
return new Intl.DateTimeFormat('en-GB', {
weekday: 'short',
day: '2-digit',
month: 'short',
}).format(date);
};
return (
<>
<ChartTooltipHeader>
<div className="text-muted-foreground">{formatDate(data.date)}</div>
</ChartTooltipHeader>
<ChartTooltipItem
color={getColorValue()}
innerClassName="row justify-between"
>
<div className="flex items-center gap-1">Sessions</div>
<div className="font-mono font-bold">{number.format(data.value)}</div>
</ChartTooltipItem>
{data.revenue > 0 && (
<ChartTooltipItem color="#3ba974">
<div className="flex items-center gap-1">Revenue</div>
<div className="font-mono font-medium">
{number.currency(data.revenue / 100)}
</div>
</ChartTooltipItem>
)}
</>
);
},
);
export function ProjectChart({
data,
dots = false,
color = 'blue',
}: {
dots?: boolean;
color?: 'blue' | 'green' | 'red';
data: { value: number; date: Date; revenue: number }[];
}) {
const [activeBar, setActiveBar] = useState(-1);
if (data.length === 0) {
return null;
}
// Transform data for Recharts (needs timestamp for time-based x-axis)
const chartData = data.map((item) => ({
...item,
timestamp: item.date.getTime(),
}));
const maxValue = Math.max(...data.map((d) => d.value), 0);
const maxRevenue = Math.max(...data.map((d) => d.revenue), 0);
const getColorValue = () => {
if (color === 'green') return '#16a34a';
if (color === 'red') return '#dc2626';
return getChartColor(0);
};
return (
<div className="relative h-full w-full">
<TooltipProvider color={color}>
<ResponsiveContainer width="100%" height="100%">
<ComposedChart
data={chartData}
margin={{ top: 10, right: 10, bottom: 10, left: 10 }}
onMouseMove={(e) => {
setActiveBar(e.activeTooltipIndex ?? -1);
}}
>
<XAxis
dataKey="timestamp"
type="number"
scale="time"
domain={['dataMin', 'dataMax']}
hide
/>
<YAxis domain={[0, maxValue || 'dataMax']} hide width={0} />
<YAxis
yAxisId="right"
orientation="right"
domain={[0, maxRevenue * 2 || 'dataMax']}
hide
width={0}
/>
<Tooltip />
<defs>
<filter
id="rainbow-line-glow"
x="-20%"
y="-20%"
width="140%"
height="140%"
>
<feGaussianBlur stdDeviation="5" result="blur" />
<feComponentTransfer in="blur" result="dimmedBlur">
<feFuncA type="linear" slope="0.5" />
</feComponentTransfer>
<feComposite
in="SourceGraphic"
in2="dimmedBlur"
operator="over"
/>
</filter>
</defs>
<Line
type="monotone"
dataKey="value"
stroke={getColorValue()}
strokeWidth={2}
isAnimationActive={false}
dot={
dots && data.length <= 90
? {
stroke: getColorValue(),
fill: 'transparent',
strokeWidth: 1.5,
r: 3,
}
: false
}
activeDot={{
stroke: getColorValue(),
fill: 'var(--def-100)',
strokeWidth: 2,
r: 4,
}}
filter="url(#rainbow-line-glow)"
/>
<Bar
dataKey="revenue"
yAxisId="right"
stackId="revenue"
isAnimationActive={false}
radius={5}
maxBarSize={20}
>
{chartData.map((item, index) => (
<Cell
key={item.timestamp}
className={cn(
index === activeBar
? 'fill-emerald-700/100'
: 'fill-emerald-700/80',
)}
/>
))}
</Bar>
</ComposedChart>
</ResponsiveContainer>
</TooltipProvider>
</div>
);
}

View File

@@ -75,7 +75,7 @@ export function RealtimeGeo({ projectId }: RealtimeGeoProps) {
}, },
{ {
name: 'Events', name: 'Events',
width: '84px', width: '60px',
render(item) { render(item) {
return ( return (
<div className="row gap-2 justify-end"> <div className="row gap-2 justify-end">
@@ -86,6 +86,19 @@ export function RealtimeGeo({ projectId }: RealtimeGeoProps) {
); );
}, },
}, },
{
name: 'Sessions',
width: '82px',
render(item) {
return (
<div className="row gap-2 justify-end">
<span className="font-semibold">
{number.short(item.unique_sessions)}
</span>
</div>
);
},
},
]} ]}
/> />
</div> </div>

View File

@@ -1,126 +1,99 @@
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useTRPC } from '@/integrations/trpc/react'; import { useTRPC } from '@/integrations/trpc/react';
import { cn } from '@/utils/cn';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import type { IChartProps } from '@openpanel/validation'; import { useNumber } from '@/hooks/use-numer-formatter';
import { getChartColor } from '@/utils/theme';
import * as Portal from '@radix-ui/react-portal';
import { bind } from 'bind-event-listener';
import throttle from 'lodash.throttle';
import React, { useEffect, useState } from 'react';
import {
Bar,
BarChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { AnimatedNumber } from '../animated-number'; import { AnimatedNumber } from '../animated-number';
import { BarShapeBlue } from '../charts/common-bar';
import { SerieIcon } from '../report-chart/common/serie-icon';
interface RealtimeLiveHistogramProps { interface RealtimeLiveHistogramProps {
projectId: string; projectId: string;
} }
export const getReport = (projectId: string): IChartProps => {
return {
projectId,
events: [
{
segment: 'user',
filters: [],
name: '*',
displayName: 'Active users',
},
],
chartType: 'histogram',
interval: 'minute',
range: '30min',
name: '',
metric: 'sum',
breakdowns: [],
lineType: 'monotone',
previous: false,
};
};
export const getCountReport = (projectId: string): IChartProps => {
return {
name: '',
projectId,
events: [
{
segment: 'user',
filters: [],
id: 'A',
name: 'session_start',
},
],
breakdowns: [],
chartType: 'metric',
lineType: 'monotone',
interval: 'minute',
range: '30min',
previous: false,
metric: 'sum',
};
};
export function RealtimeLiveHistogram({ export function RealtimeLiveHistogram({
projectId, projectId,
}: RealtimeLiveHistogramProps) { }: RealtimeLiveHistogramProps) {
const report = getReport(projectId);
const countReport = getCountReport(projectId);
const trpc = useTRPC(); const trpc = useTRPC();
const res = useQuery(trpc.chart.chart.queryOptions(report));
const countRes = useQuery(trpc.chart.chart.queryOptions(countReport));
const metrics = res.data?.series[0]?.metrics; // Use the same liveData endpoint as overview
const minutes = (res.data?.series[0]?.data || []).slice(-30); const { data: liveData, isLoading } = useQuery(
const liveCount = countRes.data?.series[0]?.metrics?.sum ?? 0; trpc.overview.liveData.queryOptions({ projectId }),
);
if (res.isInitialLoading || countRes.isInitialLoading || liveCount === 0) { const chartData = liveData?.minuteCounts ?? [];
const staticArray = [ // Calculate total unique visitors (sum of unique visitors per minute)
10, 25, 30, 45, 20, 5, 55, 18, 40, 12, 50, 35, 8, 22, 38, 42, 15, 28, 52, // Note: This is an approximation - ideally we'd want unique visitors across all minutes
5, 48, 14, 32, 58, 7, 19, 33, 56, 24, 5, const totalVisitors = liveData?.totalSessions ?? 0;
];
if (isLoading) {
return ( return (
<Wrapper count={0}> <Wrapper count={0}>
{staticArray.map((percent, i) => ( <div className="h-full w-full animate-pulse bg-def-200 rounded" />
<div
key={i as number}
className="flex-1 animate-pulse rounded-sm bg-def-200"
style={{ height: `${percent}%` }}
/>
))}
</Wrapper> </Wrapper>
); );
} }
if (!res.isSuccess && !countRes.isSuccess) { if (!liveData) {
return null; return null;
} }
const maxDomain =
Math.max(...chartData.map((item) => item.visitorCount), 0) * 1.2 || 1;
return ( return (
<Wrapper count={liveCount}> <Wrapper
{minutes.map((minute) => { count={totalVisitors}
return ( icons={
<Tooltip key={minute.date}> liveData.referrers && liveData.referrers.length > 0 ? (
<TooltipTrigger asChild> <div className="row gap-2">
{liveData.referrers.slice(0, 3).map((ref, index) => (
<div <div
className={cn( key={`${ref.referrer}-${ref.count}-${index}`}
'flex-1 rounded-sm transition-all ease-in-out hover:scale-110', className="font-bold text-xs row gap-1 items-center"
minute.count === 0 ? 'bg-def-200' : 'bg-highlight', >
)} <SerieIcon name={ref.referrer} />
style={{ <span>{ref.count}</span>
height: </div>
minute.count === 0 ))}
? '20%' </div>
: `${(minute.count / metrics!.max) * 100}%`, ) : null
}} }
/> >
</TooltipTrigger> <ResponsiveContainer width="100%" height="100%">
<TooltipContent side="top"> <BarChart
<div>{minute.count} active users</div> data={chartData}
<div>@ {new Date(minute.date).toLocaleTimeString()}</div> margin={{ top: 0, right: 0, left: 0, bottom: 0 }}
</TooltipContent> >
</Tooltip> <Tooltip
); content={CustomTooltip}
})} cursor={{
fill: 'var(--def-200)',
}}
/>
<XAxis dataKey="time" axisLine={false} tickLine={false} hide />
<YAxis hide domain={[0, maxDomain]} />
<Bar
dataKey="visitorCount"
fill="rgba(59, 121, 255, 0.2)"
isAnimationActive={false}
shape={BarShapeBlue}
activeBar={BarShapeBlue}
/>
</BarChart>
</ResponsiveContainer>
</Wrapper> </Wrapper>
); );
} }
@@ -128,22 +101,144 @@ export function RealtimeLiveHistogram({
interface WrapperProps { interface WrapperProps {
children: React.ReactNode; children: React.ReactNode;
count: number; count: number;
icons?: React.ReactNode;
} }
function Wrapper({ children, count }: WrapperProps) { function Wrapper({ children, count, icons }: WrapperProps) {
return ( return (
<div className="flex flex-col"> <div className="flex flex-col">
<div className="col gap-2 p-4"> <div className="row gap-2 justify-between mb-2">
<div className="font-medium text-muted-foreground"> <div className="relative text-sm font-medium text-muted-foreground leading-normal">
Unique vistors last 30 minutes Unique visitors {icons ? <br /> : null}
last 30 min
</div> </div>
<div>{icons}</div>
</div>
<div className="col gap-2 mb-4">
<div className="font-mono text-6xl font-bold"> <div className="font-mono text-6xl font-bold">
<AnimatedNumber value={count} /> <AnimatedNumber value={count} />
</div> </div>
</div> </div>
<div className="relative flex aspect-[6/1] w-full flex-1 items-end gap-0.5"> <div className="relative aspect-[6/1] w-full">{children}</div>
{children}
</div>
</div> </div>
); );
} }
// Custom tooltip component that uses portals to escape overflow hidden
const CustomTooltip = ({ active, payload, coordinate }: any) => {
const number = useNumber();
const [position, setPosition] = useState<{ x: number; y: number } | null>(
null,
);
const inactive = !active || !payload?.length;
useEffect(() => {
const setPositionThrottled = throttle(setPosition, 50);
const unsubMouseMove = bind(window, {
type: 'mousemove',
listener(event) {
if (!inactive) {
setPositionThrottled({ x: event.clientX, y: event.clientY + 20 });
}
},
});
const unsubDragEnter = bind(window, {
type: 'pointerdown',
listener() {
setPosition(null);
},
});
return () => {
unsubMouseMove();
unsubDragEnter();
};
}, [inactive]);
if (inactive) {
return null;
}
if (!active || !payload || !payload.length) {
return null;
}
const data = payload[0].payload;
const tooltipWidth = 200;
const correctXPosition = (x: number | undefined) => {
if (!x) {
return undefined;
}
const screenWidth = window.innerWidth;
const newX = x;
if (newX + tooltipWidth > screenWidth) {
return screenWidth - tooltipWidth;
}
return newX;
};
return (
<Portal.Portal
style={{
position: 'fixed',
top: position?.y,
left: correctXPosition(position?.x),
zIndex: 1000,
width: tooltipWidth,
}}
className="bg-background/80 p-3 rounded-md border shadow-xl backdrop-blur-sm"
>
<div className="flex justify-between gap-8 text-muted-foreground">
<div>{data.time}</div>
</div>
<React.Fragment>
<div className="flex gap-2">
<div
className="w-[3px] rounded-full"
style={{ background: getChartColor(0) }}
/>
<div className="col flex-1 gap-1">
<div className="flex items-center gap-1">Active users</div>
<div className="flex justify-between gap-8 font-mono font-medium">
<div className="row gap-1">
{number.formatWithUnit(data.visitorCount)}
</div>
</div>
</div>
</div>
{data.referrers && data.referrers.length > 0 && (
<div className="mt-2 pt-2 border-t border-border">
<div className="text-xs text-muted-foreground mb-2">Referrers:</div>
<div className="space-y-1">
{data.referrers.slice(0, 3).map((ref: any, index: number) => (
<div
key={`${ref.referrer}-${ref.count}-${index}`}
className="row items-center justify-between text-xs"
>
<div className="row items-center gap-1">
<SerieIcon name={ref.referrer} />
<span
className="truncate max-w-[120px]"
title={ref.referrer}
>
{ref.referrer}
</span>
</div>
<span className="font-mono">{ref.count}</span>
</div>
))}
{data.referrers.length > 3 && (
<div className="text-xs text-muted-foreground">
+{data.referrers.length - 3} more
</div>
)}
</div>
</div>
)}
</React.Fragment>
</Portal.Portal>
);
};

View File

@@ -82,7 +82,7 @@ export function RealtimePaths({ projectId }: RealtimePathsProps) {
}, },
{ {
name: 'Events', name: 'Events',
width: '84px', width: '60px',
render(item) { render(item) {
return ( return (
<div className="row gap-2 justify-end"> <div className="row gap-2 justify-end">
@@ -93,6 +93,19 @@ export function RealtimePaths({ projectId }: RealtimePathsProps) {
); );
}, },
}, },
{
name: 'Sessions',
width: '82px',
render(item) {
return (
<div className="row gap-2 justify-end">
<span className="font-semibold">
{number.short(item.unique_sessions)}
</span>
</div>
);
},
},
]} ]}
/> />
</div> </div>

View File

@@ -65,7 +65,7 @@ export function RealtimeReferrals({ projectId }: RealtimeReferralsProps) {
}, },
{ {
name: 'Events', name: 'Events',
width: '84px', width: '60px',
render(item) { render(item) {
return ( return (
<div className="row gap-2 justify-end"> <div className="row gap-2 justify-end">
@@ -76,6 +76,19 @@ export function RealtimeReferrals({ projectId }: RealtimeReferralsProps) {
); );
}, },
}, },
{
name: 'Sessions',
width: '82px',
render(item) {
return (
<div className="row gap-2 justify-end">
<span className="font-semibold">
{number.short(item.unique_sessions)}
</span>
</div>
);
},
},
]} ]}
/> />
</div> </div>

View File

@@ -1,7 +1,6 @@
import useWS from '@/hooks/use-ws'; import useWS from '@/hooks/use-ws';
import { useTRPC } from '@/integrations/trpc/react'; import { useTRPC } from '@/integrations/trpc/react';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { getCountReport, getReport } from './realtime-live-histogram';
type Props = { type Props = {
projectId: string; projectId: string;
@@ -17,11 +16,15 @@ const RealtimeReloader = ({ projectId }: Props) => {
if (!document.hidden) { if (!document.hidden) {
client.refetchQueries(trpc.realtime.pathFilter()); client.refetchQueries(trpc.realtime.pathFilter());
client.refetchQueries( client.refetchQueries(
trpc.chart.chart.queryFilter(getReport(projectId)), trpc.overview.liveData.queryFilter({ projectId }),
); );
client.refetchQueries( client.refetchQueries(
trpc.chart.chart.queryFilter(getCountReport(projectId)), trpc.realtime.activeSessions.queryFilter({ projectId }),
); );
client.refetchQueries(
trpc.realtime.referrals.queryFilter({ projectId }),
);
client.refetchQueries(trpc.realtime.paths.queryFilter({ projectId }));
} }
}, },
{ {

View File

@@ -7,6 +7,7 @@ import { cn } from '@/utils/cn';
import { getChartColor } from '@/utils/theme'; import { getChartColor } from '@/utils/theme';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { isSameDay, isSameHour, isSameMonth, isSameWeek } from 'date-fns'; import { isSameDay, isSameHour, isSameMonth, isSameWeek } from 'date-fns';
import { BookmarkIcon, UsersIcon } from 'lucide-react';
import { last } from 'ramda'; import { last } from 'ramda';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { import {
@@ -25,6 +26,10 @@ import {
import { useDashedStroke } from '@/hooks/use-dashed-stroke'; import { useDashedStroke } from '@/hooks/use-dashed-stroke';
import { useXAxisProps, useYAxisProps } from '../common/axis'; import { useXAxisProps, useYAxisProps } from '../common/axis';
import {
ChartClickMenu,
type ChartClickMenuItem,
} from '../common/chart-click-menu';
import { ReportChartTooltip } from '../common/report-chart-tooltip'; import { ReportChartTooltip } from '../common/report-chart-tooltip';
import { ReportTable } from '../common/report-table'; import { ReportTable } from '../common/report-table';
import { SerieIcon } from '../common/serie-icon'; import { SerieIcon } from '../common/serie-icon';
@@ -45,6 +50,8 @@ export function Chart({ data }: Props) {
endDate, endDate,
range, range,
lineType, lineType,
series: reportSeries,
breakdowns,
}, },
isEditMode, isEditMode,
options: { hideXAxis, hideYAxis }, options: { hideXAxis, hideYAxis },
@@ -126,16 +133,66 @@ export function Chart({ data }: Props) {
interval, interval,
}); });
const handleChartClick = useCallback((e: any) => { const getMenuItems = useCallback(
if (e?.activePayload?.[0]) { (e: any, clickedData: any): ChartClickMenuItem[] => {
const clickedData = e.activePayload[0].payload; const items: ChartClickMenuItem[] = [];
if (clickedData.date) {
pushModal('AddReference', { if (!clickedData?.date) {
datetime: new Date(clickedData.date).toISOString(), return items;
}
// View Users - only show if we have projectId
if (projectId) {
items.push({
label: 'View Users',
icon: <UsersIcon size={16} />,
onClick: () => {
pushModal('ViewChartUsers', {
type: 'chart',
chartData: data,
report: {
projectId,
series: reportSeries,
breakdowns: breakdowns || [],
interval,
startDate,
endDate,
range,
previous,
chartType: 'area',
metric: 'sum',
},
date: clickedData.date,
});
},
}); });
} }
}
}, []); // Add Reference - always show
items.push({
label: 'Add Reference',
icon: <BookmarkIcon size={16} />,
onClick: () => {
pushModal('AddReference', {
datetime: new Date(clickedData.date).toISOString(),
});
},
});
return items;
},
[
projectId,
data,
reportSeries,
breakdowns,
interval,
startDate,
endDate,
range,
previous,
],
);
const { getStrokeDasharray, calcStrokeDasharray, handleAnimationEnd } = const { getStrokeDasharray, calcStrokeDasharray, handleAnimationEnd } =
useDashedStroke({ useDashedStroke({
@@ -144,106 +201,114 @@ export function Chart({ data }: Props) {
return ( return (
<ReportChartTooltip.TooltipProvider references={references.data}> <ReportChartTooltip.TooltipProvider references={references.data}>
<div className={cn('h-full w-full', isEditMode && 'card p-4')}> <ChartClickMenu getMenuItems={getMenuItems}>
<ResponsiveContainer> <div className={cn('h-full w-full', isEditMode && 'card p-4')}>
<ComposedChart data={rechartData} onClick={handleChartClick}> <ResponsiveContainer>
<Customized component={calcStrokeDasharray} /> <ComposedChart data={rechartData}>
<Line <Customized component={calcStrokeDasharray} />
dataKey="calcStrokeDasharray" <Line
legendType="none" dataKey="calcStrokeDasharray"
animationDuration={0} legendType="none"
onAnimationEnd={handleAnimationEnd} animationDuration={0}
/> onAnimationEnd={handleAnimationEnd}
<CartesianGrid
strokeDasharray="3 3"
horizontal={true}
vertical={false}
className="stroke-border"
/>
{references.data?.map((ref) => (
<ReferenceLine
key={ref.id}
x={ref.date.getTime()}
stroke={'oklch(from var(--foreground) l c h / 0.1)'}
strokeDasharray={'3 3'}
label={{
value: ref.title,
position: 'centerTop',
fill: '#334155',
fontSize: 12,
}}
fontSize={10}
/> />
))} <CartesianGrid
<YAxis {...yAxisProps} /> strokeDasharray="3 3"
<XAxis {...xAxisProps} /> horizontal={true}
<Legend content={<CustomLegend />} /> vertical={false}
<Tooltip content={<ReportChartTooltip.Tooltip />} /> className="stroke-border"
{series.map((serie) => { />
const color = getChartColor(serie.index); {references.data?.map((ref) => (
return ( <ReferenceLine
<defs key={`defs-${serie.id}`}> key={ref.id}
<linearGradient x={ref.date.getTime()}
id={`color${color}`} stroke={'oklch(from var(--foreground) l c h / 0.1)'}
x1="0" strokeDasharray={'3 3'}
y1="0" label={{
x2="0" value: ref.title,
y2="1" position: 'centerTop',
> fill: '#334155',
<stop offset="0%" stopColor={color} stopOpacity={0.8} /> fontSize: 12,
<stop offset={'100%'} stopColor={color} stopOpacity={0.1} /> }}
</linearGradient> fontSize={10}
</defs>
);
})}
{series.map((serie) => {
const color = getChartColor(serie.index);
return (
<Area
key={serie.id}
stackId="1"
type={lineType}
name={serie.id}
dataKey={`${serie.id}:count`}
strokeDasharray={
useDashedLastLine
? getStrokeDasharray(`${serie.id}:count`)
: undefined
}
fill={`url(#color${color})`}
isAnimationActive={false}
fillOpacity={0.7}
/> />
); ))}
})} <YAxis {...yAxisProps} />
{previous && <XAxis {...xAxisProps} />
series.map((serie) => { <Legend content={<CustomLegend />} />
<Tooltip content={<ReportChartTooltip.Tooltip />} />
{series.map((serie) => {
const color = getChartColor(serie.index);
return (
<defs key={`defs-${serie.id}`}>
<linearGradient
id={`color${color}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop offset="0%" stopColor={color} stopOpacity={0.8} />
<stop
offset={'100%'}
stopColor={color}
stopOpacity={0.1}
/>
</linearGradient>
</defs>
);
})}
{series.map((serie) => {
const color = getChartColor(serie.index); const color = getChartColor(serie.index);
return ( return (
<Area <Area
key={`${serie.id}:prev`} key={serie.id}
stackId="2" stackId="1"
type={lineType} type={lineType}
name={`${serie.id}:prev`} name={serie.id}
dataKey={`${serie.id}:prev:count`} dataKey={`${serie.id}:count`}
strokeDasharray={
useDashedLastLine
? getStrokeDasharray(`${serie.id}:count`)
: undefined
}
fill={`url(#color${color})`}
stroke={color} stroke={color}
fill={color} strokeWidth={2}
fillOpacity={0.3}
strokeOpacity={0.3}
isAnimationActive={false} isAnimationActive={false}
fillOpacity={0.7}
/> />
); );
})} })}
</ComposedChart> {previous &&
</ResponsiveContainer> series.map((serie) => {
</div> const color = getChartColor(serie.index);
{isEditMode && ( return (
<ReportTable <Area
data={data} key={`${serie.id}:prev`}
visibleSeries={series} stackId="2"
setVisibleSeries={setVisibleSeries} type={lineType}
/> name={`${serie.id}:prev`}
)} dataKey={`${serie.id}:prev:count`}
stroke={color}
fill={color}
fillOpacity={0.3}
strokeOpacity={0.3}
isAnimationActive={false}
/>
);
})}
</ComposedChart>
</ResponsiveContainer>
</div>
{isEditMode && (
<ReportTable
data={data}
visibleSeries={series}
setVisibleSeries={setVisibleSeries}
/>
)}
</ChartClickMenu>
</ReportChartTooltip.TooltipProvider> </ReportChartTooltip.TooltipProvider>
); );
} }

View File

@@ -6,7 +6,6 @@ import { useRef, useState } from 'react';
import type { AxisDomain } from 'recharts/types/util/types'; import type { AxisDomain } from 'recharts/types/util/types';
import type { IInterval } from '@openpanel/validation'; import type { IInterval } from '@openpanel/validation';
export const AXIS_FONT_PROPS = { export const AXIS_FONT_PROPS = {
fontSize: 8, fontSize: 8,
className: 'font-mono', className: 'font-mono',
@@ -69,9 +68,11 @@ export const useXAxisProps = (
interval: 'auto', interval: 'auto',
}, },
) => { ) => {
const formatDate = useFormatDateInterval( const formatDate = useFormatDateInterval({
interval === 'auto' ? 'day' : interval, interval: interval === 'auto' ? 'day' : interval,
); short: true,
});
return { return {
...X_AXIS_STYLE_PROPS, ...X_AXIS_STYLE_PROPS,
height: hide ? 0 : X_AXIS_STYLE_PROPS.height, height: hide ? 0 : X_AXIS_STYLE_PROPS.height,

View File

@@ -0,0 +1,263 @@
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import React, {
forwardRef,
useCallback,
useImperativeHandle,
useRef,
useState,
} from 'react';
export interface ChartClickMenuItem {
label: string;
icon?: React.ReactNode;
onClick: () => void;
disabled?: boolean;
}
interface ChartClickMenuProps {
children: React.ReactNode;
/**
* Function that receives the click event and clicked data, returns menu items
* This allows conditional menu items based on what was clicked
*/
getMenuItems: (e: any, clickedData: any) => ChartClickMenuItem[];
/**
* Optional callback when menu closes
*/
onClose?: () => void;
}
export interface ChartClickMenuHandle {
setPosition: (position: { x: number; y: number } | null) => void;
getContainerElement: () => HTMLDivElement | null;
}
/**
* Reusable component for handling chart clicks and showing a dropdown menu
* Wraps the chart and handles click position tracking and dropdown positioning
*/
export const ChartClickMenu = forwardRef<
ChartClickMenuHandle,
ChartClickMenuProps
>(({ children, getMenuItems, onClose }, ref) => {
const containerRef = useRef<HTMLDivElement>(null);
const [clickPosition, setClickPosition] = useState<{
x: number;
y: number;
} | null>(null);
const [clickedData, setClickedData] = useState<any>(null);
const [clickEvent, setClickEvent] = useState<any>(null);
const handleChartClick = useCallback((e: any) => {
if (e?.activePayload?.[0] && containerRef.current) {
const payload = e.activePayload[0].payload;
// Calculate click position relative to chart container
const containerRect = containerRef.current.getBoundingClientRect();
// Try to get viewport coordinates from the event
// Recharts passes nativeEvent with clientX/clientY (viewport coordinates)
let clientX = 0;
let clientY = 0;
if (
e.nativeEvent?.clientX !== undefined &&
e.nativeEvent?.clientY !== undefined
) {
// Best case: use nativeEvent client coordinates (viewport coordinates)
clientX = e.nativeEvent.clientX;
clientY = e.nativeEvent.clientY;
} else if (e.clientX !== undefined && e.clientY !== undefined) {
// Fallback: use event's clientX/Y directly
clientX = e.clientX;
clientY = e.clientY;
} else if (e.activeCoordinate) {
// Last resort: activeCoordinate is SVG-relative, need to find SVG element
// and convert to viewport coordinates
const svgElement = containerRef.current.querySelector('svg');
if (svgElement) {
const svgRect = svgElement.getBoundingClientRect();
clientX = svgRect.left + (e.activeCoordinate.x ?? 0);
clientY = svgRect.top + (e.activeCoordinate.y ?? 0);
} else {
// If no SVG found, use container position + activeCoordinate
clientX = containerRect.left + (e.activeCoordinate.x ?? 0);
clientY = containerRect.top + (e.activeCoordinate.y ?? 0);
}
}
setClickedData(payload);
setClickEvent(e); // Store the full event
setClickPosition({
x: clientX - containerRect.left,
y: clientY - containerRect.top,
});
}
}, []);
const menuItems =
clickedData && clickEvent ? getMenuItems(clickEvent, clickedData) : [];
const handleItemClick = useCallback(
(item: ChartClickMenuItem) => {
item.onClick();
setClickPosition(null);
setClickedData(null);
setClickEvent(null);
if (onClose) {
onClose();
}
},
[onClose],
);
const handleOpenChange = useCallback(
(open: boolean) => {
if (!open) {
setClickPosition(null);
setClickedData(null);
setClickEvent(null);
if (onClose) {
onClose();
}
}
},
[onClose],
);
// Expose methods via ref (for advanced use cases)
useImperativeHandle(
ref,
() => ({
setPosition: (position: { x: number; y: number } | null) => {
setClickPosition(position);
},
getContainerElement: () => containerRef.current,
}),
[],
);
// Clone children and add onClick handler to chart components
const chartWithClickHandler = React.useMemo(() => {
const addClickHandler = (node: React.ReactNode): React.ReactNode => {
// Handle null, undefined, strings, numbers
if (!React.isValidElement(node)) {
return node;
}
// Check if this is a chart component
const componentName =
(node.type as any)?.displayName || (node.type as any)?.name;
const isChartComponent =
componentName === 'ComposedChart' ||
componentName === 'LineChart' ||
componentName === 'BarChart' ||
componentName === 'AreaChart' ||
componentName === 'PieChart' ||
componentName === 'ResponsiveContainer';
// Process children recursively - handle arrays, fragments, and single elements
const processChildren = (children: React.ReactNode): React.ReactNode => {
if (children == null) {
return children;
}
// Handle arrays
if (Array.isArray(children)) {
return children.map(addClickHandler);
}
// Handle React fragments
if (
React.isValidElement(children) &&
children.type === React.Fragment
) {
const fragmentElement = children as React.ReactElement<{
children?: React.ReactNode;
}>;
return React.cloneElement(fragmentElement, {
children: processChildren(fragmentElement.props.children),
});
}
// Recursively process single child
return addClickHandler(children);
};
const element = node as React.ReactElement<{
children?: React.ReactNode;
onClick?: (e: any) => void;
}>;
if (isChartComponent) {
// For ResponsiveContainer, we need to add onClick to its child (ComposedChart, etc.)
if (componentName === 'ResponsiveContainer') {
return React.cloneElement(element, {
children: processChildren(element.props.children),
});
}
// For chart components, add onClick directly
return React.cloneElement(element, {
onClick: handleChartClick,
children: processChildren(element.props.children),
});
}
// Recursively process children for non-chart components
if (element.props.children != null) {
return React.cloneElement(element, {
children: processChildren(element.props.children),
});
}
return node;
};
// Handle multiple children (array) or single child
if (Array.isArray(children)) {
return children.map(addClickHandler);
}
return addClickHandler(children);
}, [children, handleChartClick]);
return (
<div ref={containerRef} className="relative h-full w-full">
<DropdownMenu
open={clickPosition !== null}
onOpenChange={handleOpenChange}
>
<DropdownMenuTrigger asChild>
<div
style={{
position: 'absolute',
left: clickPosition?.x ?? -9999,
top: clickPosition?.y ?? -9999,
pointerEvents: 'none',
}}
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" side="bottom" sideOffset={5}>
{menuItems.map((item) => (
<DropdownMenuItem
key={item.label}
onClick={() => handleItemClick(item)}
disabled={item.disabled}
>
{item.icon && <span className="mr-2">{item.icon}</span>}
{item.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{chartWithClickHandler}
</div>
);
});
ChartClickMenu.displayName = 'ChartClickMenu';

View File

@@ -17,10 +17,10 @@ export function ReportChartEmpty({
}) { }) {
const { const {
isEditMode, isEditMode,
report: { events }, report: { series },
} = useReportChartContext(); } = useReportChartContext();
if (events.length === 0) { if (!series || series.length === 0) {
return ( return (
<div className="card p-4 center-center h-full w-full flex-col relative"> <div className="card p-4 center-center h-full w-full flex-col relative">
<div className="row gap-2 items-end absolute top-4 left-4"> <div className="row gap-2 items-end absolute top-4 left-4">

View File

@@ -62,7 +62,10 @@ export const ReportChartTooltip = createChartTooltip<Data, Context>(
const { const {
report: { interval, unit }, report: { interval, unit },
} = useReportChartContext(); } = useReportChartContext();
const formatDate = useFormatDateInterval(interval); const formatDate = useFormatDateInterval({
interval,
short: false,
});
const number = useNumber(); const number = useNumber();
if (!data || data.length === 0) { if (!data || data.length === 0) {

View File

@@ -0,0 +1,52 @@
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { List, Rows3, Search, X } from 'lucide-react';
interface ReportTableToolbarProps {
grouped?: boolean;
onToggleGrouped?: () => void;
search: string;
onSearchChange?: (value: string) => void;
onUnselectAll?: () => void;
}
export function ReportTableToolbar({
grouped,
onToggleGrouped,
search,
onSearchChange,
onUnselectAll,
}: ReportTableToolbarProps) {
return (
<div className="col md:row md:items-center gap-2 p-2 border-b md:justify-between">
{onSearchChange && (
<div className="relative flex-1 w-full md:max-w-sm">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
placeholder="Search..."
value={search}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-8"
/>
</div>
)}
<div className="flex items-center gap-2">
{onToggleGrouped && (
<Button
variant={'outline'}
size="sm"
onClick={onToggleGrouped}
icon={grouped ? Rows3 : List}
>
{grouped ? 'Grouped' : 'Flat'}
</Button>
)}
{onUnselectAll && (
<Button variant="outline" size="sm" onClick={onUnselectAll} icon={X}>
Unselect All
</Button>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,742 @@
import { getPropertyLabel } from '@/translations/properties';
import type { IChartData } from '@/trpc/client';
export type TableRow = {
id: string;
serieId: string; // Serie ID for visibility/color lookup
serieName: string;
breakdownValues: string[];
count: number;
sum: number;
average: number;
min: number;
max: number;
dateValues: Record<string, number>; // date -> count
// Group metadata
groupKey?: string;
parentGroupKey?: string;
isSummaryRow?: boolean;
};
export type GroupedTableRow = TableRow & {
// For grouped mode, indicates which breakdown levels should show empty cells
breakdownDisplay: (string | null)[]; // null means show empty cell
};
/**
* Row type that supports TanStack Table's expanding feature
* Can represent both group header rows and data rows
*/
export type ExpandableTableRow = TableRow & {
subRows?: ExpandableTableRow[];
isGroupHeader?: boolean; // True if this is a group header row
groupValue?: string; // The value this group represents
groupLevel?: number; // The level in the hierarchy (0-based)
breakdownDisplay?: (string | null)[]; // For display purposes
};
/**
* Hierarchical group structure for better collapse/expand functionality
*/
export type GroupedItem<T> = {
group: string;
items: Array<GroupedItem<T> | T>;
level: number;
groupKey: string; // Unique key for this group (path-based)
parentGroupKey?: string; // Key of parent group
};
/**
* Transform flat array of items with hierarchical names into nested group structure
* This creates a tree structure that makes it easier to toggle specific groups
*/
export function groupByNames<T extends { names: string[] }>(
items: T[],
): Array<GroupedItem<T>> {
const rootGroups = new Map<string, GroupedItem<T>>();
for (const item of items) {
const names = item.names;
if (names.length === 0) continue;
// Start with the first level (serie name, level -1)
const firstLevel = names[0]!;
const rootGroupKey = firstLevel;
if (!rootGroups.has(firstLevel)) {
rootGroups.set(firstLevel, {
group: firstLevel,
items: [],
level: -1, // Serie level
groupKey: rootGroupKey,
});
}
const rootGroup = rootGroups.get(firstLevel)!;
// Navigate/create nested groups for remaining levels (breakdowns, level 0+)
let currentGroup = rootGroup;
let parentGroupKey = rootGroupKey;
for (let i = 1; i < names.length; i++) {
const levelName = names[i]!;
const groupKey = `${parentGroupKey}:${levelName}`;
const level = i - 1; // Breakdown levels start at 0
// Find existing group at this level
const existingGroup = currentGroup.items.find(
(child): child is GroupedItem<T> =>
typeof child === 'object' &&
'group' in child &&
child.group === levelName &&
'level' in child &&
child.level === level,
);
if (existingGroup) {
currentGroup = existingGroup;
parentGroupKey = groupKey;
} else {
// Create new group at this level
const newGroup: GroupedItem<T> = {
group: levelName,
items: [],
level,
groupKey,
parentGroupKey,
};
currentGroup.items.push(newGroup);
currentGroup = newGroup;
parentGroupKey = groupKey;
}
}
// Add the actual item to the deepest group
currentGroup.items.push(item);
}
return Array.from(rootGroups.values());
}
/**
* Flatten a grouped structure back into a flat array of items
* Useful for getting all items in a group or its children
*/
export function flattenGroupedItems<T>(
groupedItems: Array<GroupedItem<T> | T>,
): T[] {
const result: T[] = [];
for (const item of groupedItems) {
if (item && typeof item === 'object' && 'items' in item) {
// It's a group, recursively flatten its items
result.push(...flattenGroupedItems(item.items));
} else if (item) {
// It's an actual item
result.push(item);
}
}
return result;
}
/**
* Find a group by its groupKey in a nested structure
*/
export function findGroup<T>(
groups: Array<GroupedItem<T>>,
groupKey: string,
): GroupedItem<T> | null {
for (const group of groups) {
if (group.groupKey === groupKey) {
return group;
}
// Search in nested groups
for (const item of group.items) {
if (item && typeof item === 'object' && 'items' in item) {
const found = findGroup([item], groupKey);
if (found) return found;
}
}
}
return null;
}
/**
* Convert hierarchical groups to TanStack Table's expandable row format
*
* Transforms nested GroupedItem structure into flat ExpandableTableRow array
* that TanStack Table can use with its native expanding feature.
*
* Key behaviors:
* - Serie level (level -1) and breakdown levels 0 to breakdownCount-2 create group headers
* - Last breakdown level (breakdownCount-1) does NOT create group headers (always individual rows)
* - Individual rows are explicitly marked as NOT group headers or summary rows
*/
export function groupsToExpandableRows(
groups: Array<GroupedItem<TableRow>>,
breakdownCount: number,
): ExpandableTableRow[] {
const result: ExpandableTableRow[] = [];
function processGroup(
group: GroupedItem<TableRow>,
parentPath: string[] = [],
): ExpandableTableRow[] {
const currentPath = [...parentPath, group.group];
const subRows: ExpandableTableRow[] = [];
// Separate nested groups from individual data items
const nestedGroups: GroupedItem<TableRow>[] = [];
const individualItems: TableRow[] = [];
for (const item of group.items) {
if (item && typeof item === 'object' && 'items' in item) {
nestedGroups.push(item);
} else if (item) {
individualItems.push(item);
}
}
// Process nested groups recursively (they become expandable group headers)
for (const nestedGroup of nestedGroups) {
subRows.push(...processGroup(nestedGroup, currentPath));
}
// Process individual data items (leaf nodes)
individualItems.forEach((item, index) => {
// Build breakdownDisplay: first row shows all values, subsequent rows show parent path + item values
const breakdownDisplay: (string | null)[] = [];
const breakdownValues = item.breakdownValues;
for (let i = 0; i < breakdownCount; i++) {
if (index === 0) {
// First row: show all breakdown values
breakdownDisplay.push(breakdownValues[i] ?? null);
} else {
// Subsequent rows: show parent path values, then item values
if (i < currentPath.length) {
breakdownDisplay.push(currentPath[i] ?? null);
} else if (i < breakdownValues.length) {
breakdownDisplay.push(breakdownValues[i] ?? null);
} else {
breakdownDisplay.push(null);
}
}
}
subRows.push({
...item,
breakdownDisplay,
groupKey: group.groupKey,
parentGroupKey: group.parentGroupKey,
isGroupHeader: false,
isSummaryRow: false,
});
});
// If this group has subRows and is not the last breakdown level, create a group header row
// Don't create group headers for the last breakdown level (level === breakdownCount - 1)
// because the last breakdown should always be individual rows
// -1 is serie level (should be grouped)
// 0 to breakdownCount-2 are breakdown levels (should be grouped)
// breakdownCount-1 is the last breakdown level (should NOT be grouped, always individual)
const shouldCreateGroupHeader =
subRows.length > 0 &&
(group.level === -1 || group.level < breakdownCount - 1);
if (shouldCreateGroupHeader) {
// Create a summary row for the group
const groupItems = flattenGroupedItems(group.items);
const summaryRow = createSummaryRow(
groupItems,
group.groupKey,
breakdownCount,
);
return [
{
...summaryRow,
isGroupHeader: true,
groupValue: group.group,
groupLevel: group.level,
subRows,
},
];
}
return subRows;
}
for (const group of groups) {
result.push(...processGroup(group));
}
return result;
}
/**
* Convert hierarchical groups to flat table rows, respecting collapsed groups
* This creates GroupedTableRow entries with proper breakdownDisplay values
* @deprecated Use groupsToExpandableRows with TanStack Table's expanding feature instead
*/
export function groupsToTableRows<T extends TableRow>(
groups: Array<GroupedItem<T>>,
collapsedGroups: Set<string>,
breakdownCount: number,
): GroupedTableRow[] {
const rows: GroupedTableRow[] = [];
function processGroup(
group: GroupedItem<T>,
parentPath: string[] = [],
parentGroupKey?: string,
): void {
const isGroupCollapsed = collapsedGroups.has(group.groupKey);
const currentPath = [...parentPath, group.group];
if (isGroupCollapsed) {
// Group is collapsed - add summary row
const groupItems = flattenGroupedItems(group.items);
if (groupItems.length > 0) {
const summaryRow = createSummaryRow(
groupItems,
group.groupKey,
breakdownCount,
);
rows.push(summaryRow);
}
return;
}
// Group is expanded - process items
// Separate nested groups from actual items
const nestedGroups: GroupedItem<T>[] = [];
const actualItems: T[] = [];
for (const item of group.items) {
if (item && typeof item === 'object' && 'items' in item) {
nestedGroups.push(item);
} else if (item) {
actualItems.push(item);
}
}
// Process actual items first
actualItems.forEach((item, index) => {
const breakdownDisplay: (string | null)[] = [];
const breakdownValues = item.breakdownValues;
// For the first item in the group, show all breakdown values
// For subsequent items, show values based on hierarchy
if (index === 0) {
// First row shows all breakdown values
for (let i = 0; i < breakdownCount; i++) {
breakdownDisplay.push(breakdownValues[i] ?? null);
}
} else {
// Subsequent rows: show values from parent path, then item values
for (let i = 0; i < breakdownCount; i++) {
if (i < currentPath.length) {
// Show value from parent group path
breakdownDisplay.push(currentPath[i] ?? null);
} else if (i < breakdownValues.length) {
// Show current breakdown value from the item
breakdownDisplay.push(breakdownValues[i] ?? null);
} else {
breakdownDisplay.push(null);
}
}
}
rows.push({
...item,
breakdownDisplay,
groupKey: group.groupKey,
parentGroupKey: group.parentGroupKey,
});
});
// Process nested groups
for (const nestedGroup of nestedGroups) {
processGroup(nestedGroup, currentPath, group.groupKey);
}
}
for (const group of groups) {
processGroup(group);
}
return rows;
}
/**
* Extract unique dates from all series
*/
function getUniqueDates(series: IChartData['series']): string[] {
const dateSet = new Set<string>();
series.forEach((serie) => {
serie.data.forEach((d) => {
dateSet.add(d.date);
});
});
return Array.from(dateSet).sort();
}
/**
* Get breakdown property names from series
* Breakdown values are in names.slice(1), so we need to infer the property names
* from the breakdowns array or from the series structure
*/
function getBreakdownPropertyNames(
series: IChartData['series'],
breakdowns: Array<{ name: string }>,
): string[] {
// If we have breakdowns from state, use those
if (breakdowns.length > 0) {
return breakdowns.map((b) => getPropertyLabel(b.name));
}
// Otherwise, infer from series names
// All series should have the same number of breakdown values
if (series.length === 0) return [];
const firstSerie = series[0];
const breakdownCount = firstSerie.names.length - 1;
return Array.from({ length: breakdownCount }, (_, i) => `Breakdown ${i + 1}`);
}
/**
* Transform series into flat table rows
*/
export function createFlatRows(
series: IChartData['series'],
dates: string[],
): TableRow[] {
return series.map((serie) => {
const dateValues: Record<string, number> = {};
dates.forEach((date) => {
const dataPoint = serie.data.find((d) => d.date === date);
dateValues[date] = dataPoint?.count ?? 0;
});
return {
id: serie.id,
serieId: serie.id,
serieName: serie.names[0] ?? '',
breakdownValues: serie.names.slice(1),
count: serie.metrics.count ?? 0,
sum: serie.metrics.sum,
average: serie.metrics.average,
min: serie.metrics.min,
max: serie.metrics.max,
dateValues,
};
});
}
/**
* Transform series into hierarchical groups
* Uses the new groupByNames function for better structure
* Groups by serie name first, then by breakdown values
*/
export function createGroupedRowsHierarchical(
series: IChartData['series'],
dates: string[],
): Array<GroupedItem<TableRow>> {
const flatRows = createFlatRows(series, dates);
// Sort by sum descending before grouping
flatRows.sort((a, b) => b.sum - a.sum);
const breakdownCount = flatRows[0]?.breakdownValues.length ?? 0;
if (breakdownCount === 0) {
// No breakdowns - return empty array (will be handled as flat rows)
return [];
}
// Create hierarchical groups using groupByNames
// Note: groupByNames expects items with a `names` array, so we create a temporary array
// This is a minor inefficiency but keeps groupByNames generic and reusable
const itemsWithNames = flatRows.map((row) => ({
...row,
names: [row.serieName, ...row.breakdownValues],
}));
return groupByNames(itemsWithNames);
}
/**
* Transform series into grouped table rows (legacy flat format)
* Groups rows hierarchically by breakdown values
* @deprecated Use createGroupedRowsHierarchical + groupsToTableRows instead
*/
export function createGroupedRows(
series: IChartData['series'],
dates: string[],
): GroupedTableRow[] {
const flatRows = createFlatRows(series, dates);
// Sort by sum descending
flatRows.sort((a, b) => b.sum - a.sum);
// Group rows by breakdown values hierarchically
const grouped: GroupedTableRow[] = [];
const breakdownCount = flatRows[0]?.breakdownValues.length ?? 0;
if (breakdownCount === 0) {
// No breakdowns, just return flat rows
return flatRows.map((row) => ({
...row,
breakdownDisplay: [],
}));
}
// Group rows hierarchically by breakdown values
// We need to group by parent breakdowns first, then by child breakdowns
// This creates the nested structure shown in the user's example
// First, group by first breakdown value
const groupsByFirstBreakdown = new Map<string, TableRow[]>();
flatRows.forEach((row) => {
const firstBreakdown = row.breakdownValues[0] ?? '';
if (!groupsByFirstBreakdown.has(firstBreakdown)) {
groupsByFirstBreakdown.set(firstBreakdown, []);
}
groupsByFirstBreakdown.get(firstBreakdown)!.push(row);
});
// Sort groups by sum of highest row in group
const sortedGroups = Array.from(groupsByFirstBreakdown.entries()).sort(
(a, b) => {
const aMax = Math.max(...a[1].map((r) => r.sum));
const bMax = Math.max(...b[1].map((r) => r.sum));
return bMax - aMax;
},
);
// Process each group hierarchically
sortedGroups.forEach(([firstBreakdownValue, groupRows]) => {
// Within each first-breakdown group, sort by sum
groupRows.sort((a, b) => b.sum - a.sum);
// Generate group key for this first-breakdown group
const groupKey = firstBreakdownValue;
// For each row in the group
groupRows.forEach((row, index) => {
const breakdownDisplay: (string | null)[] = [];
const firstRow = groupRows[0]!;
if (index === 0) {
// First row shows all breakdown values
breakdownDisplay.push(...row.breakdownValues);
} else {
// Subsequent rows: show all values, but mark duplicates for muted styling
for (let i = 0; i < row.breakdownValues.length; i++) {
// Always show the value, even if it matches the first row
breakdownDisplay.push(row.breakdownValues[i] ?? null);
}
}
grouped.push({
...row,
breakdownDisplay,
groupKey,
});
});
});
return grouped;
}
/**
* Create a summary row for a collapsed group
*/
export function createSummaryRow(
groupRows: TableRow[],
groupKey: string,
breakdownCount: number,
): GroupedTableRow {
const firstRow = groupRows[0]!;
// Aggregate metrics from all rows in the group
const totalSum = groupRows.reduce((sum, row) => sum + row.sum, 0);
const totalCount = groupRows.reduce((sum, row) => sum + row.count, 0);
const totalAverage =
groupRows.reduce((sum, row) => sum + row.average, 0) / groupRows.length;
const totalMin = Math.min(...groupRows.map((row) => row.min));
const totalMax = Math.max(...groupRows.map((row) => row.max));
// Aggregate date values across all rows
const dateValues: Record<string, number> = {};
groupRows.forEach((row) => {
Object.keys(row.dateValues).forEach((date) => {
dateValues[date] = (dateValues[date] ?? 0) + row.dateValues[date];
});
});
// Build breakdownDisplay: show first breakdown value, rest are null
const breakdownDisplay: (string | null)[] = [
firstRow.breakdownValues[0] ?? null,
...Array(breakdownCount - 1).fill(null),
];
return {
id: `summary-${groupKey}`,
serieId: firstRow.serieId,
serieName: firstRow.serieName,
breakdownValues: firstRow.breakdownValues,
count: totalCount,
sum: totalSum,
average: totalAverage,
min: totalMin,
max: totalMax,
dateValues,
groupKey,
isSummaryRow: true,
breakdownDisplay,
};
}
/**
* Reorder breakdowns by number of unique values (fewest first)
*/
function reorderBreakdownsByUniqueCount(
series: IChartData['series'],
breakdownPropertyNames: string[],
): {
reorderedNames: string[];
reorderMap: number[]; // Maps new index -> old index
reverseMap: number[]; // Maps old index -> new index
} {
if (breakdownPropertyNames.length === 0 || series.length === 0) {
return {
reorderedNames: breakdownPropertyNames,
reorderMap: [],
reverseMap: [],
};
}
// Count unique values for each breakdown index
const uniqueCounts = breakdownPropertyNames.map((_, index) => {
const uniqueValues = new Set<string>();
series.forEach((serie) => {
const value = serie.names[index + 1]; // +1 because names[0] is serie name
if (value) {
uniqueValues.add(value);
}
});
return { index, count: uniqueValues.size };
});
// Sort by count (ascending - fewest first)
uniqueCounts.sort((a, b) => a.count - b.count);
// Create reordered names and mapping
const reorderedNames = uniqueCounts.map(
(item) => breakdownPropertyNames[item.index]!,
);
const reorderMap = uniqueCounts.map((item) => item.index); // new index -> old index
const reverseMap = new Array(breakdownPropertyNames.length);
reorderMap.forEach((oldIndex, newIndex) => {
reverseMap[oldIndex] = newIndex;
});
return { reorderedNames, reorderMap, reverseMap };
}
/**
* Transform chart data into table-ready format
*/
export function transformToTableData(
data: IChartData,
breakdowns: Array<{ name: string }>,
grouped: boolean,
): {
rows: TableRow[] | GroupedTableRow[];
dates: string[];
breakdownPropertyNames: string[];
} {
const dates = getUniqueDates(data.series);
const originalBreakdownPropertyNames = getBreakdownPropertyNames(
data.series,
breakdowns,
);
// Reorder breakdowns by unique count (fewest first)
const { reorderedNames: breakdownPropertyNames, reorderMap } =
reorderBreakdownsByUniqueCount(data.series, originalBreakdownPropertyNames);
// Reorder breakdown values in series before creating rows
const reorderedSeries = data.series.map((serie) => {
const reorderedNames = [
serie.names[0], // Keep serie name first
...reorderMap.map((oldIndex) => serie.names[oldIndex + 1] ?? ''), // Reorder breakdown values
];
return {
...serie,
names: reorderedNames,
};
});
const rows = grouped
? createGroupedRows(reorderedSeries, dates)
: createFlatRows(reorderedSeries, dates);
// Sort flat rows by sum descending
if (!grouped) {
(rows as TableRow[]).sort((a, b) => b.sum - a.sum);
}
return {
rows,
dates,
breakdownPropertyNames,
};
}
/**
* Transform chart data into hierarchical groups
* Returns hierarchical structure for better group management
*/
export function transformToHierarchicalGroups(
data: IChartData,
breakdowns: Array<{ name: string }>,
): {
groups: Array<GroupedItem<TableRow>>;
dates: string[];
breakdownPropertyNames: string[];
} {
const dates = getUniqueDates(data.series);
const originalBreakdownPropertyNames = getBreakdownPropertyNames(
data.series,
breakdowns,
);
// Reorder breakdowns by unique count (fewest first)
const { reorderedNames: breakdownPropertyNames, reorderMap } =
reorderBreakdownsByUniqueCount(data.series, originalBreakdownPropertyNames);
// Reorder breakdown values in series before creating rows
const reorderedSeries = data.series.map((serie) => {
const reorderedNames = [
serie.names[0], // Keep serie name first
...reorderMap.map((oldIndex) => serie.names[oldIndex + 1] ?? ''), // Reorder breakdown values
];
return {
...serie,
names: reorderedNames,
};
});
const groups = createGroupedRowsHierarchical(reorderedSeries, dates);
return {
groups,
dates,
breakdownPropertyNames,
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,11 @@
import { pushModal } from '@/modals';
import type { RouterOutputs } from '@/trpc/client'; import type { RouterOutputs } from '@/trpc/client';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import { getChartColor } from '@/utils/theme'; import { getChartColor } from '@/utils/theme';
import React, { useCallback } from 'react';
import { import {
CartesianGrid, CartesianGrid,
Legend,
Line, Line,
LineChart, LineChart,
ReferenceLine, ReferenceLine,
@@ -10,19 +13,26 @@ import {
XAxis, XAxis,
YAxis, YAxis,
} from 'recharts'; } from 'recharts';
import { pushModal } from '@/modals';
import { useCallback } from 'react';
import { createChartTooltip } from '@/components/charts/chart-tooltip'; import {
ChartTooltipHeader,
ChartTooltipItem,
createChartTooltip,
} from '@/components/charts/chart-tooltip';
import { useConversionRechartDataModel } from '@/hooks/use-conversion-rechart-data-model';
import { useFormatDateInterval } from '@/hooks/use-format-date-interval'; import { useFormatDateInterval } from '@/hooks/use-format-date-interval';
import { useNumber } from '@/hooks/use-numer-formatter'; import { useNumber } from '@/hooks/use-numer-formatter';
import { useVisibleConversionSeries } from '@/hooks/use-visible-conversion-series';
import { useTRPC } from '@/integrations/trpc/react'; import { useTRPC } from '@/integrations/trpc/react';
import { average, getPreviousMetric, round } from '@openpanel/common'; import { average, getPreviousMetric, round } from '@openpanel/common';
import type { IInterval } from '@openpanel/validation'; import type { IInterval } from '@openpanel/validation';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useXAxisProps, useYAxisProps } from '../common/axis'; import { useXAxisProps, useYAxisProps } from '../common/axis';
import { PreviousDiffIndicatorPure } from '../common/previous-diff-indicator'; import { PreviousDiffIndicator } from '../common/previous-diff-indicator';
import { SerieIcon } from '../common/serie-icon';
import { SerieName } from '../common/serie-name';
import { useReportChartContext } from '../context'; import { useReportChartContext } from '../context';
import { ConversionTable } from './conversion-table';
interface Props { interface Props {
data: RouterOutputs['chart']['conversion']; data: RouterOutputs['chart']['conversion'];
@@ -30,20 +40,12 @@ interface Props {
export function Chart({ data }: Props) { export function Chart({ data }: Props) {
const { const {
report: { report: { interval, projectId, startDate, endDate, range, lineType },
previous,
interval,
projectId,
startDate,
endDate,
range,
lineType,
events,
},
isEditMode, isEditMode,
options: { hideXAxis, hideYAxis, maxDomain }, options: { hideXAxis, hideYAxis, maxDomain },
} = useReportChartContext(); } = useReportChartContext();
const dataLength = data.current.length || 0; const { series, setVisibleSeries } = useVisibleConversionSeries(data, 5);
const rechartData = useConversionRechartDataModel(series);
const trpc = useTRPC(); const trpc = useTRPC();
const references = useQuery( const references = useQuery(
trpc.reference.getChartReferences.queryOptions( trpc.reference.getChartReferences.queryOptions(
@@ -65,18 +67,11 @@ export function Chart({ data }: Props) {
}); });
const averageConversionRate = average( const averageConversionRate = average(
data.current.map((serie) => { series.map((serie) => {
return average(serie.data.map((item) => item.rate)); return average(serie.data.map((item) => item.rate));
}, 0), }, 0),
); );
const rechartData = data.current[0].data.map((item) => {
return {
...item,
timestamp: new Date(item.date).getTime(),
};
});
const handleChartClick = useCallback((e: any) => { const handleChartClick = useCallback((e: any) => {
if (e?.activePayload?.[0]) { if (e?.activePayload?.[0]) {
const clickedData = e.activePayload[0].payload; const clickedData = e.activePayload[0].payload;
@@ -88,8 +83,36 @@ export function Chart({ data }: Props) {
} }
}, []); }, []);
const CustomLegend = useCallback(() => {
return (
<div className="flex flex-wrap justify-center gap-x-4 gap-y-1 text-xs mt-4 -mb-2">
{series.map((serie) => (
<div
className="flex items-center gap-1"
key={serie.id}
style={{
color: getChartColor(serie.index),
}}
>
<SerieIcon name={serie.breakdowns} />
<SerieName
name={
serie.breakdowns.length > 0 ? serie.breakdowns : ['Conversion']
}
className="font-semibold"
/>
</div>
))}
</div>
);
}, [series]);
return ( return (
<TooltipProvider conversion={data} interval={interval}> <TooltipProvider
conversion={data}
interval={interval}
visibleSeries={series}
>
<div className={cn('h-full w-full', isEditMode && 'card p-4')}> <div className={cn('h-full w-full', isEditMode && 'card p-4')}>
<ResponsiveContainer> <ResponsiveContainer>
<LineChart data={rechartData} onClick={handleChartClick}> <LineChart data={rechartData} onClick={handleChartClick}>
@@ -116,35 +139,48 @@ export function Chart({ data }: Props) {
))} ))}
<YAxis {...yAxisProps} domain={[0, 100]} /> <YAxis {...yAxisProps} domain={[0, 100]} />
<XAxis {...xAxisProps} allowDuplicatedCategory={false} /> <XAxis {...xAxisProps} allowDuplicatedCategory={false} />
{series.length > 1 && <Legend content={<CustomLegend />} />}
<Tooltip /> <Tooltip />
<Line {series.map((serie) => {
dot={false} const color = getChartColor(serie.index);
dataKey="previousRate" return (
stroke={getChartColor(0)} <Line
type={lineType} key={`${serie.id}:previousRate`}
isAnimationActive={false} dot={false}
strokeWidth={1} dataKey={`${serie.id}:previousRate`}
strokeOpacity={0.5} stroke={color}
/> type={lineType}
<Line isAnimationActive={false}
dataKey="rate" strokeWidth={1}
stroke={getChartColor(0)} strokeOpacity={0.3}
type={lineType} />
isAnimationActive={false} );
strokeWidth={2} })}
/> {series.map((serie) => {
const color = getChartColor(serie.index);
return (
<Line
key={`${serie.id}:rate`}
dataKey={`${serie.id}:rate`}
stroke={color}
type={lineType}
isAnimationActive={false}
strokeWidth={2}
/>
);
})}
{typeof averageConversionRate === 'number' && {typeof averageConversionRate === 'number' &&
averageConversionRate && ( averageConversionRate && (
<ReferenceLine <ReferenceLine
y={averageConversionRate} y={averageConversionRate}
stroke={getChartColor(1)} stroke={getChartColor(series.length)}
strokeWidth={2} strokeWidth={2}
strokeDasharray="3 3" strokeDasharray="3 3"
strokeOpacity={0.5} strokeOpacity={0.5}
strokeLinecap="round" strokeLinecap="round"
label={{ label={{
value: `Average (${round(averageConversionRate, 2)} %)`, value: `Average (${round(averageConversionRate, 2)} %)`,
fill: getChartColor(1), fill: getChartColor(series.length),
position: 'insideBottomRight', position: 'insideBottomRight',
fontSize: 12, fontSize: 12,
}} }}
@@ -153,69 +189,92 @@ export function Chart({ data }: Props) {
</LineChart> </LineChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
<ConversionTable
data={data}
visibleSeries={series}
setVisibleSeries={setVisibleSeries}
/>
</TooltipProvider> </TooltipProvider>
); );
} }
const { Tooltip, TooltipProvider } = createChartTooltip< const { Tooltip, TooltipProvider } = createChartTooltip<
NonNullable< Record<string, any>,
RouterOutputs['chart']['conversion']['current'][number]
>['data'][number],
{ {
conversion: RouterOutputs['chart']['conversion']; conversion: RouterOutputs['chart']['conversion'];
interval: IInterval; interval: IInterval;
visibleSeries: RouterOutputs['chart']['conversion']['current'];
} }
>(({ data, context }) => { >(({ data, context }) => {
if (!data[0]) { if (!data || !data[0]) {
return null; return null;
} }
const { date } = data[0]; const payload = data[0];
const formatDate = useFormatDateInterval(context.interval); const { date } = payload;
const formatDate = useFormatDateInterval({
interval: context.interval,
short: false,
});
const number = useNumber(); const number = useNumber();
return ( return (
<> <>
<div className="flex justify-between gap-8 text-muted-foreground"> {context.visibleSeries.map((serie, index) => {
<div>{formatDate(date)}</div> const rate = payload[`${serie.id}:rate`];
</div> const total = payload[`${serie.id}:total`];
{context.conversion.current.map((serie, index) => { const previousRate = payload[`${serie.id}:previousRate`];
const item = data[index];
if (!item) { if (rate === undefined) {
return null; return null;
} }
const prevItem = context.conversion?.previous?.[0]?.data[item.index];
const title = const prevSerie = context.conversion?.previous?.find(
serie.breakdowns.length > 0 (p) => p.id === serie.id,
? (serie.breakdowns.join(',') ?? 'Not set') );
: 'Conversion'; const prevItem = prevSerie?.data.find((d) => d.date === date);
const previousMetric = getPreviousMetric(rate, previousRate);
return ( return (
<div className="row gap-2" key={serie.id}> <React.Fragment key={serie.id}>
<div {index === 0 && (
className="w-[3px] rounded-full" <ChartTooltipHeader>
style={{ background: getChartColor(index) }} <div>{formatDate(date)}</div>
/> </ChartTooltipHeader>
<div className="col flex-1 gap-1"> )}
<div className="flex items-center gap-1">{title}</div> <ChartTooltipItem color={getChartColor(index)}>
<div className="flex items-center gap-1">
<SerieIcon
name={
serie.breakdowns.length > 0
? serie.breakdowns
: ['Conversion']
}
/>
<SerieName
name={
serie.breakdowns.length > 0
? serie.breakdowns
: ['Conversion']
}
/>
</div>
<div className="flex justify-between gap-8 font-mono font-medium"> <div className="flex justify-between gap-8 font-mono font-medium">
<div className="col gap-1"> <div className="row gap-1">
<span>{number.formatWithUnit(item.rate / 100, '%')}</span> <span>{number.formatWithUnit(rate / 100, '%')}</span>
<span>{item.total}</span> <span className="text-muted-foreground">({total})</span>
</div> {prevItem && previousRate !== undefined && (
{!!prevItem && (
<div className="col gap-1">
<PreviousDiffIndicatorPure
{...getPreviousMetric(item.rate, prevItem?.rate)}
/>
<span className="text-muted-foreground"> <span className="text-muted-foreground">
({prevItem?.total}) ({number.formatWithUnit(previousRate / 100, '%')})
</span> </span>
</div> )}
</div>
{previousRate !== undefined && (
<PreviousDiffIndicator {...previousMetric} />
)} )}
</div> </div>
</div> </ChartTooltipItem>
</div> </React.Fragment>
); );
})} })}
</> </>

View File

@@ -0,0 +1,515 @@
import { Checkbox } from '@/components/ui/checkbox';
import { useFormatDateInterval } from '@/hooks/use-format-date-interval';
import { useNumber } from '@/hooks/use-numer-formatter';
import { useSelector } from '@/redux';
import type { RouterOutputs } from '@/trpc/client';
import { cn } from '@/utils/cn';
import { getChartColor } from '@/utils/theme';
import { getPreviousMetric } from '@openpanel/common';
import type { SortingState } from '@tanstack/react-table';
import { useMemo, useState } from 'react';
import { PreviousDiffIndicatorPure } from '../common/previous-diff-indicator';
import { ReportTableToolbar } from '../common/report-table-toolbar';
import { SerieIcon } from '../common/serie-icon';
import { SerieName } from '../common/serie-name';
interface ConversionTableProps {
data: RouterOutputs['chart']['conversion'];
visibleSeries: RouterOutputs['chart']['conversion']['current'];
setVisibleSeries: React.Dispatch<React.SetStateAction<string[]>>;
}
export function ConversionTable({
data,
visibleSeries,
setVisibleSeries,
}: ConversionTableProps) {
const [sorting, setSorting] = useState<SortingState>([]);
const [globalFilter, setGlobalFilter] = useState('');
const number = useNumber();
const interval = useSelector((state) => state.report.interval);
const formatDate = useFormatDateInterval({
interval,
short: true,
});
// Get all unique dates from the first series
const dates = useMemo(
() => data.current[0]?.data.map((item) => item.date) ?? [],
[data.current],
);
// Get all series (including non-visible ones for toggle functionality)
const allSeries = data.current;
// Transform data to table rows with memoization
const rows = useMemo(() => {
return allSeries.map((serie) => {
const dateValues: Record<string, number> = {};
dates.forEach((date) => {
const item = serie.data.find((d) => d.date === date);
dateValues[date] = item?.rate ?? 0;
});
const total = serie.data.reduce((sum, item) => sum + item.total, 0);
const conversions = serie.data.reduce(
(sum, item) => sum + item.conversions,
0,
);
const avgRate =
serie.data.length > 0
? serie.data.reduce((sum, item) => sum + item.rate, 0) /
serie.data.length
: 0;
const prevSerie = data.previous?.find((p) => p.id === serie.id);
const prevAvgRate =
prevSerie && prevSerie.data.length > 0
? prevSerie.data.reduce((sum, item) => sum + item.rate, 0) /
prevSerie.data.length
: undefined;
return {
id: serie.id,
serieId: serie.id,
serieName:
serie.breakdowns.length > 0 ? serie.breakdowns : ['Conversion'],
breakdownValues: serie.breakdowns,
avgRate,
prevAvgRate,
total,
conversions,
dateValues,
};
});
}, [allSeries, dates, data.previous]);
// Calculate ranges for color visualization (memoized)
const { metricRanges, dateRanges } = useMemo(() => {
const metricRanges: Record<string, { min: number; max: number }> = {
avgRate: {
min: Number.POSITIVE_INFINITY,
max: Number.NEGATIVE_INFINITY,
},
total: {
min: Number.POSITIVE_INFINITY,
max: Number.NEGATIVE_INFINITY,
},
conversions: {
min: Number.POSITIVE_INFINITY,
max: Number.NEGATIVE_INFINITY,
},
};
const dateRanges: Record<string, { min: number; max: number }> = {};
dates.forEach((date) => {
dateRanges[date] = {
min: Number.POSITIVE_INFINITY,
max: Number.NEGATIVE_INFINITY,
};
});
rows.forEach((row) => {
// Metric ranges
metricRanges.avgRate.min = Math.min(
metricRanges.avgRate.min,
row.avgRate,
);
metricRanges.avgRate.max = Math.max(
metricRanges.avgRate.max,
row.avgRate,
);
metricRanges.total.min = Math.min(metricRanges.total.min, row.total);
metricRanges.total.max = Math.max(metricRanges.total.max, row.total);
metricRanges.conversions.min = Math.min(
metricRanges.conversions.min,
row.conversions,
);
metricRanges.conversions.max = Math.max(
metricRanges.conversions.max,
row.conversions,
);
// Date ranges
dates.forEach((date) => {
const value = row.dateValues[date] ?? 0;
dateRanges[date]!.min = Math.min(dateRanges[date]!.min, value);
dateRanges[date]!.max = Math.max(dateRanges[date]!.max, value);
});
});
return { metricRanges, dateRanges };
}, [rows, dates]);
// Helper to get background color style
const getCellBackgroundStyle = (
value: number,
min: number,
max: number,
colorClass: 'purple' | 'emerald' = 'emerald',
): React.CSSProperties => {
if (value === 0 || max === min) {
return {};
}
const percentage = (value - min) / (max - min);
const opacity = Math.max(0.05, Math.min(1, percentage));
const backgroundColor =
colorClass === 'purple'
? `rgba(168, 85, 247, ${opacity})`
: `rgba(16, 185, 129, ${opacity})`;
return { backgroundColor };
};
const visibleSeriesIds = useMemo(
() => visibleSeries.map((s) => s.id),
[visibleSeries],
);
const getSerieIndex = (serieId: string): number => {
return allSeries.findIndex((s) => s.id === serieId);
};
const toggleSerieVisibility = (serieId: string) => {
setVisibleSeries((prev) => {
if (prev.includes(serieId)) {
return prev.filter((id) => id !== serieId);
}
return [...prev, serieId];
});
};
// Filter and sort rows
const filteredAndSortedRows = useMemo(() => {
let result = rows;
// Apply search filter
if (globalFilter.trim()) {
const searchLower = globalFilter.toLowerCase();
result = rows.filter((row) => {
// Search in serie name
if (
row.serieName.some((name) =>
name?.toLowerCase().includes(searchLower),
)
) {
return true;
}
// Search in breakdown values
if (
row.breakdownValues.some((val) =>
val?.toLowerCase().includes(searchLower),
)
) {
return true;
}
// Search in metric values
if (
String(row.avgRate).toLowerCase().includes(searchLower) ||
String(row.total).toLowerCase().includes(searchLower) ||
String(row.conversions).toLowerCase().includes(searchLower)
) {
return true;
}
// Search in date values
if (
Object.values(row.dateValues).some((val) =>
String(val).toLowerCase().includes(searchLower),
)
) {
return true;
}
return false;
});
}
// Apply sorting
if (sorting.length > 0) {
result = [...result].sort((a, b) => {
for (const sort of sorting) {
const { id, desc } = sort;
let aValue: any;
let bValue: any;
if (id === 'serie-name') {
aValue = a.serieName.join(' > ') ?? '';
bValue = b.serieName.join(' > ') ?? '';
} else if (id === 'metric-avgRate') {
aValue = a.avgRate ?? 0;
bValue = b.avgRate ?? 0;
} else if (id === 'metric-total') {
aValue = a.total ?? 0;
bValue = b.total ?? 0;
} else if (id === 'metric-conversions') {
aValue = a.conversions ?? 0;
bValue = b.conversions ?? 0;
} else if (id.startsWith('date-')) {
const date = id.replace('date-', '');
aValue = a.dateValues[date] ?? 0;
bValue = b.dateValues[date] ?? 0;
} else {
continue;
}
// Handle null/undefined values
if (aValue == null && bValue == null) continue;
if (aValue == null) return 1;
if (bValue == null) return -1;
// Compare values
if (typeof aValue === 'string' && typeof bValue === 'string') {
const comparison = aValue.localeCompare(bValue);
if (comparison !== 0) return desc ? -comparison : comparison;
} else {
if (aValue < bValue) return desc ? 1 : -1;
if (aValue > bValue) return desc ? -1 : 1;
}
}
return 0;
});
}
return result;
}, [rows, globalFilter, sorting]);
const handleSort = (columnId: string) => {
setSorting((prev) => {
const existingSort = prev.find((s) => s.id === columnId);
if (existingSort) {
if (existingSort.desc) {
// Toggle to ascending if already descending
return [{ id: columnId, desc: false }];
}
// Remove sort if already ascending
return [];
}
// Start with descending (highest first)
return [{ id: columnId, desc: true }];
});
};
const getSortIcon = (columnId: string) => {
const sort = sorting.find((s) => s.id === columnId);
if (!sort) return '⇅';
return sort.desc ? '↓' : '↑';
};
if (allSeries.length === 0) {
return null;
}
return (
<div className="flex flex-col border rounded-lg overflow-hidden bg-card mt-8">
<ReportTableToolbar
search={globalFilter}
onSearchChange={setGlobalFilter}
onUnselectAll={() => setVisibleSeries([])}
/>
<div
className="overflow-x-auto overflow-y-auto"
style={{
width: '100%',
maxHeight: '600px',
}}
>
<table className="w-full" style={{ minWidth: 'fit-content' }}>
<thead className="bg-muted/30 border-b sticky top-0 z-10">
<tr>
<th
className="text-left h-10 px-4 text-[10px] uppercase font-semibold sticky left-0 bg-card z-20 min-w-[200px] border-r border-border whitespace-nowrap"
style={{
boxShadow: '2px 0 4px -2px var(--border)',
}}
>
<div className="flex items-center">Serie</div>
</th>
<th
className="text-right h-10 px-4 text-[10px] uppercase font-semibold min-w-[100px] cursor-pointer hover:bg-muted/50 select-none border-r border-border whitespace-nowrap"
onClick={() => handleSort('metric-avgRate')}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleSort('metric-avgRate');
}
}}
>
<div className="flex items-center justify-end gap-1.5">
Avg Rate
<span className="text-muted-foreground">
{getSortIcon('metric-avgRate')}
</span>
</div>
</th>
<th
className="text-right h-10 px-4 text-[10px] uppercase font-semibold min-w-[100px] cursor-pointer hover:bg-muted/50 select-none border-r border-border whitespace-nowrap"
onClick={() => handleSort('metric-total')}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleSort('metric-total');
}
}}
>
<div className="flex items-center justify-end gap-1.5">
Total
<span className="text-muted-foreground">
{getSortIcon('metric-total')}
</span>
</div>
</th>
<th
className="text-right h-10 px-4 text-[10px] uppercase font-semibold min-w-[100px] cursor-pointer hover:bg-muted/50 select-none border-r border-border whitespace-nowrap"
onClick={() => handleSort('metric-conversions')}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleSort('metric-conversions');
}
}}
>
<div className="flex items-center justify-end gap-1.5">
Conversions
<span className="text-muted-foreground">
{getSortIcon('metric-conversions')}
</span>
</div>
</th>
{dates.map((date) => (
<th
key={date}
className="text-right h-10 px-4 text-[10px] uppercase font-semibold min-w-[100px] cursor-pointer hover:bg-muted/50 select-none border-r border-border whitespace-nowrap"
onClick={() => handleSort(`date-${date}`)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleSort(`date-${date}`);
}
}}
>
<div className="flex items-center justify-end gap-1.5">
{formatDate(date)}
<span className="text-muted-foreground">
{getSortIcon(`date-${date}`)}
</span>
</div>
</th>
))}
</tr>
</thead>
<tbody>
{filteredAndSortedRows.map((row) => {
const isVisible = visibleSeriesIds.includes(row.serieId);
const serieIndex = getSerieIndex(row.serieId);
const color = getChartColor(serieIndex);
const previousMetric =
row.prevAvgRate !== undefined
? getPreviousMetric(row.avgRate, row.prevAvgRate)
: null;
return (
<tr
key={row.id}
className={cn(
'border-b hover:bg-muted/30 transition-colors',
!isVisible && 'opacity-50',
)}
>
<td
className="px-4 py-3 sticky left-0 z-10 border-r border-border"
style={{
backgroundColor: 'var(--card)',
boxShadow: '2px 0 4px -2px var(--border)',
}}
>
<div className="flex items-center gap-2">
<Checkbox
checked={isVisible}
onCheckedChange={() =>
toggleSerieVisibility(row.serieId)
}
style={{
borderColor: color,
backgroundColor: isVisible ? color : 'transparent',
}}
className="h-4 w-4 shrink-0"
/>
<div
className="w-[3px] rounded-full shrink-0"
style={{ background: color }}
/>
<SerieIcon name={row.serieName} />
<SerieName name={row.serieName} className="truncate" />
</div>
</td>
<td
className="px-4 py-3 text-right font-mono text-sm"
style={getCellBackgroundStyle(
row.avgRate,
metricRanges.avgRate.min,
metricRanges.avgRate.max,
'purple',
)}
>
<div className="flex items-center justify-end gap-2">
<span>
{number.formatWithUnit(row.avgRate / 100, '%')}
</span>
{previousMetric && (
<PreviousDiffIndicatorPure {...previousMetric} />
)}
</div>
</td>
<td
className="px-4 py-3 text-right font-mono text-sm"
style={getCellBackgroundStyle(
row.total,
metricRanges.total.min,
metricRanges.total.max,
'purple',
)}
>
{number.format(row.total)}
</td>
<td
className="px-4 py-3 text-right font-mono text-sm"
style={getCellBackgroundStyle(
row.conversions,
metricRanges.conversions.min,
metricRanges.conversions.max,
'purple',
)}
>
{number.format(row.conversions)}
</td>
{dates.map((date) => {
const value = row.dateValues[date] ?? 0;
return (
<td
key={date}
className="px-4 py-3 text-right font-mono text-sm"
style={getCellBackgroundStyle(
value,
dateRanges[date]!.min,
dateRanges[date]!.max,
'emerald',
)}
>
{number.formatWithUnit(value / 100, '%')}
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -13,7 +13,7 @@ import { Summary } from './summary';
export function ReportConversionChart() { export function ReportConversionChart() {
const { isLazyLoading, report } = useReportChartContext(); const { isLazyLoading, report } = useReportChartContext();
const trpc = useTRPC(); const trpc = useTRPC();
console.log(report.limit);
const res = useQuery( const res = useQuery(
trpc.chart.conversion.queryOptions(report, { trpc.chart.conversion.queryOptions(report, {
placeholderData: keepPreviousData, placeholderData: keepPreviousData,

View File

@@ -144,14 +144,16 @@ export function Summary({ data }: Props) {
title="Flow" title="Flow"
value={ value={
<div className="row flex-wrap gap-1"> <div className="row flex-wrap gap-1">
{report.events.map((event, index) => { {report.series
return ( .filter((item) => item.type === 'event')
<div key={event.id} className="row items-center gap-2"> .map((event, index) => {
{index !== 0 && <ChevronRightIcon className="size-3" />} return (
<span>{event.name}</span> <div key={event.id} className="row items-center gap-2">
</div> {index !== 0 && <ChevronRightIcon className="size-3" />}
); <span>{event.name}</span>
})} </div>
);
})}
</div> </div>
} }
/> />

View File

@@ -1,7 +1,9 @@
import { ColorSquare } from '@/components/color-square'; import { ColorSquare } from '@/components/color-square';
import { Button } from '@/components/ui/button';
import { pushModal } from '@/modals';
import type { RouterOutputs } from '@/trpc/client'; import type { RouterOutputs } from '@/trpc/client';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import { ChevronRightIcon, InfoIcon } from 'lucide-react'; import { ChevronRightIcon, InfoIcon, UsersIcon } from 'lucide-react';
import { alphabetIds } from '@openpanel/constants'; import { alphabetIds } from '@openpanel/constants';
@@ -23,6 +25,7 @@ import {
} from 'recharts'; } from 'recharts';
import { useXAxisProps, useYAxisProps } from '../common/axis'; import { useXAxisProps, useYAxisProps } from '../common/axis';
import { PreviousDiffIndicatorPure } from '../common/previous-diff-indicator'; import { PreviousDiffIndicatorPure } from '../common/previous-diff-indicator';
import { useReportChartContext } from '../context';
type Props = { type Props = {
data: { data: {
@@ -113,11 +116,50 @@ function ChartName({
export function Tables({ export function Tables({
data: { data: {
current: { steps, mostDropoffsStep, lastStep, breakdowns }, current: { steps, mostDropoffsStep, lastStep, breakdowns },
previous, previous: previousData,
}, },
}: Props) { }: Props) {
const number = useNumber(); const number = useNumber();
const hasHeader = breakdowns.length > 0; const hasHeader = breakdowns.length > 0;
const {
report: {
projectId,
startDate,
endDate,
range,
interval,
series: reportSeries,
breakdowns: reportBreakdowns,
previous,
funnelWindow,
funnelGroup,
},
} = useReportChartContext();
const handleInspectStep = (step: (typeof steps)[0], stepIndex: number) => {
if (!projectId || !step.event.id) return;
// For funnels, we need to pass the step index so the modal can query
// users who completed at least that step in the funnel sequence
pushModal('ViewChartUsers', {
type: 'funnel',
report: {
projectId,
series: reportSeries,
breakdowns: reportBreakdowns || [],
interval: interval || 'day',
startDate,
endDate,
range,
previous,
chartType: 'funnel',
metric: 'sum',
funnelWindow,
funnelGroup,
},
stepIndex, // Pass the step index for funnel queries
});
};
return ( return (
<div className={cn('col @container divide-y divide-border card')}> <div className={cn('col @container divide-y divide-border card')}>
{hasHeader && <ChartName breakdowns={breakdowns} className="p-4 py-3" />} {hasHeader && <ChartName breakdowns={breakdowns} className="p-4 py-3" />}
@@ -128,11 +170,11 @@ export function Tables({
label="Conversion" label="Conversion"
value={number.formatWithUnit(lastStep?.percent / 100, '%')} value={number.formatWithUnit(lastStep?.percent / 100, '%')}
enhancer={ enhancer={
previous && ( previousData && (
<PreviousDiffIndicatorPure <PreviousDiffIndicatorPure
{...getPreviousMetric( {...getPreviousMetric(
lastStep?.percent, lastStep?.percent,
previous.lastStep?.percent, previousData.lastStep?.percent,
)} )}
/> />
) )
@@ -143,11 +185,11 @@ export function Tables({
label="Completed" label="Completed"
value={number.format(lastStep?.count)} value={number.format(lastStep?.count)}
enhancer={ enhancer={
previous && ( previousData && (
<PreviousDiffIndicatorPure <PreviousDiffIndicatorPure
{...getPreviousMetric( {...getPreviousMetric(
lastStep?.count, lastStep?.count,
previous.lastStep?.count, previousData.lastStep?.count,
)} )}
/> />
) )
@@ -238,6 +280,28 @@ export function Tables({
className: 'text-right font-mono font-semibold', className: 'text-right font-mono font-semibold',
width: '90px', width: '90px',
}, },
{
name: '',
render: (item) => (
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={(e) => {
e.stopPropagation();
const stepIndex = steps.findIndex(
(s) => s.event.id === item.event.id,
);
handleInspectStep(item, stepIndex);
}}
title="View users who completed this step"
>
<UsersIcon size={16} />
</Button>
),
className: 'text-right',
width: '48px',
},
]} ]}
/> />
</div> </div>
@@ -299,6 +363,7 @@ export function Chart({ data }: { data: RouterOutputs['chart']['funnel'] }) {
const rechartData = useRechartData(data); const rechartData = useRechartData(data);
const xAxisProps = useXAxisProps(); const xAxisProps = useXAxisProps();
const yAxisProps = useYAxisProps(); const yAxisProps = useYAxisProps();
const hasBreakdowns = data.current.length > 1;
return ( return (
<TooltipProvider data={data.current}> <TooltipProvider data={data.current}>
@@ -327,19 +392,37 @@ export function Chart({ data }: { data: RouterOutputs['chart']['funnel'] }) {
} }
/> />
<YAxis {...yAxisProps} /> <YAxis {...yAxisProps} />
<Bar {hasBreakdowns ? (
data={rechartData} data.current.map((item, breakdownIndex) => (
dataKey="step:percent:0" <Bar
shape={<BarShapeProps />} key={`step:percent:${item.id}`}
> dataKey={`step:percent:${breakdownIndex}`}
{rechartData.map((item, index) => ( shape={<BarShapeProps />}
<Cell >
key={item.name} {rechartData.map((item, stepIndex) => (
fill={getChartTranslucentColor(index)} <Cell
stroke={getChartColor(index)} key={`${item.name}-${breakdownIndex}`}
/> fill={getChartTranslucentColor(breakdownIndex)}
))} stroke={getChartColor(breakdownIndex)}
</Bar> />
))}
</Bar>
))
) : (
<Bar
data={rechartData}
dataKey="step:percent:0"
shape={<BarShapeProps />}
>
{rechartData.map((item, index) => (
<Cell
key={item.name}
fill={getChartTranslucentColor(index)}
stroke={getChartColor(index)}
/>
))}
</Bar>
)}
<Tooltip /> <Tooltip />
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
@@ -348,8 +431,6 @@ export function Chart({ data }: { data: RouterOutputs['chart']['funnel'] }) {
); );
} }
type Hej = RouterOutputs['chart']['funnel']['current'];
const { Tooltip, TooltipProvider } = createChartTooltip< const { Tooltip, TooltipProvider } = createChartTooltip<
RechartData, RechartData,
{ {
@@ -371,7 +452,7 @@ const { Tooltip, TooltipProvider } = createChartTooltip<
<div className="flex justify-between gap-8 text-muted-foreground"> <div className="flex justify-between gap-8 text-muted-foreground">
<div>{data.name}</div> <div>{data.name}</div>
</div> </div>
{variants.map((key) => { {variants.map((key, breakdownIndex) => {
const variant = data[key]; const variant = data[key];
const prevVariant = data[`prev_${key}`]; const prevVariant = data[`prev_${key}`];
if (!variant?.step) { if (!variant?.step) {
@@ -381,7 +462,11 @@ const { Tooltip, TooltipProvider } = createChartTooltip<
<div className="row gap-2" key={key}> <div className="row gap-2" key={key}>
<div <div
className="w-[3px] rounded-full" className="w-[3px] rounded-full"
style={{ background: getChartColor(index) }} style={{
background: getChartColor(
variants.length > 1 ? breakdownIndex : index,
),
}}
/> />
<div className="col flex-1 gap-1"> <div className="col flex-1 gap-1">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">

View File

@@ -14,7 +14,7 @@ import { Chart, Summary, Tables } from './chart';
export function ReportFunnelChart() { export function ReportFunnelChart() {
const { const {
report: { report: {
events, series,
range, range,
projectId, projectId,
funnelWindow, funnelWindow,
@@ -28,7 +28,7 @@ export function ReportFunnelChart() {
} = useReportChartContext(); } = useReportChartContext();
const input: IChartInput = { const input: IChartInput = {
events, series,
range, range,
projectId, projectId,
interval: 'day', interval: 'day',
@@ -40,11 +40,12 @@ export function ReportFunnelChart() {
metric: 'sum', metric: 'sum',
startDate, startDate,
endDate, endDate,
limit: 20,
}; };
const trpc = useTRPC(); const trpc = useTRPC();
const res = useQuery( const res = useQuery(
trpc.chart.funnel.queryOptions(input, { trpc.chart.funnel.queryOptions(input, {
enabled: !isLazyLoading && input.events.length > 0, enabled: !isLazyLoading && input.series.length > 0,
}), }),
); );

View File

@@ -7,6 +7,7 @@ import type { IChartData } from '@/trpc/client';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import { getChartColor } from '@/utils/theme'; import { getChartColor } from '@/utils/theme';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { BookmarkIcon, UsersIcon } from 'lucide-react';
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { import {
Bar, Bar,
@@ -20,6 +21,10 @@ import {
} from 'recharts'; } from 'recharts';
import { useXAxisProps, useYAxisProps } from '../common/axis'; import { useXAxisProps, useYAxisProps } from '../common/axis';
import {
ChartClickMenu,
type ChartClickMenuItem,
} from '../common/chart-click-menu';
import { ReportChartTooltip } from '../common/report-chart-tooltip'; import { ReportChartTooltip } from '../common/report-chart-tooltip';
import { ReportTable } from '../common/report-table'; import { ReportTable } from '../common/report-table';
import { useReportChartContext } from '../context'; import { useReportChartContext } from '../context';
@@ -47,7 +52,16 @@ function BarHover({ x, y, width, height, top, left, right, bottom }: any) {
export function Chart({ data }: Props) { export function Chart({ data }: Props) {
const { const {
isEditMode, isEditMode,
report: { previous, interval, projectId, startDate, endDate, range }, report: {
previous,
interval,
projectId,
startDate,
endDate,
range,
series: reportSeries,
breakdowns,
},
options: { hideXAxis, hideYAxis }, options: { hideXAxis, hideYAxis },
} = useReportChartContext(); } = useReportChartContext();
const trpc = useTRPC(); const trpc = useTRPC();
@@ -74,22 +88,73 @@ export function Chart({ data }: Props) {
interval, interval,
}); });
const handleChartClick = useCallback((e: any) => { const getMenuItems = useCallback(
if (e?.activePayload?.[0]) { (e: any, clickedData: any): ChartClickMenuItem[] => {
const clickedData = e.activePayload[0].payload; const items: ChartClickMenuItem[] = [];
if (clickedData.date) {
pushModal('AddReference', { if (!clickedData?.date) {
datetime: new Date(clickedData.date).toISOString(), return items;
}
// View Users - only show if we have projectId
if (projectId) {
items.push({
label: 'View Users',
icon: <UsersIcon size={16} />,
onClick: () => {
pushModal('ViewChartUsers', {
type: 'chart',
chartData: data,
report: {
projectId,
series: reportSeries,
breakdowns: breakdowns || [],
interval,
startDate,
endDate,
range,
previous,
chartType: 'histogram',
metric: 'sum',
},
date: clickedData.date,
});
},
}); });
} }
}
}, []); // Add Reference - always show
items.push({
label: 'Add Reference',
icon: <BookmarkIcon size={16} />,
onClick: () => {
pushModal('AddReference', {
datetime: new Date(clickedData.date).toISOString(),
});
},
});
return items;
},
[
projectId,
data,
reportSeries,
breakdowns,
interval,
startDate,
endDate,
range,
previous,
],
);
return ( return (
<ReportChartTooltip.TooltipProvider references={references.data}> <ReportChartTooltip.TooltipProvider references={references.data}>
<div className={cn('h-full w-full', isEditMode && 'card p-4')}> <ChartClickMenu getMenuItems={getMenuItems}>
<ResponsiveContainer> <div className={cn('h-full w-full', isEditMode && 'card p-4')}>
<BarChart data={rechartData} onClick={handleChartClick}> <ResponsiveContainer>
<BarChart data={rechartData}>
<CartesianGrid <CartesianGrid
strokeDasharray="3 3" strokeDasharray="3 3"
vertical={false} vertical={false}
@@ -152,6 +217,7 @@ export function Chart({ data }: Props) {
setVisibleSeries={setVisibleSeries} setVisibleSeries={setVisibleSeries}
/> />
)} )}
</ChartClickMenu>
</ReportChartTooltip.TooltipProvider> </ReportChartTooltip.TooltipProvider>
); );
} }

View File

@@ -7,6 +7,7 @@ import { cn } from '@/utils/cn';
import { getChartColor } from '@/utils/theme'; import { getChartColor } from '@/utils/theme';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { isSameDay, isSameHour, isSameMonth, isSameWeek } from 'date-fns'; import { isSameDay, isSameHour, isSameMonth, isSameWeek } from 'date-fns';
import { BookmarkIcon, UsersIcon } from 'lucide-react';
import { last } from 'ramda'; import { last } from 'ramda';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { import {
@@ -24,6 +25,10 @@ import {
import { useDashedStroke } from '@/hooks/use-dashed-stroke'; import { useDashedStroke } from '@/hooks/use-dashed-stroke';
import { useXAxisProps, useYAxisProps } from '../common/axis'; import { useXAxisProps, useYAxisProps } from '../common/axis';
import {
ChartClickMenu,
type ChartClickMenuItem,
} from '../common/chart-click-menu';
import { ReportChartTooltip } from '../common/report-chart-tooltip'; import { ReportChartTooltip } from '../common/report-chart-tooltip';
import { ReportTable } from '../common/report-table'; import { ReportTable } from '../common/report-table';
import { SerieIcon } from '../common/serie-icon'; import { SerieIcon } from '../common/serie-icon';
@@ -44,6 +49,8 @@ export function Chart({ data }: Props) {
endDate, endDate,
range, range,
lineType, lineType,
series: reportSeries,
breakdowns,
}, },
isEditMode, isEditMode,
options: { hideXAxis, hideYAxis, maxDomain }, options: { hideXAxis, hideYAxis, maxDomain },
@@ -128,81 +135,146 @@ export function Chart({ data }: Props) {
hide: hideYAxis, hide: hideYAxis,
}); });
const handleChartClick = useCallback((e: any) => { const getMenuItems = useCallback(
if (e?.activePayload?.[0]) { (e: any, clickedData: any): ChartClickMenuItem[] => {
const clickedData = e.activePayload[0].payload; const items: ChartClickMenuItem[] = [];
if (clickedData.date) {
pushModal('AddReference', { if (!clickedData?.date) {
datetime: new Date(clickedData.date).toISOString(), return items;
}
// Extract serie ID from the click event if needed
// activePayload is an array of payload objects
const validPayload = e.activePayload?.find(
(p: any) =>
p.dataKey &&
p.dataKey !== 'calcStrokeDasharray' &&
typeof p.dataKey === 'string' &&
p.dataKey.includes(':count'),
);
const serieId = validPayload?.dataKey?.toString().replace(':count', '');
// View Users - only show if we have projectId
if (projectId) {
items.push({
label: 'View Users',
icon: <UsersIcon size={16} />,
onClick: () => {
pushModal('ViewChartUsers', {
type: 'chart',
chartData: data,
report: {
projectId,
series: reportSeries,
breakdowns: breakdowns || [],
interval,
startDate,
endDate,
range,
previous,
chartType: 'linear',
metric: 'sum',
},
date: clickedData.date,
});
},
}); });
} }
}
}, []); // Add Reference - always show
items.push({
label: 'Add Reference',
icon: <BookmarkIcon size={16} />,
onClick: () => {
pushModal('AddReference', {
datetime: new Date(clickedData.date).toISOString(),
});
},
});
return items;
},
[
projectId,
data,
reportSeries,
breakdowns,
interval,
startDate,
endDate,
range,
previous,
],
);
return ( return (
<ReportChartTooltip.TooltipProvider references={references.data}> <ReportChartTooltip.TooltipProvider references={references.data}>
<div className={cn('h-full w-full', isEditMode && 'card p-4')}> <ChartClickMenu getMenuItems={getMenuItems}>
<ResponsiveContainer> <div className={cn('h-full w-full', isEditMode && 'card p-4')}>
<ComposedChart data={rechartData} onClick={handleChartClick}> <ResponsiveContainer>
<Customized component={calcStrokeDasharray} /> <ComposedChart data={rechartData}>
<Line <Customized component={calcStrokeDasharray} />
dataKey="calcStrokeDasharray" <Line
legendType="none" dataKey="calcStrokeDasharray"
animationDuration={0} legendType="none"
onAnimationEnd={handleAnimationEnd} animationDuration={0}
/> onAnimationEnd={handleAnimationEnd}
<CartesianGrid
strokeDasharray="3 3"
horizontal={true}
vertical={false}
className="stroke-border"
/>
{references.data?.map((ref) => (
<ReferenceLine
key={ref.id}
x={ref.date.getTime()}
stroke={'oklch(from var(--foreground) l c h / 0.1)'}
strokeDasharray={'3 3'}
label={{
value: ref.title,
position: 'centerTop',
fill: '#334155',
fontSize: 12,
}}
fontSize={10}
/> />
))} <CartesianGrid
<YAxis strokeDasharray="3 3"
{...yAxisProps} horizontal={true}
domain={maxDomain ? [0, maxDomain] : undefined} vertical={false}
/> className="stroke-border"
<XAxis {...xAxisProps} /> />
{series.length > 1 && <Legend content={<CustomLegend />} />} {references.data?.map((ref) => (
<Tooltip content={<ReportChartTooltip.Tooltip />} /> <ReferenceLine
{/* {series.map((serie) => { key={ref.id}
const color = getChartColor(serie.index); x={ref.date.getTime()}
return ( stroke={'oklch(from var(--foreground) l c h / 0.1)'}
<React.Fragment key={serie.id}> strokeDasharray={'3 3'}
<defs> label={{
{isAreaStyle && ( value: ref.title,
<linearGradient position: 'centerTop',
id={`color${color}`} fill: '#334155',
x1="0" fontSize: 12,
y1="0" }}
x2="0" fontSize={10}
y2="1" />
> ))}
<stop offset="0%" stopColor={color} stopOpacity={0.8} /> <YAxis
<stop {...yAxisProps}
offset="100%" domain={maxDomain ? [0, maxDomain] : undefined}
stopColor={color} />
stopOpacity={0.1} <XAxis {...xAxisProps} />
/> {series.length > 1 && <Legend content={<CustomLegend />} />}
</linearGradient> <Tooltip content={<ReportChartTooltip.Tooltip />} />
)}
</defs> <defs>
<filter
id="rainbow-line-glow"
x="-20%"
y="-20%"
width="140%"
height="140%"
>
<feGaussianBlur stdDeviation="5" result="blur" />
<feComponentTransfer in="blur" result="dimmedBlur">
<feFuncA type="linear" slope="0.5" />
</feComponentTransfer>
<feComposite
in="SourceGraphic"
in2="dimmedBlur"
operator="over"
/>
</filter>
</defs>
{series.map((serie) => {
const color = getChartColor(serie.index);
return (
<Line <Line
dot={isAreaStyle && dataLength <= 8} key={serie.id}
dot={dataLength <= 8}
type={lineType} type={lineType}
name={serie.id} name={serie.id}
isAnimationActive={false} isAnimationActive={false}
@@ -216,100 +288,46 @@ export function Chart({ data }: Props) {
} }
// Use for legend // Use for legend
fill={color} fill={color}
filter={
series.length === 1
? 'url(#rainbow-line-glow)'
: undefined
}
/> />
{previous && ( );
<Line })}
type={lineType}
name={`${serie.id}:prev`}
isAnimationActive
dot={false}
strokeOpacity={0.3}
dataKey={`${serie.id}:prev:count`}
stroke={color}
// Use for legend
fill={color}
/>
)}
</React.Fragment>
);
})} */}
<defs> {/* Previous */}
<filter {previous
id="rainbow-line-glow" ? series.map((serie) => {
x="-20%" const color = getChartColor(serie.index);
y="-20%" return (
width="140%" <Line
height="140%" key={`${serie.id}:prev`}
> type={lineType}
<feGaussianBlur stdDeviation="5" result="blur" /> name={`${serie.id}:prev`}
<feComponentTransfer in="blur" result="dimmedBlur"> isAnimationActive
<feFuncA type="linear" slope="0.5" /> dot={false}
</feComponentTransfer> strokeOpacity={0.3}
<feComposite dataKey={`${serie.id}:prev:count`}
in="SourceGraphic" stroke={color}
in2="dimmedBlur" // Use for legend
operator="over" fill={color}
/> />
</filter> );
</defs> })
: null}
{series.map((serie) => { </ComposedChart>
const color = getChartColor(serie.index); </ResponsiveContainer>
return ( </div>
<Line {isEditMode && (
key={serie.id} <ReportTable
dot={dataLength <= 8} data={data}
type={lineType} visibleSeries={series}
name={serie.id} setVisibleSeries={setVisibleSeries}
isAnimationActive={false} />
strokeWidth={2} )}
dataKey={`${serie.id}:count`} </ChartClickMenu>
stroke={color}
strokeDasharray={
useDashedLastLine
? getStrokeDasharray(`${serie.id}:count`)
: undefined
}
// Use for legend
fill={color}
filter={
series.length === 1 ? 'url(#rainbow-line-glow)' : undefined
}
/>
);
})}
{/* Previous */}
{previous
? series.map((serie) => {
const color = getChartColor(serie.index);
return (
<Line
key={`${serie.id}:prev`}
type={lineType}
name={`${serie.id}:prev`}
isAnimationActive
dot={false}
strokeOpacity={0.3}
dataKey={`${serie.id}:prev:count`}
stroke={color}
// Use for legend
fill={color}
/>
);
})
: null}
</ComposedChart>
</ResponsiveContainer>
</div>
{isEditMode && (
<ReportTable
data={data}
visibleSeries={series}
setVisibleSeries={setVisibleSeries}
/>
)}
</ReportChartTooltip.TooltipProvider> </ReportChartTooltip.TooltipProvider>
); );
} }

View File

@@ -12,7 +12,7 @@ import CohortTable from './table';
export function ReportRetentionChart() { export function ReportRetentionChart() {
const { const {
report: { report: {
events, series,
range, range,
projectId, projectId,
startDate, startDate,
@@ -22,8 +22,9 @@ export function ReportRetentionChart() {
}, },
isLazyLoading, isLazyLoading,
} = useReportChartContext(); } = useReportChartContext();
const firstEvent = (events[0]?.filters[0]?.value ?? []).map(String); const eventSeries = series.filter((item) => item.type === 'event');
const secondEvent = (events[1]?.filters[0]?.value ?? []).map(String); const firstEvent = (eventSeries[0]?.filters?.[0]?.value ?? []).map(String);
const secondEvent = (eventSeries[1]?.filters?.[0]?.value ?? []).map(String);
const isEnabled = const isEnabled =
firstEvent.length > 0 && secondEvent.length > 0 && !isLazyLoading; firstEvent.length > 0 && secondEvent.length > 0 && !isLazyLoading;
const trpc = useTRPC(); const trpc = useTRPC();

View File

@@ -7,7 +7,7 @@ type ChartRootShortcutProps = Omit<ReportChartProps, 'report'> & {
previous?: ReportChartProps['report']['previous']; previous?: ReportChartProps['report']['previous'];
chartType?: ReportChartProps['report']['chartType']; chartType?: ReportChartProps['report']['chartType'];
interval?: ReportChartProps['report']['interval']; interval?: ReportChartProps['report']['interval'];
events: ReportChartProps['report']['events']; series: ReportChartProps['report']['series'];
breakdowns?: ReportChartProps['report']['breakdowns']; breakdowns?: ReportChartProps['report']['breakdowns'];
lineType?: ReportChartProps['report']['lineType']; lineType?: ReportChartProps['report']['lineType'];
}; };
@@ -18,7 +18,7 @@ export const ReportChartShortcut = ({
previous = false, previous = false,
chartType = 'linear', chartType = 'linear',
interval = 'day', interval = 'day',
events, series,
breakdowns, breakdowns,
lineType = 'monotone', lineType = 'monotone',
options, options,
@@ -33,7 +33,7 @@ export const ReportChartShortcut = ({
previous, previous,
chartType, chartType,
interval, interval,
events, series,
lineType, lineType,
metric: 'sum', metric: 'sum',
}} }}

View File

@@ -11,12 +11,14 @@ import {
} from '@openpanel/constants'; } from '@openpanel/constants';
import type { import type {
IChartBreakdown, IChartBreakdown,
IChartEvent, IChartEventItem,
IChartFormula,
IChartLineType, IChartLineType,
IChartProps, IChartProps,
IChartRange, IChartRange,
IChartType, IChartType,
IInterval, IInterval,
UnionOmit,
zCriteria, zCriteria,
} from '@openpanel/validation'; } from '@openpanel/validation';
import type { z } from 'zod'; import type { z } from 'zod';
@@ -39,7 +41,7 @@ const initialState: InitialState = {
lineType: 'monotone', lineType: 'monotone',
interval: 'day', interval: 'day',
breakdowns: [], breakdowns: [],
events: [], series: [],
range: '30d', range: '30d',
startDate: null, startDate: null,
endDate: null, endDate: null,
@@ -86,24 +88,34 @@ export const reportSlice = createSlice({
state.dirty = true; state.dirty = true;
state.name = action.payload; state.name = action.payload;
}, },
// Events // Series (Events and Formulas)
addEvent: (state, action: PayloadAction<Omit<IChartEvent, 'id'>>) => { addSerie: (
state,
action: PayloadAction<UnionOmit<IChartEventItem, 'id'>>,
) => {
state.dirty = true; state.dirty = true;
state.events.push({ state.series.push({
id: shortId(), id: shortId(),
...action.payload, ...action.payload,
}); });
}, },
duplicateEvent: (state, action: PayloadAction<Omit<IChartEvent, 'id'>>) => { duplicateEvent: (state, action: PayloadAction<IChartEventItem>) => {
state.dirty = true; state.dirty = true;
state.events.push({ if (action.payload.type === 'event') {
...action.payload, state.series.push({
filters: action.payload.filters.map((filter) => ({ ...action.payload,
...filter, filters: action.payload.filters.map((filter) => ({
...filter,
id: shortId(),
})),
id: shortId(), id: shortId(),
})), } as IChartEventItem);
id: shortId(), } else {
}); state.series.push({
...action.payload,
id: shortId(),
} as IChartEventItem);
}
}, },
removeEvent: ( removeEvent: (
state, state,
@@ -112,13 +124,13 @@ export const reportSlice = createSlice({
}>, }>,
) => { ) => {
state.dirty = true; state.dirty = true;
state.events = state.events.filter( state.series = state.series.filter((event) => {
(event) => event.id !== action.payload.id, return event.id !== action.payload.id;
); });
}, },
changeEvent: (state, action: PayloadAction<IChartEvent>) => { changeEvent: (state, action: PayloadAction<IChartEventItem>) => {
state.dirty = true; state.dirty = true;
state.events = state.events.map((event) => { state.series = state.series.map((event) => {
if (event.id === action.payload.id) { if (event.id === action.payload.id) {
return action.payload; return action.payload;
} }
@@ -265,9 +277,9 @@ export const reportSlice = createSlice({
) { ) {
state.dirty = true; state.dirty = true;
const { fromIndex, toIndex } = action.payload; const { fromIndex, toIndex } = action.payload;
const [movedEvent] = state.events.splice(fromIndex, 1); const [movedEvent] = state.series.splice(fromIndex, 1);
if (movedEvent) { if (movedEvent) {
state.events.splice(toIndex, 0, movedEvent); state.series.splice(toIndex, 0, movedEvent);
} }
}, },
}, },
@@ -279,7 +291,7 @@ export const {
ready, ready,
setReport, setReport,
setName, setName,
addEvent, addSerie,
removeEvent, removeEvent,
duplicateEvent, duplicateEvent,
changeEvent, changeEvent,

View File

@@ -1,8 +1,7 @@
import { Combobox } from '@/components/ui/combobox'; import { Combobox } from '@/components/ui/combobox';
import { useAppParams } from '@/hooks/use-app-params'; import { useAppParams } from '@/hooks/use-app-params';
import { useEventProperties } from '@/hooks/use-event-properties'; import { useEventProperties } from '@/hooks/use-event-properties';
import { useDispatch, useSelector } from '@/redux'; import { useDispatch } from '@/redux';
import { api } from '@/trpc/client';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import { DatabaseIcon } from 'lucide-react'; import { DatabaseIcon } from 'lucide-react';
@@ -43,6 +42,7 @@ export function EventPropertiesCombobox({
changeEvent({ changeEvent({
...event, ...event,
property: value, property: value,
type: 'event',
}), }),
); );
}} }}

View File

@@ -1,6 +1,8 @@
import { ColorSquare } from '@/components/color-square'; import { ColorSquare } from '@/components/color-square';
import { Button } from '@/components/ui/button';
import { ComboboxEvents } from '@/components/ui/combobox-events'; import { ComboboxEvents } from '@/components/ui/combobox-events';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { InputEnter } from '@/components/ui/input-enter';
import { useAppParams } from '@/hooks/use-app-params'; import { useAppParams } from '@/hooks/use-app-params';
import { useDebounceFn } from '@/hooks/use-debounce-fn'; import { useDebounceFn } from '@/hooks/use-debounce-fn';
import { useEventNames } from '@/hooks/use-event-names'; import { useEventNames } from '@/hooks/use-event-names';
@@ -23,11 +25,11 @@ import {
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import { shortId } from '@openpanel/common'; import { shortId } from '@openpanel/common';
import { alphabetIds } from '@openpanel/constants'; import { alphabetIds } from '@openpanel/constants';
import type { IChartEvent } from '@openpanel/validation'; import type { IChartEventItem, IChartFormula } from '@openpanel/validation';
import { FilterIcon, HandIcon } from 'lucide-react'; import { FilterIcon, HandIcon, PlusIcon } from 'lucide-react';
import { ReportSegment } from '../ReportSegment'; import { ReportSegment } from '../ReportSegment';
import { import {
addEvent, addSerie,
changeEvent, changeEvent,
duplicateEvent, duplicateEvent,
removeEvent, removeEvent,
@@ -47,7 +49,7 @@ function SortableEvent({
isSelectManyEvents, isSelectManyEvents,
...props ...props
}: { }: {
event: IChartEvent; event: IChartEventItem;
index: number; index: number;
showSegment: boolean; showSegment: boolean;
showAddFilter: boolean; showAddFilter: boolean;
@@ -62,6 +64,8 @@ function SortableEvent({
transition, transition,
}; };
const isEvent = event.type === 'event';
return ( return (
<div ref={setNodeRef} style={style} {...attributes} {...props}> <div ref={setNodeRef} style={style} {...attributes} {...props}>
<div className="flex items-center gap-2 p-2 group"> <div className="flex items-center gap-2 p-2 group">
@@ -76,8 +80,8 @@ function SortableEvent({
{props.children} {props.children}
</div> </div>
{/* Segment and Filter buttons */} {/* Segment and Filter buttons - only for events */}
{(showSegment || showAddFilter) && ( {isEvent && (showSegment || showAddFilter) && (
<div className="flex gap-2 p-2 pt-0"> <div className="flex gap-2 p-2 pt-0">
{showSegment && ( {showSegment && (
<ReportSegment <ReportSegment
@@ -130,14 +134,14 @@ function SortableEvent({
</div> </div>
)} )}
{/* Filters */} {/* Filters - only for events */}
{!isSelectManyEvents && <FiltersList event={event} />} {isEvent && !isSelectManyEvents && <FiltersList event={event} />}
</div> </div>
); );
} }
export function ReportEvents() { export function ReportEvents() {
const selectedEvents = useSelector((state) => state.report.events); const selectedEvents = useSelector((state) => state.report.series);
const chartType = useSelector((state) => state.report.chartType); const chartType = useSelector((state) => state.report.chartType);
const dispatch = useDispatch(); const dispatch = useDispatch();
const { projectId } = useAppParams(); const { projectId } = useAppParams();
@@ -151,7 +155,7 @@ export function ReportEvents() {
const isAddEventDisabled = const isAddEventDisabled =
(chartType === 'retention' || chartType === 'conversion') && (chartType === 'retention' || chartType === 'conversion') &&
selectedEvents.length >= 2; selectedEvents.length >= 2;
const dispatchChangeEvent = useDebounceFn((event: IChartEvent) => { const dispatchChangeEvent = useDebounceFn((event: IChartEventItem) => {
dispatch(changeEvent(event)); dispatch(changeEvent(event));
}); });
const isSelectManyEvents = chartType === 'retention'; const isSelectManyEvents = chartType === 'retention';
@@ -174,11 +178,15 @@ export function ReportEvents() {
} }
}; };
const handleMore = (event: IChartEvent) => { const handleMore = (event: IChartEventItem) => {
const callback: ReportEventMoreProps['onClick'] = (action) => { const callback: ReportEventMoreProps['onClick'] = (action) => {
switch (action) { switch (action) {
case 'remove': { case 'remove': {
return dispatch(removeEvent(event)); return dispatch(
removeEvent({
id: event.id,
}),
);
} }
case 'duplicate': { case 'duplicate': {
return dispatch(duplicateEvent(event)); return dispatch(duplicateEvent(event));
@@ -189,20 +197,31 @@ export function ReportEvents() {
return callback; return callback;
}; };
const dispatchChangeFormula = useDebounceFn((formula: IChartFormula) => {
dispatch(changeEvent(formula));
});
const showFormula =
chartType !== 'conversion' &&
chartType !== 'funnel' &&
chartType !== 'retention';
return ( return (
<div> <div>
<h3 className="mb-2 font-medium">Events</h3> <h3 className="mb-2 font-medium">Metrics</h3>
<DndContext <DndContext
sensors={sensors} sensors={sensors}
collisionDetection={closestCenter} collisionDetection={closestCenter}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
> >
<SortableContext <SortableContext
items={selectedEvents.map((e) => ({ id: e.id ?? '' }))} items={selectedEvents.map((e) => ({ id: e.id! }))}
strategy={verticalListSortingStrategy} strategy={verticalListSortingStrategy}
> >
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{selectedEvents.map((event, index) => { {selectedEvents.map((event, index) => {
const isFormula = event.type === 'formula';
return ( return (
<SortableEvent <SortableEvent
key={event.id} key={event.id}
@@ -213,95 +232,151 @@ export function ReportEvents() {
isSelectManyEvents={isSelectManyEvents} isSelectManyEvents={isSelectManyEvents}
className="rounded-lg border bg-def-100" className="rounded-lg border bg-def-100"
> >
<ComboboxEvents {isFormula ? (
className="flex-1" <>
searchable <div className="flex-1 flex flex-col gap-2">
multiple={isSelectManyEvents as false} <InputEnter
value={ placeholder="eg: A+B, A/B"
(isSelectManyEvents value={event.formula}
? (event.filters[0]?.value ?? []) onChangeValue={(value) => {
: event.name) as any dispatchChangeFormula({
} ...event,
onChange={(value) => { formula: value,
dispatch( });
changeEvent( }}
Array.isArray(value) />
? { {showDisplayNameInput && (
id: event.id, <Input
segment: 'user', placeholder={`Formula (${alphabetIds[index]})`}
filters: [ defaultValue={event.displayName}
{ onChange={(e) => {
name: 'name', dispatchChangeFormula({
operator: 'is',
value: value,
},
],
name: '*',
}
: {
...event, ...event,
name: value, displayName: e.target.value,
filters: [], });
}, }}
), />
); )}
}} </div>
items={eventNames} <ReportEventMore onClick={handleMore(event)} />
placeholder="Select event" </>
/> ) : (
{showDisplayNameInput && ( <>
<Input <ComboboxEvents
placeholder={ className="flex-1"
event.name searchable
? `${event.name} (${alphabetIds[index]})` multiple={isSelectManyEvents as false}
: 'Display name' value={
} isSelectManyEvents
defaultValue={event.displayName} ? (event.filters[0]?.value ?? [])
onChange={(e) => { : (event.name as any)
dispatchChangeEvent({ }
...event, onChange={(value) => {
displayName: e.target.value, dispatch(
}); changeEvent(
}} Array.isArray(value)
/> ? {
id: event.id,
type: 'event',
segment: 'user',
filters: [
{
name: 'name',
operator: 'is',
value: value,
},
],
name: '*',
}
: {
...event,
type: 'event',
name: value,
filters: [],
},
),
);
}}
items={eventNames}
placeholder="Select event"
/>
{showDisplayNameInput && (
<Input
placeholder={
event.name
? `${event.name} (${alphabetIds[index]})`
: 'Display name'
}
defaultValue={event.displayName}
onChange={(e) => {
dispatchChangeEvent({
...event,
displayName: e.target.value,
});
}}
/>
)}
<ReportEventMore onClick={handleMore(event)} />
</>
)} )}
<ReportEventMore onClick={handleMore(event)} />
</SortableEvent> </SortableEvent>
); );
})} })}
<ComboboxEvents <div className="flex gap-2">
disabled={isAddEventDisabled} <ComboboxEvents
value={''} disabled={isAddEventDisabled}
searchable value={''}
onChange={(value) => { searchable
if (isSelectManyEvents) { onChange={(value) => {
dispatch( if (isSelectManyEvents) {
addEvent({ dispatch(
segment: 'user', addSerie({
name: value, type: 'event',
filters: [ segment: 'user',
{ name: value,
name: 'name', filters: [
operator: 'is', {
value: [value], name: 'name',
}, operator: 'is',
], value: [value],
}), },
); ],
} else { }),
dispatch( );
addEvent({ } else {
name: value, dispatch(
segment: 'event', addSerie({
filters: [], type: 'event',
}), name: value,
); segment: 'event',
} filters: [],
}} }),
placeholder="Select event" );
items={eventNames} }
/> }}
placeholder="Select event"
items={eventNames}
/>
{showFormula && (
<Button
type="button"
variant="outline"
icon={PlusIcon}
onClick={() => {
dispatch(
addSerie({
type: 'formula',
formula: '',
displayName: '',
}),
);
}}
>
Add Formula
</Button>
)}
</div>
</div> </div>
</SortableContext> </SortableContext>
</DndContext> </DndContext>

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