From ccd1a1456fe7ca1a9387e748910e5626d48aa658 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Sun, 4 Feb 2024 13:23:21 +0100 Subject: [PATCH] a lot --- .gitignore | 4 +- .vscode/settings.json | 5 +- ROADMAP.md | 15 + apps/sdk-api/package.json | 43 + apps/sdk-api/scripts/test-events.ts | 226 ++++++ .../src/controllers/event.controller.ts | 176 ++++ apps/sdk-api/src/index.ts | 78 ++ apps/sdk-api/src/routes/event.router.ts | 18 + apps/sdk-api/src/utils/auth.ts | 43 + apps/sdk-api/src/utils/parseIp.ts | 25 + apps/sdk-api/src/utils/parseUserAgent.ts | 29 + apps/sdk-api/tsconfig.json | 12 + apps/sdk-api/tsup.config.ts | 18 + apps/web/package.json | 6 + .../[dashboardId]/list-reports.tsx | 16 +- .../{ => dashboards}/[dashboardId]/page.tsx | 0 .../{ => dashboards}/header-dashboards.tsx | 2 +- .../{ => dashboards}/list-dashboards.tsx | 2 +- .../[projectId]/dashboards/page.tsx | 25 + .../[projectId]/events/event-list-item.tsx | 4 +- .../[projectId]/overview-metrics.tsx | 272 +++++++ .../[organizationId]/[projectId]/page.tsx | 15 +- .../[projectId]/reports/report-editor.tsx | 11 +- .../src/app/(app)/[organizationId]/page.tsx | 7 +- apps/web/src/app/(app)/layout-menu.tsx | 28 +- .../app/(app)/layout-sticky-below-header.tsx | 2 +- apps/web/src/app/(app)/layout.tsx | 7 +- apps/web/src/app/api/fml/route.ts | 10 - apps/web/src/app/cookie-provider.tsx | 24 - apps/web/src/app/layout.tsx | 7 +- apps/web/src/app/providers.tsx | 12 +- .../app/share/project/[projectId]/page.tsx | 33 + apps/web/src/components/ColorSquare.tsx | 6 + apps/web/src/components/Logo.tsx | 14 +- apps/web/src/components/Widget.tsx | 6 +- .../src/components/events/ListProperties.tsx | 2 +- apps/web/src/components/navbar/NavbarMenu.tsx | 3 +- .../overview/overview-filters-buttons.tsx | 129 +++ .../components/overview/overview-filters.tsx | 121 +++ .../overview/overview-top-devices.tsx | 199 +++++ .../overview/overview-top-events.tsx | 75 ++ .../components/overview/overview-top-geo.tsx | 171 ++++ .../overview/overview-top-pages.tsx | 130 +++ .../overview/overview-top-sources.tsx | 245 ++++++ .../components/overview/overview-widget.tsx | 25 + .../components/overview/useOverviewOptions.ts | 234 ++++++ .../components/overview/useOverviewWidget.tsx | 27 + .../report/PreviousDiffIndicator.tsx | 37 +- .../src/components/report/ReportDateRange.tsx | 28 - .../web/src/components/report/ReportRange.tsx | 20 + .../components/report/ReportSaveButton.tsx | 1 - .../components/report/chart/ChartProvider.tsx | 31 +- .../components/report/chart/MetricCard.tsx | 81 ++ .../report/chart/ReportAreaChart.tsx | 2 +- .../report/chart/ReportBarChart.tsx | 199 +++-- .../report/chart/ReportChartTooltip.tsx | 16 +- .../report/chart/ReportHistogramChart.tsx | 2 +- .../report/chart/ReportLineChart.tsx | 4 +- .../report/chart/ReportMapChart.tsx | 40 + .../report/chart/ReportMetricChart.tsx | 72 +- .../report/chart/ReportPieChart.tsx | 120 +-- .../web/src/components/report/chart/index.tsx | 29 +- apps/web/src/components/report/reportSlice.ts | 19 + .../sidebar/EventPropertiesCombobox.tsx | 62 ++ .../report/sidebar/ReportEventMore.tsx | 6 +- .../report/sidebar/ReportEvents.tsx | 69 +- .../report/sidebar/ReportForumula.tsx | 26 + .../report/sidebar/ReportSidebar.tsx | 2 + .../FilterItem.tsx} | 89 +-- .../sidebar/filters/FiltersCombobox.tsx | 61 ++ .../report/sidebar/filters/FiltersList.tsx | 19 + apps/web/src/components/ui/badge.tsx | 4 +- apps/web/src/components/ui/button.tsx | 2 +- apps/web/src/components/ui/combobox.tsx | 27 +- apps/web/src/components/ui/sheet.tsx | 2 +- apps/web/src/components/ui/table.tsx | 9 +- apps/web/src/hooks/useNumerFormatter.ts | 7 +- apps/web/src/hooks/useRechartDataModel.ts | 10 +- apps/web/src/hooks/useVisibleSeries.ts | 1 + apps/web/src/modals/SaveReport.tsx | 2 +- .../src/server/api/routers/chart.formula.ts | 264 ++++++ apps/web/src/server/api/routers/chart.ts | 752 +++++++----------- apps/web/src/server/api/routers/event.ts | 52 +- apps/web/src/server/api/routers/report.ts | 6 +- apps/web/src/server/chart-sql/getChartSql.ts | 79 -- apps/web/src/server/chart-sql/helpers.ts | 140 ---- apps/web/src/server/services/chart.service.ts | 167 ++++ apps/web/src/server/services/event.service.ts | 10 + .../server/services/organization.service.ts | 12 + .../src/server/services/project.service.ts | 13 + .../src/server/services/reports.service.ts | 9 +- apps/web/src/types/index.ts | 9 +- apps/web/src/utils/constants.ts | 28 +- apps/web/src/utils/math.ts | 18 +- apps/web/src/utils/truncate.ts | 6 + apps/web/src/utils/validation.ts | 25 +- apps/worker/package.json | 1 + apps/worker/src/index.ts | 48 +- apps/worker/src/jobs/cron.salt.ts | 22 + apps/worker/src/jobs/cron.ts | 13 + .../src/jobs/events.create-session-end.ts | 56 ++ apps/worker/src/jobs/events.ts | 265 +----- packages/common/index.ts | 4 + packages/common/package.json | 32 + packages/common/src/crypto.ts | 51 ++ packages/common/src/date.ts | 7 + .../utils => packages/common/src}/object.ts | 0 packages/common/src/profileId.ts | 17 + packages/common/tsconfig.json | 12 + packages/db/clickhouse_tables.sql | 46 ++ packages/db/index.ts | 21 +- packages/db/package.json | 6 +- .../migration.sql | 17 + .../20240129163925_add_salts/migration.sql | 8 + .../20240131204540_add_formula/migration.sql | 2 + .../20240131212106_add_metric/migration.sql | 6 + .../migration.sql | 2 + packages/db/prisma/schema.prisma | 32 +- packages/db/scripts/insert.ts | 163 ++++ packages/db/src/clickhouse-client.ts | 51 ++ packages/db/src/prisma-client.ts | 15 + packages/db/src/prisma-types.ts | 22 + packages/db/src/services/event.service.ts | 113 +++ packages/db/src/services/salt.ts | 37 + packages/db/src/sql-builder.ts | 56 ++ packages/queue/index.ts | 2 + packages/queue/package.json | 1 + packages/queue/src/queues.ts | 27 +- packages/queue/src/utils.ts | 8 + packages/redis/index.ts | 3 + packages/redis/package.json | 32 + packages/redis/tsconfig.json | 12 + packages/sdk/index.ts | 254 +----- pnpm-lock.yaml | 629 ++++++++++++++- tooling/typescript/base.json | 2 +- 135 files changed, 5588 insertions(+), 1758 deletions(-) create mode 100644 ROADMAP.md create mode 100644 apps/sdk-api/package.json create mode 100644 apps/sdk-api/scripts/test-events.ts create mode 100644 apps/sdk-api/src/controllers/event.controller.ts create mode 100644 apps/sdk-api/src/index.ts create mode 100644 apps/sdk-api/src/routes/event.router.ts create mode 100644 apps/sdk-api/src/utils/auth.ts create mode 100644 apps/sdk-api/src/utils/parseIp.ts create mode 100644 apps/sdk-api/src/utils/parseUserAgent.ts create mode 100644 apps/sdk-api/tsconfig.json create mode 100644 apps/sdk-api/tsup.config.ts rename apps/web/src/app/(app)/[organizationId]/[projectId]/{ => dashboards}/[dashboardId]/list-reports.tsx (92%) rename apps/web/src/app/(app)/[organizationId]/[projectId]/{ => dashboards}/[dashboardId]/page.tsx (100%) rename apps/web/src/app/(app)/[organizationId]/[projectId]/{ => dashboards}/header-dashboards.tsx (91%) rename apps/web/src/app/(app)/[organizationId]/[projectId]/{ => dashboards}/list-dashboards.tsx (97%) create mode 100644 apps/web/src/app/(app)/[organizationId]/[projectId]/dashboards/page.tsx create mode 100644 apps/web/src/app/(app)/[organizationId]/[projectId]/overview-metrics.tsx delete mode 100644 apps/web/src/app/api/fml/route.ts delete mode 100644 apps/web/src/app/cookie-provider.tsx create mode 100644 apps/web/src/app/share/project/[projectId]/page.tsx create mode 100644 apps/web/src/components/overview/overview-filters-buttons.tsx create mode 100644 apps/web/src/components/overview/overview-filters.tsx create mode 100644 apps/web/src/components/overview/overview-top-devices.tsx create mode 100644 apps/web/src/components/overview/overview-top-events.tsx create mode 100644 apps/web/src/components/overview/overview-top-geo.tsx create mode 100644 apps/web/src/components/overview/overview-top-pages.tsx create mode 100644 apps/web/src/components/overview/overview-top-sources.tsx create mode 100644 apps/web/src/components/overview/overview-widget.tsx create mode 100644 apps/web/src/components/overview/useOverviewOptions.ts create mode 100644 apps/web/src/components/overview/useOverviewWidget.tsx delete mode 100644 apps/web/src/components/report/ReportDateRange.tsx create mode 100644 apps/web/src/components/report/ReportRange.tsx create mode 100644 apps/web/src/components/report/chart/MetricCard.tsx create mode 100644 apps/web/src/components/report/chart/ReportMapChart.tsx create mode 100644 apps/web/src/components/report/sidebar/EventPropertiesCombobox.tsx create mode 100644 apps/web/src/components/report/sidebar/ReportForumula.tsx rename apps/web/src/components/report/sidebar/{ReportEventFilters.tsx => filters/FilterItem.tsx} (57%) create mode 100644 apps/web/src/components/report/sidebar/filters/FiltersCombobox.tsx create mode 100644 apps/web/src/components/report/sidebar/filters/FiltersList.tsx create mode 100644 apps/web/src/server/api/routers/chart.formula.ts delete mode 100644 apps/web/src/server/chart-sql/getChartSql.ts delete mode 100644 apps/web/src/server/chart-sql/helpers.ts create mode 100644 apps/web/src/server/services/chart.service.ts create mode 100644 apps/web/src/utils/truncate.ts create mode 100644 apps/worker/src/jobs/cron.salt.ts create mode 100644 apps/worker/src/jobs/cron.ts create mode 100644 apps/worker/src/jobs/events.create-session-end.ts create mode 100644 packages/common/index.ts create mode 100644 packages/common/package.json create mode 100644 packages/common/src/crypto.ts create mode 100644 packages/common/src/date.ts rename {apps/web/src/utils => packages/common/src}/object.ts (100%) create mode 100644 packages/common/src/profileId.ts create mode 100644 packages/common/tsconfig.json create mode 100644 packages/db/clickhouse_tables.sql create mode 100644 packages/db/prisma/migrations/20240122191559_remove_foreigen_key_profile_events/migration.sql create mode 100644 packages/db/prisma/migrations/20240129163925_add_salts/migration.sql create mode 100644 packages/db/prisma/migrations/20240131204540_add_formula/migration.sql create mode 100644 packages/db/prisma/migrations/20240131212106_add_metric/migration.sql create mode 100644 packages/db/prisma/migrations/20240202175049_add_events_count_on_project/migration.sql create mode 100644 packages/db/scripts/insert.ts create mode 100644 packages/db/src/clickhouse-client.ts create mode 100644 packages/db/src/prisma-client.ts create mode 100644 packages/db/src/prisma-types.ts create mode 100644 packages/db/src/services/event.service.ts create mode 100644 packages/db/src/services/salt.ts create mode 100644 packages/db/src/sql-builder.ts create mode 100644 packages/queue/src/utils.ts create mode 100644 packages/redis/index.ts create mode 100644 packages/redis/package.json create mode 100644 packages/redis/tsconfig.json diff --git a/.gitignore b/.gitignore index 233e84e5..a70ee3fd 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,9 @@ packages/sdk/profileId.txt packages/sdk/test.ts dump.sql -dump-*.sql +dump-* +.sql +clickhouse # Logs diff --git a/.vscode/settings.json b/.vscode/settings.json index cc89dd2d..1e76b8a0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -15,5 +15,8 @@ "typescript.preferences.autoImportFileExcludePatterns": [ "next/router.d.ts", "next/dist/client/router.d.ts" - ] + ], + "[sql]": { + "editor.defaultFormatter": "adpyke.vscode-sql-formatter" + } } diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 00000000..4eb2bc6d --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,15 @@ +# Roadmap + +## Simple todos + +- [ ] add session_id on events table, link this id on create +- [ ] add overview page containing + - [x] User histogram (last 30 minutes) + - [ ] Bounce rate + - [ ] Session duration + - [ ] Views per session + - [ ] Unique users + - [ ] Total users + - [ ] Total pageviews + - [ ] Total events +- [ ] diff --git a/apps/sdk-api/package.json b/apps/sdk-api/package.json new file mode 100644 index 00000000..302bfa7a --- /dev/null +++ b/apps/sdk-api/package.json @@ -0,0 +1,43 @@ +{ + "name": "@mixan/sdk-api", + "version": "0.0.1", + "scripts": { + "dev": "dotenv -e ../../.env -c -v WATCH=1 tsup", + "start": "node dist/index.js", + "build": "rm -rf dist && tsup", + "lint": "eslint .", + "format": "prettier --check \"**/*.{mjs,ts,md,json}\"", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@fastify/cors": "^9.0.0", + "@mixan/common": "workspace:*", + "@mixan/db": "workspace:*", + "@mixan/queue": "workspace:*", + "fastify": "^4.25.2", + "pino": "^8.17.2", + "ramda": "^0.29.1", + "request-ip": "^3.3.0", + "ua-parser-js": "^1.0.37" + }, + "devDependencies": { + "@mixan/eslint-config": "workspace:*", + "@mixan/prettier-config": "workspace:*", + "@mixan/tsconfig": "workspace:*", + "@mixan/types": "workspace:*", + "@types/ramda": "^0.29.6", + "@types/request-ip": "^0.0.41", + "@types/ua-parser-js": "^0.7.39", + "eslint": "^8.48.0", + "prettier": "^3.0.3", + "tsup": "^7.2.0", + "typescript": "^5.2.2" + }, + "eslintConfig": { + "root": true, + "extends": [ + "@mixan/eslint-config/base" + ] + }, + "prettier": "@mixan/prettier-config" +} diff --git a/apps/sdk-api/scripts/test-events.ts b/apps/sdk-api/scripts/test-events.ts new file mode 100644 index 00000000..7df343be --- /dev/null +++ b/apps/sdk-api/scripts/test-events.ts @@ -0,0 +1,226 @@ +import { omit, prop, uniqBy } from 'ramda'; + +import { generateProfileId, getTime, toISOString } from '@mixan/common'; +import type { Event, IServiceCreateEventPayload } from '@mixan/db'; +import { + createEvent as createClickhouseEvent, + db, + formatClickhouseDate, + getSalts, +} from '@mixan/db'; + +import { parseIp } from '../src/utils/parseIp'; +import { parseUserAgent } from '../src/utils/parseUserAgent'; + +const clean = omit([ + 'ip', + 'os', + 'ua', + 'url', + 'hash', + 'host', + 'path', + 'device', + 'screen', + 'hostname', + 'language', + 'referrer', + 'timezone', +]); +async function main() { + const events = await db.event.findMany({ + where: { + project_id: '4e2798cb-e255-4e9d-960d-c9ad095aabd7', + name: 'screen_view', + createdAt: { + gte: new Date('2024-01-01'), + lt: new Date('2024-02-04'), + }, + }, + orderBy: { + createdAt: 'asc', + }, + }); + + const grouped: Record = {}; + let index = 0; + for (const event of events.slice()) { + // console.log(index, event.name, event.createdAt.toISOString()); + index++; + + const properties = event.properties as Record; + + if (properties.ua?.includes('bot')) { + // console.log('IGNORE', event.id); + continue; + } + + if (!event.profile_id) { + // console.log('IGNORE', event.id); + continue; + } + const hej = grouped[event.profile_id]; + if (hej) { + hej.push(event); + } else { + grouped[event.profile_id] = [event]; + } + } + + console.log('Total users', Object.keys(grouped).length); + + let uidx = -1; + for (const profile_id of Object.keys(grouped)) { + uidx++; + console.log(`User index ${uidx}`); + + const events = uniqBy(prop('createdAt'), grouped[profile_id] || []); + + if (events) { + let lastSessionStart = null; + let screenViews = 0; + let totalDuration = 0; + console.log('new user...'); + let eidx = -1; + for (const event of events) { + eidx++; + const prevEvent = events[eidx - 1]; + const prevEventAt = prevEvent?.createdAt; + + const nextEvent = events[eidx + 1]; + + const properties = event.properties as Record; + const projectId = event.project_id; + const path = properties.path!; + const ip = properties.ip!; + const origin = 'https://mixan.kiddo.se'; + const ua = properties.ua!; + const uaInfo = parseUserAgent(ua); + const salts = await getSalts(); + + const [profileId, geo] = await Promise.all([ + generateProfileId({ + salt: salts.current, + origin, + ip, + ua, + }), + parseIp(ip), + ]); + + const isNextEventNewSession = + nextEvent && + nextEvent.createdAt.getTime() - event.createdAt.getTime() > + 1000 * 60 * 30; + + const payload: IServiceCreateEventPayload = { + name: event.name, + profileId, + projectId, + properties: clean(properties), + createdAt: event.createdAt.toISOString(), + country: geo.country, + city: geo.city, + region: geo.region, + continent: geo.continent, + os: uaInfo.os, + osVersion: uaInfo.osVersion, + browser: uaInfo.browser, + browserVersion: uaInfo.browserVersion, + device: uaInfo.device, + brand: uaInfo.brand, + model: uaInfo.model, + duration: + nextEvent && !isNextEventNewSession + ? nextEvent.createdAt.getTime() - event.createdAt.getTime() + : 0, + path, + referrer: properties?.referrer?.host ?? '', // TODO + referrerName: properties?.referrer?.host ?? '', // TODO + }; + + if (!prevEventAt) { + lastSessionStart = await createSessionStart(payload); + } else if ( + event.createdAt.getTime() - prevEventAt.getTime() > + 1000 * 60 * 30 + ) { + if (eidx > 0 && prevEventAt && lastSessionStart) { + await createSessionEnd(prevEventAt, lastSessionStart, { + screenViews, + totalDuration, + }); + totalDuration = 0; + screenViews = 0; + lastSessionStart = await createSessionStart(payload); + } + } + + screenViews++; + totalDuration += payload.duration; + await createEvent(payload); + } // for each user event + + const prevEventAt = events[events.length - 1]?.createdAt; + if (prevEventAt && lastSessionStart) { + await createSessionEnd(prevEventAt, lastSessionStart, { + screenViews, + totalDuration, + }); + } + } + } +} + +async function createEvent(event: IServiceCreateEventPayload) { + console.log( + `Create ${event.name} - ${event.path} - ${formatClickhouseDate( + event.createdAt + )} - ${event.duration / 1000} sec` + ); + await createClickhouseEvent(event); +} + +async function createSessionStart(event: IServiceCreateEventPayload) { + const session: IServiceCreateEventPayload = { + ...event, + duration: 0, + name: 'session_start', + createdAt: toISOString(getTime(event.createdAt) - 10), + }; + + await createEvent(session); + return session; +} + +async function createSessionEnd( + prevEventAt: Date, + sessionStart: IServiceCreateEventPayload, + options: { + screenViews: number; + totalDuration: number; + } +) { + const properties: Record = {}; + if (options.screenViews === 1) { + properties._bounce = true; + } else { + properties._bounce = false; + } + + const session: IServiceCreateEventPayload = { + ...sessionStart, + properties: { + ...properties, + ...sessionStart.properties, + }, + duration: options.totalDuration, + name: 'session_end', + createdAt: toISOString(prevEventAt.getTime() + 10), + }; + + await createEvent(session); + return session; +} + +main(); diff --git a/apps/sdk-api/src/controllers/event.controller.ts b/apps/sdk-api/src/controllers/event.controller.ts new file mode 100644 index 00000000..48a90c0e --- /dev/null +++ b/apps/sdk-api/src/controllers/event.controller.ts @@ -0,0 +1,176 @@ +import { parseIp } from '@/utils/parseIp'; +import { parseUserAgent } from '@/utils/parseUserAgent'; +import type { FastifyReply, FastifyRequest } from 'fastify'; +import { getClientIp } from 'request-ip'; + +import { generateProfileId, getTime, toISOString } from '@mixan/common'; +import type { IServiceCreateEventPayload } from '@mixan/db'; +import { getSalts } from '@mixan/db'; +import type { JobsOptions } from '@mixan/queue'; +import { eventsQueue, findJobByPrefix } from '@mixan/queue'; + +export interface PostEventPayload { + profileId?: string; + name: string; + timestamp: string; + properties: Record; + referrer: string | undefined; + path: string; +} + +const SESSION_TIMEOUT = 1000 * 30 * 1; +const SESSION_END_TIMEOUT = SESSION_TIMEOUT + 1000; + +export async function postEvent( + request: FastifyRequest<{ + Body: PostEventPayload; + }>, + reply: FastifyReply +) { + let profileId: string | null = null; + const projectId = request.projectId; + const body = request.body; + const path = body.path; + const ip = getClientIp(request)!; + const origin = request.headers.origin!; + const ua = request.headers['user-agent']!; + const uaInfo = parseUserAgent(ua); + const salts = await getSalts(); + + const [currentProfileId, previousProfileId, geo, eventsJobs] = + await Promise.all([ + generateProfileId({ + salt: salts.current, + origin, + ip, + ua, + }), + generateProfileId({ + salt: salts.previous, + origin, + ip, + ua, + }), + parseIp(ip), + eventsQueue.getJobs(['delayed']), + ]); + + // find session_end job + const sessionEndJobCurrentProfileId = findJobByPrefix( + eventsJobs, + `sessionEnd:${projectId}:${currentProfileId}:` + ); + const sessionEndJobPreviousProfileId = findJobByPrefix( + eventsJobs, + `sessionEnd:${projectId}:${previousProfileId}:` + ); + + const createSessionStart = + !sessionEndJobCurrentProfileId && !sessionEndJobPreviousProfileId; + + if (sessionEndJobCurrentProfileId && !sessionEndJobPreviousProfileId) { + console.log('found session current'); + profileId = currentProfileId; + const diff = Date.now() - sessionEndJobCurrentProfileId.timestamp; + sessionEndJobCurrentProfileId.changeDelay(diff + SESSION_END_TIMEOUT); + } else if (!sessionEndJobCurrentProfileId && sessionEndJobPreviousProfileId) { + console.log('found session previous'); + profileId = previousProfileId; + const diff = Date.now() - sessionEndJobPreviousProfileId.timestamp; + sessionEndJobPreviousProfileId.changeDelay(diff + SESSION_END_TIMEOUT); + } else { + console.log('new session with current'); + profileId = currentProfileId; + // Queue session end + eventsQueue.add( + 'event', + { + type: 'createSessionEnd', + payload: { + profileId, + }, + }, + { + delay: SESSION_END_TIMEOUT, + jobId: `sessionEnd:${projectId}:${profileId}:${Date.now()}`, + } + ); + } + + const payload: IServiceCreateEventPayload = { + name: body.name, + profileId, + projectId, + properties: body.properties, + createdAt: body.timestamp, + country: geo.country, + city: geo.city, + region: geo.region, + continent: geo.continent, + os: uaInfo.os, + osVersion: uaInfo.osVersion, + browser: uaInfo.browser, + browserVersion: uaInfo.browserVersion, + device: uaInfo.device, + brand: uaInfo.brand, + model: uaInfo.model, + duration: 0, + path, + referrer: body.referrer, // TODO + referrerName: body.referrer, // TODO + }; + + console.log(payload); + + const job = findJobByPrefix(eventsJobs, `event:${projectId}:${profileId}:`); + + if (job?.isDelayed && job.data.type === 'createEvent') { + const prevEvent = job.data.payload; + const duration = getTime(payload.createdAt) - getTime(prevEvent.createdAt); + + // Set path from prev screen_view event if current event is not a screen_view + if (payload.name != 'screen_view') { + payload.path = prevEvent.path; + } + + if (payload.name === 'screen_view') { + await job.updateData({ + type: 'createEvent', + payload: { + ...prevEvent, + duration, + }, + }); + job.promote(); + } + } + + if (createSessionStart) { + eventsQueue.add('event', { + type: 'createEvent', + payload: { + ...payload, + name: 'session_start', + createdAt: toISOString(getTime(payload.createdAt) - 10), + }, + }); + } + + const options: JobsOptions = {}; + if (payload.name === 'screen_view') { + options.delay = SESSION_TIMEOUT; + options.jobId = `event:${projectId}:${profileId}:${Date.now()}`; + } + + // Queue current event + eventsQueue.add( + 'event', + { + type: 'createEvent', + payload, + }, + options + ); + + reply.status(202).send(profileId); +} diff --git a/apps/sdk-api/src/index.ts b/apps/sdk-api/src/index.ts new file mode 100644 index 00000000..259b46ba --- /dev/null +++ b/apps/sdk-api/src/index.ts @@ -0,0 +1,78 @@ +import cors from '@fastify/cors'; +import Fastify from 'fastify'; +import pino from 'pino'; + +import eventRouter from './routes/event.router'; +import { validateSdkRequest } from './utils/auth'; + +declare module 'fastify' { + interface FastifyRequest { + projectId: string; + } +} + +const port = parseInt(process.env.API_PORT || '3030', 10); + +const startServer = async () => { + try { + const fastify = Fastify({ + logger: pino({ level: 'info' }), + }); + + fastify.register(cors, { + origin: '*', + }); + + fastify.decorateRequest('projectId', ''); + + fastify.addHook('preHandler', (req, reply, done) => { + validateSdkRequest(req.headers) + .then((projectId) => { + req.projectId = projectId; + done(); + }) + .catch((e) => { + console.log(e); + + reply.status(401).send(); + }); + }); + + fastify.register(eventRouter, { prefix: '/api/event' }); + fastify.setErrorHandler((error, request, reply) => { + fastify.log.error(error); + }); + fastify.get('/', (request, reply) => { + reply.send({ name: 'fastify-typescript' }); + }); + // fastify.get('/health-check', async (request, reply) => { + // try { + // await utils.healthCheck() + // reply.status(200).send() + // } catch (e) { + // reply.status(500).send() + // } + // }) + if (process.env.NODE_ENV === 'production') { + for (const signal of ['SIGINT', 'SIGTERM']) { + process.on(signal, () => + fastify.close().then((err) => { + console.log(`close application on ${signal}`); + process.exit(err ? 1 : 0); + }) + ); + } + } + + await fastify.listen({ port }); + } catch (e) { + console.error(e); + } +}; + +process.on('unhandledRejection', (e) => { + console.error(e); + process.exit(1); +}); + +startServer(); diff --git a/apps/sdk-api/src/routes/event.router.ts b/apps/sdk-api/src/routes/event.router.ts new file mode 100644 index 00000000..661f3084 --- /dev/null +++ b/apps/sdk-api/src/routes/event.router.ts @@ -0,0 +1,18 @@ +import * as controller from '@/controllers/event.controller'; +import type { FastifyPluginCallback } from 'fastify'; + +const eventRouter: FastifyPluginCallback = (fastify, opts, done) => { + fastify.route({ + method: 'POST', + url: '/', + handler: controller.postEvent, + }); + fastify.route({ + method: 'GET', + url: '/', + handler: controller.postEvent, + }); + done(); +}; + +export default eventRouter; diff --git a/apps/sdk-api/src/utils/auth.ts b/apps/sdk-api/src/utils/auth.ts new file mode 100644 index 00000000..2a106720 --- /dev/null +++ b/apps/sdk-api/src/utils/auth.ts @@ -0,0 +1,43 @@ +import type { RawRequestDefaultExpression } from 'fastify'; + +import { verifyPassword } from '@mixan/common'; +import { db } from '@mixan/db'; + +export async function validateSdkRequest( + headers: RawRequestDefaultExpression['headers'] +): Promise { + const clientId = headers['mixan-client-id'] as string; + const clientSecret = headers['mixan-client-secret'] as string; + const origin = headers.origin; + if (!clientId) { + throw new Error('Misisng client id'); + } + + const client = await db.client.findUnique({ + where: { + id: clientId, + }, + }); + + if (!client) { + throw new Error('Invalid client id'); + } + + if (client.secret) { + if (!(await verifyPassword(clientSecret || '', client.secret))) { + throw new Error('Invalid client secret'); + } + } else if (client.cors !== '*') { + const domainAllowed = client.cors.split(',').find((domain) => { + if (domain === origin) { + return true; + } + }); + + if (!domainAllowed) { + throw new Error('Invalid cors settings'); + } + } + + return client.project_id; +} diff --git a/apps/sdk-api/src/utils/parseIp.ts b/apps/sdk-api/src/utils/parseIp.ts new file mode 100644 index 00000000..9ad07109 --- /dev/null +++ b/apps/sdk-api/src/utils/parseIp.ts @@ -0,0 +1,25 @@ +export async function parseIp(ip: string) { + try { + const geo = await fetch(`http://localhost:8080/${ip}`); + const res = (await geo.json()) as { + country: string | undefined; + city: string | undefined; + stateprov: string | undefined; + continent: string | undefined; + }; + + return { + country: res.country, + city: res.city, + region: res.stateprov, + continent: res.continent, + }; + } catch (e) { + return { + country: undefined, + city: undefined, + region: undefined, + continent: undefined, + }; + } +} diff --git a/apps/sdk-api/src/utils/parseUserAgent.ts b/apps/sdk-api/src/utils/parseUserAgent.ts new file mode 100644 index 00000000..b1a72e3e --- /dev/null +++ b/apps/sdk-api/src/utils/parseUserAgent.ts @@ -0,0 +1,29 @@ +import { UAParser } from 'ua-parser-js'; + +export function parseUserAgent(ua: string) { + const res = new UAParser(ua).getResult(); + return { + os: res.os.name, + osVersion: res.os.version, + browser: res.browser.name, + browserVersion: res.browser.version, + device: res.device.type ?? getDevice(ua), + brand: res.device.vendor, + model: res.device.model, + }; +} + +export function getDevice(ua: string) { + const t1 = + /(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test( + ua + ); + const t2 = + /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw-(n|u)|c55\/|capi|ccwa|cdm-|cell|chtm|cldc|cmd-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc-s|devi|dica|dmob|do(c|p)o|ds(12|-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(-|_)|g1 u|g560|gene|gf-5|g-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd-(m|p|t)|hei-|hi(pt|ta)|hp( i|ip)|hs-c|ht(c(-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i-(20|go|ma)|i230|iac( |-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|-[a-w])|libw|lynx|m1-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|-([1-8]|c))|phil|pire|pl(ay|uc)|pn-2|po(ck|rt|se)|prox|psio|pt-g|qa-a|qc(07|12|21|32|60|-[2-7]|i-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h-|oo|p-)|sdk\/|se(c(-|0|1)|47|mc|nd|ri)|sgh-|shar|sie(-|m)|sk-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h-|v-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl-|tdg-|tel(i|m)|tim-|t-mo|to(pl|sh)|ts(70|m-|m3|m5)|tx-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas-|your|zeto|zte-/i.test( + ua.slice(0, 4) + ); + if (t1 || t2) { + return 'mobile'; + } + return 'desktop'; +} diff --git a/apps/sdk-api/tsconfig.json b/apps/sdk-api/tsconfig.json new file mode 100644 index 00000000..73b1bbe4 --- /dev/null +++ b/apps/sdk-api/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@mixan/tsconfig/base.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, + "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" + }, + "include": ["."], + "exclude": ["node_modules"] +} diff --git a/apps/sdk-api/tsup.config.ts b/apps/sdk-api/tsup.config.ts new file mode 100644 index 00000000..9a320b05 --- /dev/null +++ b/apps/sdk-api/tsup.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'tsup'; +import type { Options } from 'tsup'; + +const options: Options = { + clean: true, + entry: ['src/index.ts'], + noExternal: [/^@mixan\/.*$/u, /^@\/.*$/u], + sourcemap: true, + splitting: false, +}; + +if (process.env.WATCH) { + options.watch = ['src/**/*', '../../packages/**/*']; + options.onSuccess = 'node dist/index.js'; + options.minify = false; +} + +export default defineConfig(options); diff --git a/apps/web/package.json b/apps/web/package.json index b96a50ef..073c7567 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,7 +12,9 @@ "with-env": "dotenv -e ../../.env -c --" }, "dependencies": { + "@clickhouse/client": "^0.2.9", "@hookform/resolvers": "^3.3.2", + "@mixan/common": "workspace:^", "@mixan/db": "workspace:^", "@mixan/queue": "workspace:^", "@mixan/types": "workspace:*", @@ -44,6 +46,7 @@ "lodash.debounce": "^4.0.8", "lottie-react": "^2.4.0", "lucide-react": "^0.286.0", + "mathjs": "^12.3.0", "mitt": "^3.0.1", "next": "~14.0.4", "next-auth": "^4.23.0", @@ -58,9 +61,11 @@ "react-in-viewport": "1.0.0-alpha.30", "react-redux": "^8.1.3", "react-responsive": "^9.0.2", + "react-svg-worldmap": "2.0.0-alpha.16", "react-syntax-highlighter": "^15.5.0", "react-virtualized-auto-sizer": "^1.0.20", "recharts": "^2.8.0", + "request-ip": "^3.3.0", "slugify": "^1.6.6", "superjson": "^1.13.1", "tailwind-merge": "^1.14.0", @@ -79,6 +84,7 @@ "@types/react": "^18.2.20", "@types/react-dom": "^18.2.7", "@types/react-syntax-highlighter": "^15.5.9", + "@types/request-ip": "^0.0.41", "@typescript-eslint/eslint-plugin": "^6.6.0", "@typescript-eslint/parser": "^6.6.0", "autoprefixer": "^10.4.14", diff --git a/apps/web/src/app/(app)/[organizationId]/[projectId]/[dashboardId]/list-reports.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/dashboards/[dashboardId]/list-reports.tsx similarity index 92% rename from apps/web/src/app/(app)/[organizationId]/[projectId]/[dashboardId]/list-reports.tsx rename to apps/web/src/app/(app)/[organizationId]/[projectId]/dashboards/[dashboardId]/list-reports.tsx index d4c5059b..acd8ecb6 100644 --- a/apps/web/src/app/(app)/[organizationId]/[projectId]/[dashboardId]/list-reports.tsx +++ b/apps/web/src/app/(app)/[organizationId]/[projectId]/dashboards/[dashboardId]/list-reports.tsx @@ -1,8 +1,9 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { StickyBelowHeader } from '@/app/(app)/layout-sticky-below-header'; import { LazyChart } from '@/components/report/chart/LazyChart'; +import { ReportRange } from '@/components/report/ReportRange'; import { Button } from '@/components/ui/button'; import { Combobox } from '@/components/ui/combobox'; import { @@ -19,7 +20,7 @@ import { cn } from '@/utils/cn'; import { getDefaultIntervalByRange, timeRanges } from '@/utils/constants'; import { ChevronRight, MoreHorizontal, PlusIcon, Trash } from 'lucide-react'; import Link from 'next/link'; -import { useParams, useRouter } from 'next/navigation'; +import { useRouter } from 'next/navigation'; interface ListReportsProps { reports: Awaited>; @@ -33,17 +34,10 @@ export function ListReports({ reports }: ListReportsProps) { return ( <> - { - setRange((p) => (p === value ? null : value)); - }} - items={Object.values(timeRanges).map((key) => ({ - label: key, - value: key, - }))} + onChange={(value) => setRange((p) => (p === value ? null : value))} /> + + + + + + + + + + + View + + + {}}> + + Make private + + + + + + +
+ {reports.map((report, index) => ( + + ))} + + +
{selectedMetric.events[0]?.displayName}
+
+ + + +
+ + + + + +
+ + + + + + ); +} diff --git a/apps/web/src/app/(app)/[organizationId]/[projectId]/page.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/page.tsx index 77e7f4ba..f89ef128 100644 --- a/apps/web/src/app/(app)/[organizationId]/[projectId]/page.tsx +++ b/apps/web/src/app/(app)/[organizationId]/[projectId]/page.tsx @@ -1,8 +1,6 @@ import PageLayout from '@/app/(app)/page-layout'; -import { getDashboardsByProjectId } from '@/server/services/dashboard.service'; -import { HeaderDashboards } from './header-dashboards'; -import { ListDashboards } from './list-dashboards'; +import OverviewMetrics from './overview-metrics'; interface PageProps { params: { @@ -11,15 +9,10 @@ interface PageProps { }; } -export default async function Page({ - params: { organizationId, projectId }, -}: PageProps) { - const dashboards = await getDashboardsByProjectId(projectId); - +export default function Page({ params: { organizationId } }: PageProps) { return ( - - - + + ); } diff --git a/apps/web/src/app/(app)/[organizationId]/[projectId]/reports/report-editor.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/reports/report-editor.tsx index 472d5445..b5db4eae 100644 --- a/apps/web/src/app/(app)/[organizationId]/[projectId]/reports/report-editor.tsx +++ b/apps/web/src/app/(app)/[organizationId]/[projectId]/reports/report-editor.tsx @@ -1,12 +1,12 @@ 'use client'; -import { useEffect, useRef } from 'react'; -import { api } from '@/app/_trpc/client'; +import { useEffect } from 'react'; import { StickyBelowHeader } from '@/app/(app)/layout-sticky-below-header'; import { Chart } from '@/components/report/chart'; import { ReportChartType } from '@/components/report/ReportChartType'; import { ReportInterval } from '@/components/report/ReportInterval'; import { ReportLineType } from '@/components/report/ReportLineType'; +import { ReportRange } from '@/components/report/ReportRange'; import { ReportSaveButton } from '@/components/report/ReportSaveButton'; import { changeDateRanges, @@ -58,17 +58,12 @@ export default function ReportEditor({
- { dispatch(changeDateRanges(value)); }} - items={Object.values(timeRanges).map((key) => ({ - label: key, - value: key, - }))} /> diff --git a/apps/web/src/app/(app)/[organizationId]/page.tsx b/apps/web/src/app/(app)/[organizationId]/page.tsx index f20d5435..850eba57 100644 --- a/apps/web/src/app/(app)/[organizationId]/page.tsx +++ b/apps/web/src/app/(app)/[organizationId]/page.tsx @@ -1,4 +1,4 @@ -import { getFirstProjectByOrganizationId } from '@/server/services/project.service'; +import { getProjectWithMostEvents } from '@/server/services/project.service'; import { redirect } from 'next/navigation'; interface PageProps { @@ -8,10 +8,11 @@ interface PageProps { } export default async function Page({ params: { organizationId } }: PageProps) { - const project = await getFirstProjectByOrganizationId(organizationId); + const project = await getProjectWithMostEvents(organizationId); + if (project) { return redirect(`/${organizationId}/${project.id}`); } - return

List projects maybe?

; + return null; } diff --git a/apps/web/src/app/(app)/layout-menu.tsx b/apps/web/src/app/(app)/layout-menu.tsx index 16842d80..68cd01e8 100644 --- a/apps/web/src/app/(app)/layout-menu.tsx +++ b/apps/web/src/app/(app)/layout-menu.tsx @@ -2,14 +2,17 @@ import { useAppParams } from '@/hooks/useAppParams'; import type { IServiceRecentDashboards } from '@/server/services/dashboard.service'; +import { cn } from '@/utils/cn'; import { BuildingIcon, CogIcon, + DotIcon, GanttChartIcon, KeySquareIcon, LayoutPanelTopIcon, UserIcon, UsersIcon, + WallpaperIcon, WarehouseIcon, } from 'lucide-react'; import type { LucideProps } from 'lucide-react'; @@ -20,18 +23,32 @@ function LinkWithIcon({ href, icon: Icon, label, + active: overrideActive, }: { href: string; icon: React.ElementType; label: React.ReactNode; + active?: boolean; }) { + const pathname = usePathname(); + const active = overrideActive || href === pathname; return ( - {label} +
{label}
+ ); } @@ -53,10 +70,15 @@ export default function LayoutMenu({ return ( <> + diff --git a/apps/web/src/app/(app)/layout.tsx b/apps/web/src/app/(app)/layout.tsx index 5a2c9c14..c62299ab 100644 --- a/apps/web/src/app/(app)/layout.tsx +++ b/apps/web/src/app/(app)/layout.tsx @@ -2,6 +2,7 @@ import { getSession } from '@/server/auth'; import { getRecentDashboardsByUserId } from '@/server/services/dashboard.service'; import { getOrganizations } from '@/server/services/organization.service'; +import Auth from '../auth'; import { LayoutSidebar } from './layout-sidebar'; interface AppLayoutProps { @@ -15,8 +16,12 @@ export default async function AppLayout({ children }: AppLayoutProps) { ? await getRecentDashboardsByUserId(session?.user.id) : []; + if (!session) { + return ; + } + return ( -
+
{children}
diff --git a/apps/web/src/app/api/fml/route.ts b/apps/web/src/app/api/fml/route.ts deleted file mode 100644 index 5dd8ea53..00000000 --- a/apps/web/src/app/api/fml/route.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { createRecentDashboard } from '@/server/services/dashboard.service'; -import { revalidatePath } from 'next/cache'; -import { NextResponse } from 'next/server'; - -export const dynamic = 'force-dynamic'; // defaults to auto -export async function POST(req: Request) { - await createRecentDashboard(await req.json()); - revalidatePath('/', 'layout'); - return NextResponse.json({ ok: 'qe' }); -} diff --git a/apps/web/src/app/cookie-provider.tsx b/apps/web/src/app/cookie-provider.tsx deleted file mode 100644 index 06133d04..00000000 --- a/apps/web/src/app/cookie-provider.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { createContext, useContext } from 'react'; -import type { RequestCookie } from 'next/dist/compiled/@edge-runtime/cookies'; - -type ICookies = Record; - -const context = createContext({}); - -export const CookieProvider = ({ - value, - children, -}: { - children: React.ReactNode; - value: RequestCookie[]; -}) => { - const cookies = value.reduce((acc, cookie) => { - return { - ...acc, - [cookie.name]: cookie.value, - }; - }, {} as ICookies); - return {children}; -}; - -export const useCookies = (): ICookies => useContext(context); diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 0887cc24..c5a25da5 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -5,9 +5,6 @@ import Providers from './providers'; import '@/styles/globals.css'; import { getSession } from '@/server/auth'; -import { cookies } from 'next/headers'; - -import Auth from './auth'; export const metadata = {}; @@ -30,9 +27,7 @@ export default async function RootLayout({ - - {session ? children : } - + {children} ); diff --git a/apps/web/src/app/providers.tsx b/apps/web/src/app/providers.tsx index b367014e..4909e825 100644 --- a/apps/web/src/app/providers.tsx +++ b/apps/web/src/app/providers.tsx @@ -8,29 +8,25 @@ import { ModalProvider } from '@/modals'; import type { AppStore } from '@/redux'; import makeStore from '@/redux'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { httpBatchLink } from '@trpc/client'; +import { httpLink } from '@trpc/client'; import type { Session } from 'next-auth'; import { SessionProvider } from 'next-auth/react'; -import type { RequestCookie } from 'next/dist/compiled/@edge-runtime/cookies'; import { Provider as ReduxProvider } from 'react-redux'; import superjson from 'superjson'; -import { CookieProvider } from './cookie-provider'; - export default function Providers({ children, session, - cookies, }: { children: React.ReactNode; session: Session | null; - cookies: RequestCookie[]; }) { const [queryClient] = useState( () => new QueryClient({ defaultOptions: { queries: { + networkMode: 'always', refetchOnMount: true, refetchOnWindowFocus: false, }, @@ -41,7 +37,7 @@ export default function Providers({ api.createClient({ transformer: superjson, links: [ - httpBatchLink({ + httpLink({ url: 'http://localhost:3000/api/trpc', }), ], @@ -60,7 +56,7 @@ export default function Providers({ - {children} + {children} diff --git a/apps/web/src/app/share/project/[projectId]/page.tsx b/apps/web/src/app/share/project/[projectId]/page.tsx new file mode 100644 index 00000000..c2f8725d --- /dev/null +++ b/apps/web/src/app/share/project/[projectId]/page.tsx @@ -0,0 +1,33 @@ +import OverviewMetrics from '@/app/(app)/[organizationId]/[projectId]/overview-metrics'; +import { Logo } from '@/components/Logo'; +import { getOrganizationByProjectId } from '@/server/services/organization.service'; +import { getProjectById } from '@/server/services/project.service'; + +interface PageProps { + params: { + projectId: string; + }; +} + +export default async function Page({ params: { projectId } }: PageProps) { + const project = await getProjectById(projectId); + const organization = await getOrganizationByProjectId(projectId); + return ( +
+
+
+
+ {organization?.name} +

{project?.name}

+
+ + + +
+
+ +
+
+
+ ); +} diff --git a/apps/web/src/components/ColorSquare.tsx b/apps/web/src/components/ColorSquare.tsx index e0015dab..52f79340 100644 --- a/apps/web/src/components/ColorSquare.tsx +++ b/apps/web/src/components/ColorSquare.tsx @@ -1,9 +1,15 @@ import type { HtmlProps } from '@/types'; import { cn } from '@/utils/cn'; +import { useChartContext } from './report/chart/ChartProvider'; + type ColorSquareProps = HtmlProps; export function ColorSquare({ children, className }: ColorSquareProps) { + const { hideID } = useChartContext(); + if (hideID) { + return null; + } return (
- +
+ openpanel.dev
); diff --git a/apps/web/src/components/Widget.tsx b/apps/web/src/components/Widget.tsx index 1e1deb5f..57c50154 100644 --- a/apps/web/src/components/Widget.tsx +++ b/apps/web/src/components/Widget.tsx @@ -1,6 +1,6 @@ import { cn } from '@/utils/cn'; -interface WidgetHeadProps { +export interface WidgetHeadProps { children: React.ReactNode; className?: string; } @@ -17,7 +17,7 @@ export function WidgetHead({ children, className }: WidgetHeadProps) { ); } -interface WidgetBodyProps { +export interface WidgetBodyProps { children: React.ReactNode; className?: string; } @@ -25,7 +25,7 @@ export function WidgetBody({ children, className }: WidgetBodyProps) { return
{children}
; } -interface WidgetProps { +export interface WidgetProps { children: React.ReactNode; className?: string; } diff --git a/apps/web/src/components/events/ListProperties.tsx b/apps/web/src/components/events/ListProperties.tsx index d5020ba5..c84cfe97 100644 --- a/apps/web/src/components/events/ListProperties.tsx +++ b/apps/web/src/components/events/ListProperties.tsx @@ -1,4 +1,4 @@ -import { toDots } from '@/utils/object'; +import { toDots } from '@mixan/common'; import { Table, TableBody, TableCell, TableRow } from '../ui/table'; diff --git a/apps/web/src/components/navbar/NavbarMenu.tsx b/apps/web/src/components/navbar/NavbarMenu.tsx index 374dad6e..db02c057 100644 --- a/apps/web/src/components/navbar/NavbarMenu.tsx +++ b/apps/web/src/components/navbar/NavbarMenu.tsx @@ -1,9 +1,10 @@ import { useOrganizationParams } from '@/hooks/useOrganizationParams'; import { cn } from '@/utils/cn'; -import { strip } from '@/utils/object'; import type { LinkProps } from 'next/link'; import Link from 'next/link'; +import { strip } from '@mixan/common'; + import { NavbarUserDropdown } from './NavbarUserDropdown'; function Item({ diff --git a/apps/web/src/components/overview/overview-filters-buttons.tsx b/apps/web/src/components/overview/overview-filters-buttons.tsx new file mode 100644 index 00000000..c4626e02 --- /dev/null +++ b/apps/web/src/components/overview/overview-filters-buttons.tsx @@ -0,0 +1,129 @@ +'use client'; + +import { api } from '@/app/_trpc/client'; +import { useAppParams } from '@/hooks/useAppParams'; +import { cn } from '@/utils/cn'; +import { X } from 'lucide-react'; + +import { Button } from '../ui/button'; +import { Combobox } from '../ui/combobox'; +import { Label } from '../ui/label'; +import { useOverviewOptions } from './useOverviewOptions'; + +export function OverviewFiltersButtons() { + const options = useOverviewOptions(); + return ( + <> + {options.referrer && ( + + )} + {options.device && ( + + )} + {options.page && ( + + )} + {options.utmSource && ( + + )} + {options.utmMedium && ( + + )} + {options.utmCampaign && ( + + )} + {options.utmTerm && ( + + )} + {options.utmContent && ( + + )} + {options.country && ( + + )} + {options.region && ( + + )} + {options.city && ( + + )} + + ); +} diff --git a/apps/web/src/components/overview/overview-filters.tsx b/apps/web/src/components/overview/overview-filters.tsx new file mode 100644 index 00000000..77d04150 --- /dev/null +++ b/apps/web/src/components/overview/overview-filters.tsx @@ -0,0 +1,121 @@ +'use client'; + +import { api } from '@/app/_trpc/client'; +import { useAppParams } from '@/hooks/useAppParams'; +import { cn } from '@/utils/cn'; + +import { Combobox } from '../ui/combobox'; +import { Label } from '../ui/label'; +import { useOverviewOptions } from './useOverviewOptions'; + +export function OverviewFilters() { + const { projectId } = useAppParams(); + const options = useOverviewOptions(); + + const { data: referrers } = api.chart.values.useQuery({ + projectId, + property: 'referrer', + event: 'session_start', + }); + + const { data: devices } = api.chart.values.useQuery({ + projectId, + property: 'device', + event: 'session_start', + }); + + const { data: pages } = api.chart.values.useQuery({ + projectId, + property: 'path', + event: 'screen_view', + }); + + return ( +
+

Overview filters

+
+
+ + options.setReferrer(value)} + label="Referrer" + placeholder="Referrer" + items={ + referrers?.values?.filter(Boolean)?.map((value) => ({ + value, + label: value, + })) ?? [] + } + value={options.referrer} + /> +
+
+ + options.setDevice(value)} + label="Device" + placeholder="Device" + items={ + devices?.values?.filter(Boolean)?.map((value) => ({ + value, + label: value, + })) ?? [] + } + value={options.device} + /> +
+
+ + options.setPage(value)} + label="Page" + placeholder="Page" + items={ + pages?.values?.filter(Boolean)?.map((value) => ({ + value, + label: value, + })) ?? [] + } + value={options.page} + /> +
+
+
+ ); +} diff --git a/apps/web/src/components/overview/overview-top-devices.tsx b/apps/web/src/components/overview/overview-top-devices.tsx new file mode 100644 index 00000000..73516c56 --- /dev/null +++ b/apps/web/src/components/overview/overview-top-devices.tsx @@ -0,0 +1,199 @@ +'use client'; + +import { Chart } from '@/components/report/chart'; +import { cn } from '@/utils/cn'; + +import { Widget, WidgetBody } from '../Widget'; +import { WidgetButtons, WidgetHead } from './overview-widget'; +import { useOverviewOptions } from './useOverviewOptions'; +import { useOverviewWidget } from './useOverviewWidget'; + +export default function OverviewTopDevices() { + const { filters, interval, range, previous, setCountry, setRegion, setCity } = + useOverviewOptions(); + const [widget, setWidget, widgets] = useOverviewWidget('tech', { + devices: { + title: 'Top devices', + btn: 'Devices', + chart: { + projectId: '', + events: [ + { + segment: 'user', + filters, + id: 'A', + name: 'session_start', + }, + ], + breakdowns: [ + { + id: 'A', + name: 'device', + }, + ], + chartType: 'bar', + lineType: 'monotone', + interval: interval, + name: 'Top sources', + range: range, + previous: previous, + metric: 'sum', + }, + }, + browser: { + title: 'Top browser', + btn: 'Browser', + chart: { + projectId: '', + events: [ + { + segment: 'user', + filters, + id: 'A', + name: 'session_start', + }, + ], + breakdowns: [ + { + id: 'A', + name: 'browser', + }, + ], + chartType: 'bar', + lineType: 'monotone', + interval: interval, + name: 'Top sources', + range: range, + previous: previous, + metric: 'sum', + }, + }, + browser_version: { + title: 'Top Browser Version', + btn: 'Browser Version', + chart: { + projectId: '', + events: [ + { + segment: 'user', + filters, + id: 'A', + name: 'session_start', + }, + ], + breakdowns: [ + { + id: 'A', + name: 'browser_version', + }, + ], + chartType: 'bar', + lineType: 'monotone', + interval: interval, + name: 'Top sources', + range: range, + previous: previous, + metric: 'sum', + }, + }, + os: { + title: 'Top OS', + btn: 'OS', + chart: { + projectId: '', + events: [ + { + segment: 'user', + filters, + id: 'A', + name: 'session_start', + }, + ], + breakdowns: [ + { + id: 'A', + name: 'os', + }, + ], + chartType: 'bar', + lineType: 'monotone', + interval: interval, + name: 'Top sources', + range: range, + previous: previous, + metric: 'sum', + }, + }, + os_version: { + title: 'Top OS version', + btn: 'OS Version', + chart: { + projectId: '', + events: [ + { + segment: 'user', + filters, + id: 'A', + name: 'session_start', + }, + ], + breakdowns: [ + { + id: 'A', + name: 'os_version', + }, + ], + chartType: 'bar', + lineType: 'monotone', + interval: interval, + name: 'Top sources', + range: range, + previous: previous, + metric: 'sum', + }, + }, + }); + + return ( + <> + + +
{widget.title}
+ + {widgets.map((w) => ( + + ))} + +
+ + { + // switch (widget.key) { + // case 'browser': + // setWidget('browser_version'); + // // setCountry(item.name); + // break; + // case 'regions': + // setWidget('cities'); + // setRegion(item.name); + // break; + // case 'cities': + // setCity(item.name); + // break; + // } + }} + /> + +
+ + ); +} diff --git a/apps/web/src/components/overview/overview-top-events.tsx b/apps/web/src/components/overview/overview-top-events.tsx new file mode 100644 index 00000000..1454ea58 --- /dev/null +++ b/apps/web/src/components/overview/overview-top-events.tsx @@ -0,0 +1,75 @@ +'use client'; + +import { Chart } from '@/components/report/chart'; +import { cn } from '@/utils/cn'; + +import { Widget, WidgetBody } from '../Widget'; +import { WidgetButtons, WidgetHead } from './overview-widget'; +import { useOverviewOptions } from './useOverviewOptions'; +import { useOverviewWidget } from './useOverviewWidget'; + +export default function OverviewTopEvents() { + const { filters, interval, range, previous } = useOverviewOptions(); + const [widget, setWidget, widgets] = useOverviewWidget('ev', { + all: { + title: 'Top events', + btn: 'All', + chart: { + projectId: '', + events: [ + { + segment: 'event', + filters: [ + ...filters, + { + id: 'ex_session', + name: 'name', + operator: 'isNot', + value: ['session_start', 'session_end'], + }, + ], + id: 'A', + name: '*', + }, + ], + breakdowns: [ + { + id: 'A', + name: 'name', + }, + ], + chartType: 'bar', + lineType: 'monotone', + interval: interval, + name: 'Top sources', + range: range, + previous: previous, + metric: 'sum', + }, + }, + }); + + return ( + <> + + +
{widget.title}
+ + {widgets.map((w) => ( + + ))} + +
+ + + +
+ + ); +} diff --git a/apps/web/src/components/overview/overview-top-geo.tsx b/apps/web/src/components/overview/overview-top-geo.tsx new file mode 100644 index 00000000..c5364901 --- /dev/null +++ b/apps/web/src/components/overview/overview-top-geo.tsx @@ -0,0 +1,171 @@ +'use client'; + +import { Chart } from '@/components/report/chart'; +import { cn } from '@/utils/cn'; + +import { Widget, WidgetBody } from '../Widget'; +import { WidgetButtons, WidgetHead } from './overview-widget'; +import { useOverviewOptions } from './useOverviewOptions'; +import { useOverviewWidget } from './useOverviewWidget'; + +export default function OverviewTopGeo() { + const { filters, interval, range, previous, setCountry, setRegion, setCity } = + useOverviewOptions(); + const [widget, setWidget, widgets] = useOverviewWidget('geo', { + map: { + title: 'Map', + btn: 'Map', + chart: { + projectId: '', + events: [ + { + segment: 'event', + filters, + id: 'A', + name: 'session_start', + }, + ], + breakdowns: [ + { + id: 'A', + name: 'country', + }, + ], + chartType: 'map', + lineType: 'monotone', + interval: interval, + name: 'Top sources', + range: range, + previous: previous, + metric: 'sum', + }, + }, + countries: { + title: 'Top countries', + btn: 'Countries', + chart: { + projectId: '', + events: [ + { + segment: 'event', + filters, + id: 'A', + name: 'session_start', + }, + ], + breakdowns: [ + { + id: 'A', + name: 'country', + }, + ], + chartType: 'bar', + lineType: 'monotone', + interval: interval, + name: 'Top sources', + range: range, + previous: previous, + metric: 'sum', + }, + }, + regions: { + title: 'Top regions', + btn: 'Regions', + chart: { + projectId: '', + events: [ + { + segment: 'event', + filters, + id: 'A', + name: 'session_start', + }, + ], + breakdowns: [ + { + id: 'A', + name: 'region', + }, + ], + chartType: 'bar', + lineType: 'monotone', + interval: interval, + name: 'Top sources', + range: range, + previous: previous, + metric: 'sum', + }, + }, + cities: { + title: 'Top cities', + btn: 'Cities', + chart: { + projectId: '', + events: [ + { + segment: 'event', + filters, + id: 'A', + name: 'session_start', + }, + ], + breakdowns: [ + { + id: 'A', + name: 'city', + }, + ], + chartType: 'bar', + lineType: 'monotone', + interval: interval, + name: 'Top sources', + range: range, + previous: previous, + metric: 'sum', + }, + }, + }); + + return ( + <> + + +
{widget.title}
+ + {widgets.map((w) => ( + + ))} + +
+ + { + switch (widget.key) { + case 'countries': + setWidget('regions'); + setCountry(item.name); + break; + case 'regions': + setWidget('cities'); + setRegion(item.name); + break; + case 'cities': + setCity(item.name); + break; + } + }} + /> + +
+ + ); +} diff --git a/apps/web/src/components/overview/overview-top-pages.tsx b/apps/web/src/components/overview/overview-top-pages.tsx new file mode 100644 index 00000000..a970c60e --- /dev/null +++ b/apps/web/src/components/overview/overview-top-pages.tsx @@ -0,0 +1,130 @@ +'use client'; + +import { Chart } from '@/components/report/chart'; +import { cn } from '@/utils/cn'; + +import { Widget, WidgetBody } from '../Widget'; +import { WidgetButtons, WidgetHead } from './overview-widget'; +import { useOverviewOptions } from './useOverviewOptions'; +import { useOverviewWidget } from './useOverviewWidget'; + +export default function OverviewTopPages() { + const { filters, interval, range, previous, setPage } = useOverviewOptions(); + const [widget, setWidget, widgets] = useOverviewWidget('pages', { + top: { + title: 'Top pages', + btn: 'Top pages', + chart: { + projectId: '', + events: [ + { + segment: 'event', + filters, + id: 'A', + name: 'screen_view', + }, + ], + breakdowns: [ + { + id: 'A', + name: 'path', + }, + ], + chartType: 'bar', + lineType: 'monotone', + interval, + name: 'Top sources', + range, + previous, + metric: 'sum', + }, + }, + entries: { + title: 'Entry Pages', + btn: 'Entries', + chart: { + projectId: '', + events: [ + { + segment: 'event', + filters, + id: 'A', + name: 'session_start', + }, + ], + breakdowns: [ + { + id: 'A', + name: 'path', + }, + ], + chartType: 'bar', + lineType: 'monotone', + interval, + name: 'Top sources', + range, + previous, + metric: 'sum', + }, + }, + exits: { + title: 'Exit Pages', + btn: 'Exits', + chart: { + projectId: '', + events: [ + { + segment: 'event', + filters, + id: 'A', + name: 'session_end', + }, + ], + breakdowns: [ + { + id: 'A', + name: 'path', + }, + ], + chartType: 'bar', + lineType: 'monotone', + interval, + name: 'Top sources', + range, + previous, + metric: 'sum', + }, + }, + }); + + return ( + <> + + +
{widget.title}
+ + {widgets.map((w) => ( + + ))} + +
+ + { + setPage(item.name); + }} + /> + +
+ + ); +} diff --git a/apps/web/src/components/overview/overview-top-sources.tsx b/apps/web/src/components/overview/overview-top-sources.tsx new file mode 100644 index 00000000..d7fd6ebf --- /dev/null +++ b/apps/web/src/components/overview/overview-top-sources.tsx @@ -0,0 +1,245 @@ +'use client'; + +import { Chart } from '@/components/report/chart'; +import type { IChartInput } from '@/types'; +import { cn } from '@/utils/cn'; + +import { Widget, WidgetBody } from '../Widget'; +import { WidgetButtons, WidgetHead } from './overview-widget'; +import { useOverviewOptions } from './useOverviewOptions'; +import { useOverviewWidget } from './useOverviewWidget'; + +export default function OverviewTopSources() { + const { + filters, + interval, + range, + previous, + setReferrer, + setUtmSource, + setUtmMedium, + setUtmCampaign, + setUtmTerm, + setUtmContent, + } = useOverviewOptions(); + const [widget, setWidget, widgets] = useOverviewWidget('sources', { + all: { + title: 'Top sources', + btn: 'All', + chart: { + projectId: '', + events: [ + { + segment: 'event', + filters: filters, + id: 'A', + name: 'session_start', + }, + ], + breakdowns: [ + { + id: 'A', + name: 'referrer', + }, + ], + chartType: 'bar', + lineType: 'monotone', + interval: interval, + name: 'Top sources', + range: range, + previous: previous, + metric: 'sum', + }, + }, + utm_source: { + title: 'UTM Source', + btn: 'Source', + chart: { + projectId: '', + events: [ + { + segment: 'event', + filters, + id: 'A', + name: 'session_start', + }, + ], + breakdowns: [ + { + id: 'A', + name: 'properties.query.utm_source', + }, + ], + chartType: 'bar', + lineType: 'monotone', + interval: interval, + name: 'Top sources', + range: range, + previous: previous, + metric: 'sum', + }, + }, + utm_medium: { + title: 'UTM Medium', + btn: 'Medium', + chart: { + projectId: '', + events: [ + { + segment: 'event', + filters, + id: 'A', + name: 'session_start', + }, + ], + breakdowns: [ + { + id: 'A', + name: 'properties.query.utm_medium', + }, + ], + chartType: 'bar', + lineType: 'monotone', + interval: interval, + name: 'Top sources', + range: range, + previous: previous, + metric: 'sum', + }, + }, + utm_campaign: { + title: 'UTM Campaign', + btn: 'Campaign', + chart: { + projectId: '', + events: [ + { + segment: 'event', + filters, + id: 'A', + name: 'session_start', + }, + ], + breakdowns: [ + { + id: 'A', + name: 'properties.query.utm_campaign', + }, + ], + chartType: 'bar', + lineType: 'monotone', + interval: interval, + name: 'Top sources', + range: range, + previous: previous, + metric: 'sum', + }, + }, + utm_term: { + title: 'UTM Term', + btn: 'Term', + chart: { + projectId: '', + events: [ + { + segment: 'event', + filters, + id: 'A', + name: 'session_start', + }, + ], + breakdowns: [ + { + id: 'A', + name: 'properties.query.utm_term', + }, + ], + chartType: 'bar', + lineType: 'monotone', + interval: interval, + name: 'Top sources', + range: range, + previous: previous, + metric: 'sum', + }, + }, + utm_content: { + title: 'UTM Content', + btn: 'Content', + chart: { + projectId: '', + events: [ + { + segment: 'event', + filters, + id: 'A', + name: 'session_start', + }, + ], + breakdowns: [ + { + id: 'A', + name: 'properties.query.utm_content', + }, + ], + chartType: 'bar', + lineType: 'monotone', + interval: interval, + name: 'Top sources', + range: range, + previous: previous, + metric: 'sum', + }, + }, + }); + + return ( + <> + + +
{widget.title}
+ + {widgets.map((w) => ( + + ))} + +
+ + { + switch (widget.key) { + case 'all': + setReferrer(item.name); + break; + case 'utm_source': + setUtmSource(item.name); + break; + case 'utm_medium': + setUtmMedium(item.name); + break; + case 'utm_campaign': + setUtmCampaign(item.name); + break; + case 'utm_term': + setUtmTerm(item.name); + break; + case 'utm_content': + setUtmContent(item.name); + break; + } + }} + /> + +
+ + ); +} diff --git a/apps/web/src/components/overview/overview-widget.tsx b/apps/web/src/components/overview/overview-widget.tsx new file mode 100644 index 00000000..9881fc38 --- /dev/null +++ b/apps/web/src/components/overview/overview-widget.tsx @@ -0,0 +1,25 @@ +import { cn } from '@/utils/cn'; + +import type { WidgetHeadProps } from '../Widget'; +import { WidgetHead as WidgetHeadBase } from '../Widget'; + +export function WidgetHead({ className, ...props }: WidgetHeadProps) { + return ( + + ); +} + +export function WidgetButtons({ className, ...props }: WidgetHeadProps) { + return ( +
+ ); +} diff --git a/apps/web/src/components/overview/useOverviewOptions.ts b/apps/web/src/components/overview/useOverviewOptions.ts new file mode 100644 index 00000000..9f28f14d --- /dev/null +++ b/apps/web/src/components/overview/useOverviewOptions.ts @@ -0,0 +1,234 @@ +import { useMemo } from 'react'; +import type { IChartInput } from '@/types'; +import { getDefaultIntervalByRange, timeRanges } from '@/utils/constants'; +import { mapKeys } from '@/utils/validation'; +import { + parseAsBoolean, + parseAsInteger, + parseAsString, + parseAsStringEnum, + useQueryState, +} from 'nuqs'; + +const nuqsOptions = { history: 'push' } as const; + +export function useOverviewOptions() { + const [previous, setPrevious] = useQueryState( + 'name', + parseAsBoolean.withDefault(true).withOptions(nuqsOptions) + ); + const [range, setRange] = useQueryState( + 'range', + parseAsStringEnum(mapKeys(timeRanges)) + .withDefault('7d') + .withOptions(nuqsOptions) + ); + const interval = getDefaultIntervalByRange(range); + const [metric, setMetric] = useQueryState( + 'metric', + parseAsInteger.withDefault(0).withOptions(nuqsOptions) + ); + + // Filters + const [referrer, setReferrer] = useQueryState( + 'referrer', + parseAsString.withOptions(nuqsOptions) + ); + const [device, setDevice] = useQueryState( + 'device', + parseAsString.withOptions(nuqsOptions) + ); + const [page, setPage] = useQueryState( + 'page', + parseAsString.withOptions(nuqsOptions) + ); + + const [utmSource, setUtmSource] = useQueryState( + 'utm_source', + parseAsString.withOptions(nuqsOptions) + ); + const [utmMedium, setUtmMedium] = useQueryState( + 'utm_medium', + parseAsString.withOptions(nuqsOptions) + ); + const [utmCampaign, setUtmCampaign] = useQueryState( + 'utm_campaign', + parseAsString.withOptions(nuqsOptions) + ); + const [utmContent, setUtmContent] = useQueryState( + 'utm_content', + parseAsString.withOptions(nuqsOptions) + ); + const [utmTerm, setUtmTerm] = useQueryState( + 'utm_term', + parseAsString.withOptions(nuqsOptions) + ); + + const [country, setCountry] = useQueryState( + 'country', + parseAsString.withOptions(nuqsOptions) + ); + const [region, setRegion] = useQueryState( + 'region', + parseAsString.withOptions(nuqsOptions) + ); + const [city, setCity] = useQueryState( + 'city', + parseAsString.withOptions(nuqsOptions) + ); + + const filters = useMemo(() => { + const filters: IChartInput['events'][number]['filters'] = []; + if (referrer) { + filters.push({ + id: 'referrer', + operator: 'is', + name: 'referrer', + value: [referrer], + }); + } + + if (page) { + filters.push({ + id: 'path', + operator: 'is', + name: 'path', + value: [page], + }); + } + + if (device) { + filters.push({ + id: 'device', + operator: 'is', + name: 'device', + value: [device], + }); + } + + if (utmSource) { + filters.push({ + id: 'utm_source', + operator: 'is', + name: 'properties.query.utm_source', + value: [utmSource], + }); + } + + if (utmMedium) { + filters.push({ + id: 'utm_medium', + operator: 'is', + name: 'properties.query.utm_medium', + value: [utmMedium], + }); + } + + if (utmCampaign) { + filters.push({ + id: 'utm_campaign', + operator: 'is', + name: 'properties.query.utm_campaign', + value: [utmCampaign], + }); + } + + if (utmContent) { + filters.push({ + id: 'utm_content', + operator: 'is', + name: 'properties.query.utm_content', + value: [utmContent], + }); + } + + if (utmTerm) { + filters.push({ + id: 'utm_term', + operator: 'is', + name: 'properties.query.utm_term', + value: [utmTerm], + }); + } + + if (country) { + filters.push({ + id: 'country', + operator: 'is', + name: 'country', + value: [country], + }); + } + + if (region) { + filters.push({ + id: 'region', + operator: 'is', + name: 'region', + value: [region], + }); + } + + if (city) { + filters.push({ + id: 'city', + operator: 'is', + name: 'city', + value: [city], + }); + } + + return filters; + }, [ + referrer, + page, + device, + utmSource, + utmMedium, + utmCampaign, + utmContent, + utmTerm, + country, + region, + city, + ]); + + return { + previous, + setPrevious, + range, + setRange, + metric, + setMetric, + referrer, + setReferrer, + device, + setDevice, + page, + setPage, + + // Computed + interval, + filters, + + // UTM + utmSource, + setUtmSource, + utmMedium, + setUtmMedium, + utmCampaign, + setUtmCampaign, + utmContent, + setUtmContent, + utmTerm, + setUtmTerm, + + // GEO + country, + setCountry, + region, + setRegion, + city, + setCity, + }; +} diff --git a/apps/web/src/components/overview/useOverviewWidget.tsx b/apps/web/src/components/overview/useOverviewWidget.tsx new file mode 100644 index 00000000..242a1240 --- /dev/null +++ b/apps/web/src/components/overview/useOverviewWidget.tsx @@ -0,0 +1,27 @@ +import type { IChartInput } from '@/types'; +import { mapKeys } from '@/utils/validation'; +import { parseAsStringEnum, useQueryState } from 'nuqs'; + +export function useOverviewWidget( + key: string, + widgets: Record +) { + const keys = Object.keys(widgets) as T[]; + const [widget, setWidget] = useQueryState( + key, + parseAsStringEnum(keys) + .withDefault(keys[0]!) + .withOptions({ history: 'push' }) + ); + return [ + { + ...widgets[widget]!, + key: widget, + }, + setWidget, + mapKeys(widgets).map((key) => ({ + ...widgets[key], + key, + })), + ] as const; +} diff --git a/apps/web/src/components/report/PreviousDiffIndicator.tsx b/apps/web/src/components/report/PreviousDiffIndicator.tsx index bd80a312..7d3c3003 100644 --- a/apps/web/src/components/report/PreviousDiffIndicator.tsx +++ b/apps/web/src/components/report/PreviousDiffIndicator.tsx @@ -1,13 +1,15 @@ import { useNumber } from '@/hooks/useNumerFormatter'; import { cn } from '@/utils/cn'; -import { ChevronDown, ChevronUp } from 'lucide-react'; +import { TrendingDownIcon, TrendingUpIcon } from 'lucide-react'; +import { Badge } from '../ui/badge'; import { useChartContext } from './chart/ChartProvider'; interface PreviousDiffIndicatorProps { diff?: number | null | undefined; state?: string | null | undefined; children?: React.ReactNode; + inverted?: boolean; } export function PreviousDiffIndicator({ @@ -15,25 +17,38 @@ export function PreviousDiffIndicator({ state, children, }: PreviousDiffIndicatorProps) { - const { previous } = useChartContext(); + const { previous, previousIndicatorInverted } = useChartContext(); const number = useNumber(); if (diff === null || diff === undefined || previous === false) { return children ?? null; } + if (previousIndicatorInverted === true) { + return ( + <> + + {state === 'negative' && } + {state === 'positive' && } + {number.format(diff)}% + + {children} + + ); + } + return ( <> -
- {state === 'positive' && } - {state === 'negative' && } + {state === 'positive' && } + {state === 'negative' && } {number.format(diff)}% -
+ {children} ); diff --git a/apps/web/src/components/report/ReportDateRange.tsx b/apps/web/src/components/report/ReportDateRange.tsx deleted file mode 100644 index 12fc2f4c..00000000 --- a/apps/web/src/components/report/ReportDateRange.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { useDispatch, useSelector } from '@/redux'; -import { timeRanges } from '@/utils/constants'; - -import { RadioGroup, RadioGroupItem } from '../ui/radio-group'; -import { changeDateRanges } from './reportSlice'; - -export function ReportDateRange() { - const dispatch = useDispatch(); - const range = useSelector((state) => state.report.range); - - return ( - - {Object.values(timeRanges).map((key) => { - return ( - { - dispatch(changeDateRanges(key)); - }} - > - {key} - - ); - })} - - ); -} diff --git a/apps/web/src/components/report/ReportRange.tsx b/apps/web/src/components/report/ReportRange.tsx new file mode 100644 index 00000000..51d51fdc --- /dev/null +++ b/apps/web/src/components/report/ReportRange.tsx @@ -0,0 +1,20 @@ +import type { IChartRange } from '@/types'; +import { timeRanges } from '@/utils/constants'; +import { CalendarIcon } from 'lucide-react'; + +import type { ExtendedComboboxProps } from '../ui/combobox'; +import { Combobox } from '../ui/combobox'; + +export function ReportRange(props: ExtendedComboboxProps) { + return ( + ({ + label: key, + value: key, + }))} + {...props} + /> + ); +} diff --git a/apps/web/src/components/report/ReportSaveButton.tsx b/apps/web/src/components/report/ReportSaveButton.tsx index 6f5c9e86..bcf98235 100644 --- a/apps/web/src/components/report/ReportSaveButton.tsx +++ b/apps/web/src/components/report/ReportSaveButton.tsx @@ -7,7 +7,6 @@ import { useAppParams } from '@/hooks/useAppParams'; import { pushModal } from '@/modals'; import { useDispatch, useSelector } from '@/redux'; import { SaveIcon } from 'lucide-react'; -import { useParams } from 'next/navigation'; import { resetDirty } from './reportSlice'; diff --git a/apps/web/src/components/report/chart/ChartProvider.tsx b/apps/web/src/components/report/chart/ChartProvider.tsx index 37888960..d7fb362a 100644 --- a/apps/web/src/components/report/chart/ChartProvider.tsx +++ b/apps/web/src/components/report/chart/ChartProvider.tsx @@ -1,31 +1,46 @@ import { createContext, memo, useContext, useMemo } from 'react'; +import type { IChartInput } from '@/types'; -export interface ChartContextType { - editMode: boolean; - previous?: boolean; +export interface ChartContextType extends IChartInput { + editMode?: boolean; + hideID?: boolean; + onClick?: (item: any) => void; } type ChartProviderProps = { children: React.ReactNode; } & ChartContextType; -const ChartContext = createContext({ - editMode: false, +const ChartContext = createContext({ + events: [], + breakdowns: [], + chartType: 'linear', + lineType: 'monotone', + interval: 'day', + name: '', + range: '7d', + metric: 'sum', + previous: false, + projectId: '', }); export function ChartProvider({ children, editMode, previous, + hideID, + ...props }: ChartProviderProps) { return ( ({ - editMode, + editMode: editMode ?? false, previous: previous ?? false, + hideID: hideID ?? false, + ...props, }), - [editMode, previous] + [editMode, previous, hideID, props] )} > {children} @@ -52,5 +67,5 @@ export function withChartProivder( } export function useChartContext() { - return useContext(ChartContext); + return useContext(ChartContext)!; } diff --git a/apps/web/src/components/report/chart/MetricCard.tsx b/apps/web/src/components/report/chart/MetricCard.tsx new file mode 100644 index 00000000..6945c3bc --- /dev/null +++ b/apps/web/src/components/report/chart/MetricCard.tsx @@ -0,0 +1,81 @@ +import type { IChartData } from '@/app/_trpc/client'; +import { ColorSquare } from '@/components/ColorSquare'; +import { useNumber } from '@/hooks/useNumerFormatter'; +import type { IChartMetric } from '@/types'; +import { theme } from '@/utils/theme'; +import AutoSizer from 'react-virtualized-auto-sizer'; +import { Area, AreaChart } from 'recharts'; + +import { PreviousDiffIndicator } from '../PreviousDiffIndicator'; + +interface MetricCardProps { + serie: IChartData['series'][number]; + color?: string; + metric: IChartMetric; + unit?: string; +} + +export function MetricCard({ + serie, + color: _color, + metric, + unit, +}: MetricCardProps) { + const color = _color || theme?.colors['chart-0']; + const number = useNumber(); + return ( +
+
+ + {({ width, height }) => ( + + + + + + + + + + )} + +
+
+
+
+ {serie.event.id} + {serie.name ?? serie.event.displayName ?? serie.event.name} +
+ +
+
+
+ {number.format(serie.metrics[metric])} + {unit && {unit}} +
+ {!!serie.metrics.previous[metric] && ( +
+ {number.format(serie.metrics.previous[metric]?.value)} + {unit} +
+ )} +
+
+
+ ); +} diff --git a/apps/web/src/components/report/chart/ReportAreaChart.tsx b/apps/web/src/components/report/chart/ReportAreaChart.tsx index b6e64fc8..dc1b8c95 100644 --- a/apps/web/src/components/report/chart/ReportAreaChart.tsx +++ b/apps/web/src/components/report/chart/ReportAreaChart.tsx @@ -34,7 +34,7 @@ export function ReportAreaChart({ const { editMode } = useChartContext(); const { series, setVisibleSeries } = useVisibleSeries(data); const formatDate = useFormatDateInterval(interval); - const rechartData = useRechartDataModel(data); + const rechartData = useRechartDataModel(series); return ( <> diff --git a/apps/web/src/components/report/chart/ReportBarChart.tsx b/apps/web/src/components/report/chart/ReportBarChart.tsx index 545f118d..f36dbb14 100644 --- a/apps/web/src/components/report/chart/ReportBarChart.tsx +++ b/apps/web/src/components/report/chart/ReportBarChart.tsx @@ -9,6 +9,11 @@ import { TableHeader, TableRow, } from '@/components/ui/table'; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip'; import { useNumber } from '@/hooks/useNumerFormatter'; import { cn } from '@/utils/cn'; import { getChartColor } from '@/utils/theme'; @@ -21,7 +26,6 @@ import { } from '@tanstack/react-table'; import type { SortingState } from '@tanstack/react-table'; import { ChevronDown, ChevronUp } from 'lucide-react'; -import { useElementSize } from 'usehooks-ts'; import { PreviousDiffIndicator } from '../PreviousDiffIndicator'; import { useChartContext } from './ChartProvider'; @@ -34,10 +38,11 @@ interface ReportBarChartProps { } export function ReportBarChart({ data }: ReportBarChartProps) { - const { editMode } = useChartContext(); - const [ref, { width }] = useElementSize(); + const { editMode, metric, unit, onClick } = useChartContext(); const [sorting, setSorting] = useState([]); - const maxCount = Math.max(...data.series.map((serie) => serie.metrics.sum)); + const maxCount = Math.max( + ...data.series.map((serie) => serie.metrics[metric]) + ); const number = useNumber(); const table = useReactTable({ data: useMemo( @@ -53,46 +58,45 @@ export function ReportBarChart({ data }: ReportBarChartProps) { return (
{info.row.original.event.id} - {info.getValue()} + + +
+ {info.getValue()} +
+
+ {info.getValue()} +
); }, - footer: (info) => info.column.id, - size: width ? width * 0.3 : undefined, }), - columnHelper.accessor((row) => row.metrics.sum, { + columnHelper.accessor((row) => row.metrics[metric], { id: 'totalCount', cell: (info) => ( -
-
{number.format(info.getValue())}
+
+
+
+
+
+ {number.format(info.getValue())} + {unit} +
), header: () => 'Count', - footer: (info) => info.column.id, - size: width ? width * 0.1 : undefined, enableSorting: true, }), - columnHelper.accessor((row) => row.metrics.sum, { - id: 'graph', - cell: (info) => ( -
- ), - header: () => 'Graph', - footer: (info) => info.column.id, - size: width ? width * 0.6 : undefined, - }), ]; - }, [width]), - columnResizeMode: 'onChange', + }, [maxCount, number]), state: { sorting, }, @@ -102,85 +106,64 @@ export function ReportBarChart({ data }: ReportBarChartProps) { }); return ( -
-
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - -
- {flexRender( - header.column.columnDef.header, - header.getContext() - )} - {{ - asc: , - desc: , - }[header.column.getIsSorted() as string] ?? null} -
-
- - ))} - +
+ + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + +
+ {flexRender( + header.column.columnDef.header, + header.getContext() + )} + {{ + asc: , + desc: , + }[header.column.getIsSorted() as string] ?? null} +
+
))} -
- - {table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + ))} - -
-
-
+ + ))} + + ); } diff --git a/apps/web/src/components/report/chart/ReportChartTooltip.tsx b/apps/web/src/components/report/chart/ReportChartTooltip.tsx index 852fd9d6..8b904bc1 100644 --- a/apps/web/src/components/report/chart/ReportChartTooltip.tsx +++ b/apps/web/src/components/report/chart/ReportChartTooltip.tsx @@ -19,7 +19,7 @@ export function ReportChartTooltip({ active, payload, }: ReportLineChartTooltipProps) { - const { previous } = useChartContext(); + const { previous, unit } = useChartContext(); const getLabel = useMappings(); const interval = useSelector((state) => state.report.interval); const formatDate = useFormatDateInterval(interval); @@ -41,7 +41,7 @@ export function ReportChartTooltip({ const hidden = sorted.slice(limit); return ( -
+
{visible.map((item, index) => { // If we have a component, payload can be nested const payload = item.payload.payload ?? item.payload; @@ -57,11 +57,11 @@ export function ReportChartTooltip({ {index === 0 && data.date && (
{formatDate(new Date(data.date))}
- {previous && data.previous?.date && ( + {/* {previous && data.previous?.date && (
{formatDate(new Date(data.previous.date))}
- )} + )} */}
)}
@@ -74,11 +74,15 @@ export function ReportChartTooltip({ {getLabel(data.label)}
-
{number.format(data.count)}
+
+ {number.format(data.count)} + {unit} +
- {!!data.previous && `(${data.previous.count})`} + {!!data.previous && + `(${data.previous.value + (unit ? unit : '')})`}
diff --git a/apps/web/src/components/report/chart/ReportHistogramChart.tsx b/apps/web/src/components/report/chart/ReportHistogramChart.tsx index 7d057aec..cfa15bb1 100644 --- a/apps/web/src/components/report/chart/ReportHistogramChart.tsx +++ b/apps/web/src/components/report/chart/ReportHistogramChart.tsx @@ -32,7 +32,7 @@ export function ReportHistogramChart({ const formatDate = useFormatDateInterval(interval); const { series, setVisibleSeries } = useVisibleSeries(data); - const rechartData = useRechartDataModel(data); + const rechartData = useRechartDataModel(series); return ( <> diff --git a/apps/web/src/components/report/chart/ReportLineChart.tsx b/apps/web/src/components/report/chart/ReportLineChart.tsx index 4fa51382..a4206ab5 100644 --- a/apps/web/src/components/report/chart/ReportLineChart.tsx +++ b/apps/web/src/components/report/chart/ReportLineChart.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React from 'react'; import type { IChartData } from '@/app/_trpc/client'; import { AutoSizer } from '@/components/AutoSizer'; import { useFormatDateInterval } from '@/hooks/useFormatDateInterval'; @@ -35,7 +35,7 @@ export function ReportLineChart({ const { editMode, previous } = useChartContext(); const formatDate = useFormatDateInterval(interval); const { series, setVisibleSeries } = useVisibleSeries(data); - const rechartData = useRechartDataModel(data); + const rechartData = useRechartDataModel(series); return ( <> diff --git a/apps/web/src/components/report/chart/ReportMapChart.tsx b/apps/web/src/components/report/chart/ReportMapChart.tsx new file mode 100644 index 00000000..2b83b875 --- /dev/null +++ b/apps/web/src/components/report/chart/ReportMapChart.tsx @@ -0,0 +1,40 @@ +import { useMemo } from 'react'; +import type { IChartData } from '@/app/_trpc/client'; +import { useVisibleSeries } from '@/hooks/useVisibleSeries'; +import { theme } from '@/utils/theme'; +import WorldMap from 'react-svg-worldmap'; +import AutoSizer from 'react-virtualized-auto-sizer'; + +import { useChartContext } from './ChartProvider'; + +interface ReportMapChartProps { + data: IChartData; +} + +export function ReportMapChart({ data }: ReportMapChartProps) { + const { metric, unit } = useChartContext(); + const { series } = useVisibleSeries(data, 100); + + const mapData = useMemo( + () => + series.map((s) => ({ + country: s.name.toLowerCase(), + value: s.metrics[metric], + })), + [series, metric] + ); + + return ( + + {({ width }) => ( + + )} + + ); +} diff --git a/apps/web/src/components/report/chart/ReportMetricChart.tsx b/apps/web/src/components/report/chart/ReportMetricChart.tsx index 0b7137fc..59a107b0 100644 --- a/apps/web/src/components/report/chart/ReportMetricChart.tsx +++ b/apps/web/src/components/report/chart/ReportMetricChart.tsx @@ -1,25 +1,17 @@ import type { IChartData } from '@/app/_trpc/client'; -import { ColorSquare } from '@/components/ColorSquare'; -import { useNumber } from '@/hooks/useNumerFormatter'; import { useVisibleSeries } from '@/hooks/useVisibleSeries'; import { cn } from '@/utils/cn'; -import { theme } from '@/utils/theme'; -import { ChevronDown, ChevronUp, ChevronUpCircle } from 'lucide-react'; -import AutoSizer from 'react-virtualized-auto-sizer'; -import { Area, AreaChart } from 'recharts'; -import { PreviousDiffIndicator } from '../PreviousDiffIndicator'; import { useChartContext } from './ChartProvider'; +import { MetricCard } from './MetricCard'; interface ReportMetricChartProps { data: IChartData; } export function ReportMetricChart({ data }: ReportMetricChartProps) { - const { editMode } = useChartContext(); + const { editMode, metric, unit } = useChartContext(); const { series } = useVisibleSeries(data, editMode ? undefined : 2); - const color = theme?.colors['chart-0']; - const number = useNumber(); return (
{series.map((serie) => { return ( -
-
- - {({ width, height }) => ( - - - - - - - - - - )} - -
-
-
- {serie.event.id} - {serie.name ?? serie.event.displayName ?? serie.event.name} -
-
-
- {number.format(serie.metrics.sum)} -
- {!!serie.metrics.previous.sum && ( -
- -
- {number.format(serie.metrics.previous.sum.value)} -
-
-
- )} -
-
-
+ serie={serie} + metric={metric} + unit={unit} + /> ); })}
diff --git a/apps/web/src/components/report/chart/ReportPieChart.tsx b/apps/web/src/components/report/chart/ReportPieChart.tsx index e6c4c971..5ba9fd8c 100644 --- a/apps/web/src/components/report/chart/ReportPieChart.tsx +++ b/apps/web/src/components/report/chart/ReportPieChart.tsx @@ -1,10 +1,10 @@ -import { useEffect, useRef, useState } from 'react'; import type { IChartData } from '@/app/_trpc/client'; import { AutoSizer } from '@/components/AutoSizer'; import { useVisibleSeries } from '@/hooks/useVisibleSeries'; import { cn } from '@/utils/cn'; import { round } from '@/utils/math'; import { getChartColor } from '@/utils/theme'; +import { truncate } from '@/utils/truncate'; import { Cell, Pie, PieChart, Tooltip } from 'recharts'; import { useChartContext } from './ChartProvider'; @@ -15,66 +15,19 @@ interface ReportPieChartProps { data: IChartData; } -const RADIAN = Math.PI / 180; -const renderLabel = ({ - x, - y, - cx, - cy, - midAngle, - innerRadius, - outerRadius, - payload, - ...props -}: any) => { - const radius = innerRadius + (outerRadius - innerRadius) * 0.5; - const xx = cx + radius * Math.cos(-midAngle * RADIAN); - const yy = cy + radius * Math.sin(-midAngle * RADIAN); - const label = payload.label; - const percent = round(payload.percent * 100, 1); - - return ( - <> - - {percent}% - - - {label} - - - ); -}; - export function ReportPieChart({ data }: ReportPieChartProps) { const { editMode } = useChartContext(); const { series, setVisibleSeries } = useVisibleSeries(data); const sum = series.reduce((acc, serie) => acc + serie.metrics.sum, 0); - // Get max 10 series and than combine others into one - const pieData = series.map((serie) => { - return { - id: serie.name, - color: getChartColor(serie.index), - index: serie.index, - label: serie.name, - count: serie.metrics.sum, - percent: serie.metrics.sum / sum, - }; - }); + const pieData = series.map((serie) => ({ + id: serie.name, + color: getChartColor(serie.index), + index: serie.index, + label: serie.name, + count: serie.metrics.sum, + percent: serie.metrics.sum / sum, + })); return ( <> @@ -127,3 +80,58 @@ export function ReportPieChart({ data }: ReportPieChartProps) { ); } + +const renderLabel = ({ + cx, + cy, + midAngle, + innerRadius, + outerRadius, + fill, + payload, +}: { + cx: number; + cy: number; + midAngle: number; + innerRadius: number; + outerRadius: number; + fill: string; + payload: { label: string; percent: number }; +}) => { + const RADIAN = Math.PI / 180; + const radius = 25 + innerRadius + (outerRadius - innerRadius); + const radiusProcent = innerRadius + (outerRadius - innerRadius) * 0.5; + const xProcent = cx + radiusProcent * Math.cos(-midAngle * RADIAN); + const yProcent = cy + radiusProcent * Math.sin(-midAngle * RADIAN); + const x = cx + radius * Math.cos(-midAngle * RADIAN); + const y = cy + radius * Math.sin(-midAngle * RADIAN); + const label = payload.label; + const percent = round(payload.percent * 100, 1); + + return ( + <> + + {percent}% + + cx ? 'start' : 'end'} + dominantBaseline="central" + fontSize={10} + > + {truncate(label, 20)} + + + ); +}; diff --git a/apps/web/src/components/report/chart/index.tsx b/apps/web/src/components/report/chart/index.tsx index c1811edb..b19221ef 100644 --- a/apps/web/src/components/report/chart/index.tsx +++ b/apps/web/src/components/report/chart/index.tsx @@ -1,6 +1,7 @@ 'use client'; import { memo } from 'react'; +import type { RouterOutputs } from '@/app/_trpc/client'; import { api } from '@/app/_trpc/client'; import { useAppParams } from '@/hooks/useAppParams'; import type { IChartInput } from '@/types'; @@ -11,10 +12,13 @@ import { ReportAreaChart } from './ReportAreaChart'; import { ReportBarChart } from './ReportBarChart'; import { ReportHistogramChart } from './ReportHistogramChart'; import { ReportLineChart } from './ReportLineChart'; +import { ReportMapChart } from './ReportMapChart'; import { ReportMetricChart } from './ReportMetricChart'; import { ReportPieChart } from './ReportPieChart'; -export type ReportChartProps = IChartInput; +export type ReportChartProps = IChartInput & { + initialData?: RouterOutputs['chart']['chart']; +}; export const Chart = memo( withChartProivder(function Chart({ @@ -26,18 +30,23 @@ export const Chart = memo( range, lineType, previous, + formula, + unit, + metric, + initialData, }: ReportChartProps) { const params = useAppParams(); const hasEmptyFilters = events.some((event) => event.filters.some((filter) => filter.value.length === 0) ); const enabled = events.length > 0 && !hasEmptyFilters; + const chart = api.chart.chart.useQuery( { - interval, - chartType, // dont send lineType since it does not need to be sent lineType: 'monotone', + interval, + chartType, events, breakdowns, name, @@ -46,10 +55,14 @@ export const Chart = memo( endDate: null, projectId: params.projectId, previous, + formula, + unit, + metric, }, { - keepPreviousData: false, + keepPreviousData: true, enabled, + initialData, } ); @@ -66,10 +79,10 @@ export const Chart = memo( ); } - if (chart.isFetching) { + if (chart.isLoading) { return ( - + {/* */}

Loading...

); @@ -99,6 +112,10 @@ export const Chart = memo( ); } + if (chartType === 'map') { + return ; + } + if (chartType === 'histogram') { return ; } diff --git a/apps/web/src/components/report/reportSlice.ts b/apps/web/src/components/report/reportSlice.ts index 118eb4c3..b8c3dec6 100644 --- a/apps/web/src/components/report/reportSlice.ts +++ b/apps/web/src/components/report/reportSlice.ts @@ -27,6 +27,8 @@ type InitialState = IChartInput & { const initialState: InitialState = { ready: false, dirty: false, + // TODO: remove this + projectId: '', name: 'Untitled', chartType: 'linear', lineType: 'monotone', @@ -37,6 +39,9 @@ const initialState: InitialState = { startDate: null, endDate: null, previous: false, + formula: undefined, + unit: undefined, + metric: 'sum', }; export const reportSlice = createSlice({ @@ -100,6 +105,12 @@ export const reportSlice = createSlice({ }); }, + // Previous + changePrevious: (state, action: PayloadAction) => { + state.dirty = true; + state.previous = action.payload; + }, + // Breakdowns addBreakdown: ( state, @@ -181,6 +192,12 @@ export const reportSlice = createSlice({ state.range = action.payload; state.interval = getDefaultIntervalByRange(action.payload); }, + + // Formula + changeFormula: (state, action: PayloadAction) => { + state.dirty = true; + state.formula = action.payload; + }, }, }); @@ -201,6 +218,8 @@ export const { changeChartType, changeLineType, resetDirty, + changeFormula, + changePrevious, } = reportSlice.actions; export default reportSlice.reducer; diff --git a/apps/web/src/components/report/sidebar/EventPropertiesCombobox.tsx b/apps/web/src/components/report/sidebar/EventPropertiesCombobox.tsx new file mode 100644 index 00000000..b6751e48 --- /dev/null +++ b/apps/web/src/components/report/sidebar/EventPropertiesCombobox.tsx @@ -0,0 +1,62 @@ +import { api } from '@/app/_trpc/client'; +import { Combobox } from '@/components/ui/combobox'; +import { useAppParams } from '@/hooks/useAppParams'; +import { useDispatch } from '@/redux'; +import type { IChartEvent } from '@/types'; +import { cn } from '@/utils/cn'; +import { DatabaseIcon, FilterIcon } from 'lucide-react'; + +import { changeEvent } from '../reportSlice'; + +interface EventPropertiesComboboxProps { + event: IChartEvent; +} + +export function EventPropertiesCombobox({ + event, +}: EventPropertiesComboboxProps) { + const dispatch = useDispatch(); + const { projectId } = useAppParams(); + + const query = api.chart.properties.useQuery( + { + event: event.name, + projectId, + }, + { + enabled: !!event.name, + } + ); + + const properties = (query.data ?? []).map((item) => ({ + label: item, + value: item, + })); + + return ( + { + dispatch( + changeEvent({ + ...event, + property: value, + }) + ); + }} + > + + + ); +} diff --git a/apps/web/src/components/report/sidebar/ReportEventMore.tsx b/apps/web/src/components/report/sidebar/ReportEventMore.tsx index 987a5f34..25168b87 100644 --- a/apps/web/src/components/report/sidebar/ReportEventMore.tsx +++ b/apps/web/src/components/report/sidebar/ReportEventMore.tsx @@ -34,7 +34,7 @@ const labels = [ ]; export interface ReportEventMoreProps { - onClick: (action: 'createFilter' | 'remove') => void; + onClick: (action: 'remove') => void; } export function ReportEventMore({ onClick }: ReportEventMoreProps) { @@ -49,10 +49,6 @@ export function ReportEventMore({ onClick }: ReportEventMoreProps) { - onClick('createFilter')}> - - Add filter - state.report.previous); const selectedEvents = useSelector((state) => state.report.events); const dispatch = useDispatch(); const params = useAppParams(); @@ -37,9 +43,6 @@ export function ReportEvents() { const handleMore = (event: IChartEvent) => { const callback: ReportEventMoreProps['onClick'] = (action) => { switch (action) { - case 'createFilter': { - return setIsCreating(true); - } case 'remove': { return dispatch(removeEvent(event)); } @@ -111,12 +114,20 @@ export function ReportEvents() { }, { value: 'user_average', - label: 'Unique users (average)', + label: 'Average event per user', }, { value: 'one_event_per_user', label: 'One event per user', }, + { + value: 'property_sum', + label: 'Sum of property', + }, + { + value: 'property_average', + label: 'Average of property', + }, ]} label="Segment" > @@ -127,12 +138,20 @@ export function ReportEvents() { ) : event.segment === 'user_average' ? ( <> - Unique users (average) + Average event per user ) : event.segment === 'one_event_per_user' ? ( <> One event per user + ) : event.segment === 'property_sum' ? ( + <> + Sum of property + + ) : event.segment === 'property_average' ? ( + <> + Average of property + ) : ( <> All events @@ -140,18 +159,17 @@ export function ReportEvents() { )} - + {/* */} + + + {(event.segment === 'property_average' || + event.segment === 'property_sum') && ( + + )}
{/* Filters */} - +
); })} @@ -172,6 +190,17 @@ export function ReportEvents() { placeholder="Select event" />
+
); } diff --git a/apps/web/src/components/report/sidebar/ReportForumula.tsx b/apps/web/src/components/report/sidebar/ReportForumula.tsx new file mode 100644 index 00000000..1908bbe7 --- /dev/null +++ b/apps/web/src/components/report/sidebar/ReportForumula.tsx @@ -0,0 +1,26 @@ +'use client'; + +import { Input } from '@/components/ui/input'; +import { useDispatch, useSelector } from '@/redux'; + +import { changeFormula } from '../reportSlice'; + +export function ReportForumula() { + const forumula = useSelector((state) => state.report.formula); + const dispatch = useDispatch(); + + return ( +
+

Forumula

+
+ { + dispatch(changeFormula(event.target.value)); + }} + /> +
+
+ ); +} diff --git a/apps/web/src/components/report/sidebar/ReportSidebar.tsx b/apps/web/src/components/report/sidebar/ReportSidebar.tsx index 00b57b3e..7fc31b2d 100644 --- a/apps/web/src/components/report/sidebar/ReportSidebar.tsx +++ b/apps/web/src/components/report/sidebar/ReportSidebar.tsx @@ -3,11 +3,13 @@ import { SheetClose } from '@/components/ui/sheet'; import { ReportBreakdowns } from './ReportBreakdowns'; import { ReportEvents } from './ReportEvents'; +import { ReportForumula } from './ReportForumula'; export function ReportSidebar() { return (
+
diff --git a/apps/web/src/components/report/sidebar/ReportEventFilters.tsx b/apps/web/src/components/report/sidebar/filters/FilterItem.tsx similarity index 57% rename from apps/web/src/components/report/sidebar/ReportEventFilters.tsx rename to apps/web/src/components/report/sidebar/filters/FilterItem.tsx index 1259be2d..72033e11 100644 --- a/apps/web/src/components/report/sidebar/ReportEventFilters.tsx +++ b/apps/web/src/components/report/sidebar/filters/FilterItem.tsx @@ -1,18 +1,8 @@ -import type { Dispatch } from 'react'; import { api } from '@/app/_trpc/client'; import { ColorSquare } from '@/components/ColorSquare'; import { Dropdown } from '@/components/Dropdown'; import { Button } from '@/components/ui/button'; import { ComboboxAdvanced } from '@/components/ui/combobox-advanced'; -import { - CommandDialog, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, - CommandSeparator, -} from '@/components/ui/command'; import { RenderDots } from '@/components/ui/RenderDots'; import { useAppParams } from '@/hooks/useAppParams'; import { useMappings } from '@/hooks/useMappings'; @@ -23,93 +13,24 @@ import type { IChartEventFilterValue, } from '@/types'; import { operators } from '@/utils/constants'; -import { CreditCard, SlidersHorizontal, Trash } from 'lucide-react'; +import { SlidersHorizontal, Trash } from 'lucide-react'; import { useParams } from 'next/navigation'; -import { changeEvent } from '../reportSlice'; - -interface ReportEventFiltersProps { - event: IChartEvent; - isCreating: boolean; - setIsCreating: Dispatch; -} - -export function ReportEventFilters({ - event, - isCreating, - setIsCreating, -}: ReportEventFiltersProps) { - const params = useAppParams(); - const dispatch = useDispatch(); - const propertiesQuery = api.chart.properties.useQuery( - { - event: event.name, - projectId: params.projectId, - }, - { - enabled: !!event.name, - } - ); - - return ( -
-
- {event.filters.map((filter) => { - return ; - })} - - - - - Such emptyness 🤨 - - {propertiesQuery.data?.map((item) => ( - { - setIsCreating(false); - dispatch( - changeEvent({ - ...event, - filters: [ - ...event.filters, - { - id: (event.filters.length + 1).toString(), - name: item, - operator: 'is', - value: [], - }, - ], - }) - ); - }} - > - - {item} - - ))} - - - - -
-
- ); -} +import { changeEvent } from '../../reportSlice'; interface FilterProps { event: IChartEvent; filter: IChartEvent['filters'][number]; } -function Filter({ filter, event }: FilterProps) { - const params = useParams<{ organizationId: string; projectId: string }>(); +export function FilterItem({ filter, event }: FilterProps) { + const { projectId } = useAppParams(); const getLabel = useMappings(); const dispatch = useDispatch(); const potentialValues = api.chart.values.useQuery({ event: event.name, property: filter.name, - projectId: params?.projectId!, + projectId, }); const valuesCombobox = diff --git a/apps/web/src/components/report/sidebar/filters/FiltersCombobox.tsx b/apps/web/src/components/report/sidebar/filters/FiltersCombobox.tsx new file mode 100644 index 00000000..e639b61c --- /dev/null +++ b/apps/web/src/components/report/sidebar/filters/FiltersCombobox.tsx @@ -0,0 +1,61 @@ +import { api } from '@/app/_trpc/client'; +import { Combobox } from '@/components/ui/combobox'; +import { useAppParams } from '@/hooks/useAppParams'; +import { useDispatch } from '@/redux'; +import type { IChartEvent } from '@/types'; +import { FilterIcon } from 'lucide-react'; + +import { changeEvent } from '../../reportSlice'; + +interface FiltersComboboxProps { + event: IChartEvent; +} + +export function FiltersCombobox({ event }: FiltersComboboxProps) { + const dispatch = useDispatch(); + const { projectId } = useAppParams(); + + const query = api.chart.properties.useQuery( + { + event: event.name, + projectId, + }, + { + enabled: !!event.name, + } + ); + + const properties = (query.data ?? []).map((item) => ({ + label: item, + value: item, + })); + + return ( + { + dispatch( + changeEvent({ + ...event, + filters: [ + ...event.filters, + { + id: (event.filters.length + 1).toString(), + name: value, + operator: 'is', + value: [], + }, + ], + }) + ); + }} + > + + + ); +} diff --git a/apps/web/src/components/report/sidebar/filters/FiltersList.tsx b/apps/web/src/components/report/sidebar/filters/FiltersList.tsx new file mode 100644 index 00000000..03c4f920 --- /dev/null +++ b/apps/web/src/components/report/sidebar/filters/FiltersList.tsx @@ -0,0 +1,19 @@ +import type { IChartEvent } from '@/types'; + +import { FilterItem } from './FilterItem'; + +interface ReportEventFiltersProps { + event: IChartEvent; +} + +export function FiltersList({ event }: ReportEventFiltersProps) { + return ( +
+
+ {event.filters.map((filter) => { + return ; + })} +
+
+ ); +} diff --git a/apps/web/src/components/ui/badge.tsx b/apps/web/src/components/ui/badge.tsx index c8df0653..60b45ea9 100644 --- a/apps/web/src/components/ui/badge.tsx +++ b/apps/web/src/components/ui/badge.tsx @@ -6,7 +6,7 @@ import { cva } from 'class-variance-authority'; import type { VariantProps } from 'class-variance-authority'; const badgeVariants = cva( - 'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', + 'inline-flex items-center rounded-full border px-1.5 h-[20px] text-[10px] font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', { variants: { variant: { @@ -16,6 +16,8 @@ const badgeVariants = cva( 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80', destructive: 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80', + success: + 'border-transparent bg-emerald-500 text-emerald-100 hover:bg-emerald-500/80', outline: 'text-foreground', }, }, diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx index 5e4f5e59..0ab333b1 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -38,7 +38,7 @@ const buttonVariants = cva( } ); -interface ButtonProps +export interface ButtonProps extends React.ButtonHTMLAttributes, VariantProps { asChild?: boolean; diff --git a/apps/web/src/components/ui/combobox.tsx b/apps/web/src/components/ui/combobox.tsx index 706d51a6..a939345a 100644 --- a/apps/web/src/components/ui/combobox.tsx +++ b/apps/web/src/components/ui/combobox.tsx @@ -1,6 +1,7 @@ 'use client'; import * as React from 'react'; +import type { ButtonProps } from '@/components/ui/button'; import { Button } from '@/components/ui/button'; import { Command, @@ -15,9 +16,10 @@ import { PopoverTrigger, } from '@/components/ui/popover'; import { cn } from '@/utils/cn'; +import type { LucideIcon } from 'lucide-react'; import { Check, ChevronsUpDown } from 'lucide-react'; -interface ComboboxProps { +export interface ComboboxProps { placeholder: string; items: { value: T; @@ -30,8 +32,18 @@ interface ComboboxProps { onCreate?: (value: T) => void; className?: string; searchable?: boolean; + icon?: LucideIcon; + size?: ButtonProps['size']; + label?: string; } +export type ExtendedComboboxProps = Omit< + ComboboxProps, + 'items' | 'placeholder' +> & { + placeholder?: string; +}; + export function Combobox({ placeholder, items, @@ -41,6 +53,9 @@ export function Combobox({ onCreate, className, searchable, + icon: Icon, + size, + label, }: ComboboxProps) { const [open, setOpen] = React.useState(false); const [search, setSearch] = React.useState(''); @@ -55,11 +70,13 @@ export function Combobox({ {children ?? ( )} - + {searchable === true && ( ({