diff --git a/README.md b/README.md index 6939b374..0ccdf389 100644 --- a/README.md +++ b/README.md @@ -23,8 +23,15 @@ As of today (2023-12-12) I have more then 1.2 million events in PSQL and perform - [x] Fix tables on settings - [x] Rename event label -- [ ] Real time data (mostly screen_views stats) - - [ ] Active users (5min, 10min, 30min) +- [ ] Common web dashboard + - [x] User histogram (last 30 minutes) + - [ ] Bounce rate + - [ ] Session duration + - [ ] Views per session + - [ ] Unique users + - [ ] Total users + - [ ] Total pageviews + - [ ] Total events - [x] Save report to a specific dashboard - [x] View events in a list - [x] Simple filters @@ -34,6 +41,7 @@ As of today (2023-12-12) I have more then 1.2 million events in PSQL and perform - [x] Manage dashboards - [ ] Support more chart types - [x] Bar + - [x] Histogram - [ ] Pie - [ ] Area - [ ] Support funnels diff --git a/apps/web/package.json b/apps/web/package.json index 64e9f722..14a797af 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -70,6 +70,7 @@ "@mixan/prettier-config": "workspace:*", "@mixan/tsconfig": "workspace:*", "@types/bcrypt": "^5.0.0", + "@types/lodash.debounce": "^4.0.9", "@types/node": "^18.16.0", "@types/ramda": "^0.29.6", "@types/react": "^18.2.20", diff --git a/apps/web/prisma/migrations/20240107183438_add_histogram/migration.sql b/apps/web/prisma/migrations/20240107183438_add_histogram/migration.sql new file mode 100644 index 00000000..858e765f --- /dev/null +++ b/apps/web/prisma/migrations/20240107183438_add_histogram/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "ChartType" ADD VALUE 'histogram'; diff --git a/apps/web/prisma/migrations/20240107203928_range_remove/migration.sql b/apps/web/prisma/migrations/20240107203928_range_remove/migration.sql new file mode 100644 index 00000000..4b90c5f8 --- /dev/null +++ b/apps/web/prisma/migrations/20240107203928_range_remove/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - You are about to drop the column `range` on the `reports` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "reports" DROP COLUMN "range"; diff --git a/apps/web/prisma/migrations/20240107204032_range_add/migration.sql b/apps/web/prisma/migrations/20240107204032_range_add/migration.sql new file mode 100644 index 00000000..2a5b37da --- /dev/null +++ b/apps/web/prisma/migrations/20240107204032_range_add/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "reports" ADD COLUMN "range" TEXT NOT NULL DEFAULT '1m'; diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index b606809d..c96479f5 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -115,6 +115,7 @@ enum Interval { enum ChartType { linear bar + histogram pie metric area @@ -138,7 +139,7 @@ model Report { id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid name String interval Interval - range Int + range String @default("1m") chart_type ChartType breakdowns Json events Json diff --git a/apps/web/src/components/report/ReportDateRange.tsx b/apps/web/src/components/report/ReportDateRange.tsx index 29f0c56b..12fc2f4c 100644 --- a/apps/web/src/components/report/ReportDateRange.tsx +++ b/apps/web/src/components/report/ReportDateRange.tsx @@ -10,16 +10,16 @@ export function ReportDateRange() { return ( - {timeRanges.map((item) => { + {Object.values(timeRanges).map((key) => { return ( { - dispatch(changeDateRanges(item.range)); + dispatch(changeDateRanges(key)); }} > - {item.title} + {key} ); })} diff --git a/apps/web/src/components/report/ReportInterval.tsx b/apps/web/src/components/report/ReportInterval.tsx index ec86a5e8..60eadff4 100644 --- a/apps/web/src/components/report/ReportInterval.tsx +++ b/apps/web/src/components/report/ReportInterval.tsx @@ -38,7 +38,11 @@ export function ReportInterval() { { value: 'month', label: 'Month', - disabled: range < 1, + disabled: + range === 'today' || + range === '24h' || + range === '1h' || + range === '30min', }, ]} /> diff --git a/apps/web/src/components/report/chart/ReportHistogramChart.tsx b/apps/web/src/components/report/chart/ReportHistogramChart.tsx new file mode 100644 index 00000000..886ec368 --- /dev/null +++ b/apps/web/src/components/report/chart/ReportHistogramChart.tsx @@ -0,0 +1,107 @@ +import { useEffect, useRef, useState } from 'react'; +import { AutoSizer } from '@/components/AutoSizer'; +import { useFormatDateInterval } from '@/hooks/useFormatDateInterval'; +import type { IChartData, IInterval } from '@/types'; +import { alphabetIds } from '@/utils/constants'; +import { getChartColor } from '@/utils/theme'; +import { Bar, BarChart, CartesianGrid, Tooltip, XAxis, YAxis } from 'recharts'; + +import { useChartContext } from './ChartProvider'; +import { ReportLineChartTooltip } from './ReportLineChartTooltip'; +import { ReportTable } from './ReportTable'; + +interface ReportHistogramChartProps { + data: IChartData; + interval: IInterval; +} + +export function ReportHistogramChart({ + interval, + data, +}: ReportHistogramChartProps) { + const { editMode } = useChartContext(); + const [visibleSeries, setVisibleSeries] = useState([]); + const formatDate = useFormatDateInterval(interval); + + const ref = useRef(false); + useEffect(() => { + if (!ref.current && data) { + const max = 20; + + setVisibleSeries( + data?.series?.slice(0, max).map((serie) => serie.name) ?? [] + ); + // ref.current = true; + } + }, [data]); + + const rel = data.series[0]?.data.map(({ date }) => { + return { + date, + ...data.series.reduce((acc, serie, idx) => { + return { + ...acc, + ...serie.data.reduce( + (acc2, item) => { + const id = alphabetIds[idx]; + if (item.date === date) { + acc2[`${id}:count`] = item.count; + acc2[`${id}:label`] = item.label; + } + return acc2; + }, + {} as Record + ), + }; + }, {}), + }; + }); + + return ( + <> +
+ + {({ width }) => ( + + + } /> + + {data.series.map((serie, index) => { + const id = alphabetIds[index]; + return ( + <> + + + + ); + })} + + )} + +
+ {editMode && ( + + )} + + ); +} diff --git a/apps/web/src/components/report/chart/ReportLineChartTooltip.tsx b/apps/web/src/components/report/chart/ReportLineChartTooltip.tsx index b255c66d..eda4b26d 100644 --- a/apps/web/src/components/report/chart/ReportLineChartTooltip.tsx +++ b/apps/web/src/components/report/chart/ReportLineChartTooltip.tsx @@ -2,6 +2,7 @@ import { useFormatDateInterval } from '@/hooks/useFormatDateInterval'; import { useMappings } from '@/hooks/useMappings'; import { useSelector } from '@/redux'; import type { IToolTipProps } from '@/types'; +import { alphabetIds } from '@/utils/constants'; type ReportLineChartTooltipProps = IToolTipProps<{ color: string; @@ -10,7 +11,7 @@ type ReportLineChartTooltipProps = IToolTipProps<{ date: Date; count: number; label: string; - }; + } & Record; }>; export function ReportLineChartTooltip({ @@ -34,11 +35,13 @@ export function ReportLineChartTooltip({ const visible = sorted.slice(0, limit); const hidden = sorted.slice(limit); const first = visible[0]!; + const isBarChart = first.payload.count === undefined; return (
{formatDate(new Date(first.payload.date))} - {visible.map((item) => { + {visible.map((item, index) => { + const id = alphabetIds[index]; return (
- {getLabel(item.payload.label)} + {isBarChart + ? item.payload[`${id}:label`] + : getLabel(item.payload.label)} +
+
+ {isBarChart ? item.payload[`${id}:count`] : item.payload.count}
-
{item.payload.count}
); diff --git a/apps/web/src/components/report/chart/index.tsx b/apps/web/src/components/report/chart/index.tsx index 95234ff9..31c5cef4 100644 --- a/apps/web/src/components/report/chart/index.tsx +++ b/apps/web/src/components/report/chart/index.tsx @@ -6,6 +6,7 @@ import { api } from '@/utils/api'; import { ChartAnimation, ChartAnimationContainer } from './ChartAnimation'; import { withChartProivder } from './ChartProvider'; import { ReportBarChart } from './ReportBarChart'; +import { ReportHistogramChart } from './ReportHistogramChart'; import { ReportLineChart } from './ReportLineChart'; export type ReportChartProps = IChartInput; @@ -88,6 +89,10 @@ export const Chart = memo( ); } + if (chartType === 'histogram') { + return ; + } + if (chartType === 'bar') { return ; } diff --git a/apps/web/src/components/report/reportSlice.ts b/apps/web/src/components/report/reportSlice.ts index ccf8f0de..cd883e73 100644 --- a/apps/web/src/components/report/reportSlice.ts +++ b/apps/web/src/components/report/reportSlice.ts @@ -24,7 +24,7 @@ const initialState: InitialState = { interval: 'day', breakdowns: [], events: [], - range: 30, + range: '1m', startDate: null, endDate: null, }; @@ -149,11 +149,11 @@ export const reportSlice = createSlice({ changeDateRanges: (state, action: PayloadAction) => { state.dirty = true; state.range = action.payload; - if (action.payload === 0.3 || action.payload === 0.6) { + if (action.payload === '30min' || action.payload === '1h') { state.interval = 'minute'; - } else if (action.payload === 0 || action.payload === 1) { + } else if (action.payload === 'today' || action.payload === '24h') { state.interval = 'hour'; - } else if (action.payload <= 30) { + } else if (action.payload === '7d' || action.payload === '14d') { state.interval = 'day'; } else { state.interval = 'month'; diff --git a/apps/web/src/components/report/sidebar/ReportEvents.tsx b/apps/web/src/components/report/sidebar/ReportEvents.tsx index ab3b6513..9630ba78 100644 --- a/apps/web/src/components/report/sidebar/ReportEvents.tsx +++ b/apps/web/src/components/report/sidebar/ReportEvents.tsx @@ -108,6 +108,10 @@ export function ReportEvents() { value: 'user_average', label: 'Unique users (average)', }, + { + value: 'one_event_per_user', + label: 'One event per user', + }, ]} label="Segment" > @@ -118,7 +122,11 @@ export function ReportEvents() { ) : event.segment === 'user_average' ? ( <> - Average per user + Unique users (average) + + ) : event.segment === 'one_event_per_user' ? ( + <> + One event per user ) : ( <> diff --git a/apps/web/src/hooks/useBreakpoint.ts b/apps/web/src/hooks/useBreakpoint.ts index de8c1fd7..4bfd83c7 100644 --- a/apps/web/src/hooks/useBreakpoint.ts +++ b/apps/web/src/hooks/useBreakpoint.ts @@ -13,7 +13,7 @@ const breakpoints = theme?.screens ?? { export function useBreakpoint(breakpointKey: K) { const breakpointValue = breakpoints[breakpointKey as keyof ScreensConfig]; const bool = useMediaQuery({ - query: `(max-width: ${breakpointValue})`, + query: `(max-width: ${breakpointValue as string})`, }); const capitalizedKey = breakpointKey[0]?.toUpperCase() + breakpointKey.substring(1); diff --git a/apps/web/src/hooks/useDebounceFn.ts b/apps/web/src/hooks/useDebounceFn.ts index 411ad548..dfbc92f2 100644 --- a/apps/web/src/hooks/useDebounceFn.ts +++ b/apps/web/src/hooks/useDebounceFn.ts @@ -2,13 +2,15 @@ import { useEffect } from 'react'; import debounce from 'lodash.debounce'; export function useDebounceFn(fn: T, ms = 500): T { - const debouncedFn = debounce(fn, ms); - + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-call + const debouncedFn = debounce(fn as any, ms); + useEffect(() => { return () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call debouncedFn.cancel(); }; }); - return debouncedFn; + return debouncedFn as T } diff --git a/apps/web/src/pages/[organization]/[project]/[dashboard].tsx b/apps/web/src/pages/[organization]/[project]/[dashboard].tsx index ed3e4546..f70b5f58 100644 --- a/apps/web/src/pages/[organization]/[project]/[dashboard].tsx +++ b/apps/web/src/pages/[organization]/[project]/[dashboard].tsx @@ -15,12 +15,10 @@ import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import { useOrganizationParams } from '@/hooks/useOrganizationParams'; import { db } from '@/server/db'; import { createServerSideProps } from '@/server/getServerSideProps'; -import { getDashboardBySlug } from '@/server/services/dashboard.service'; import type { IChartRange } from '@/types'; import { api, handleError } from '@/utils/api'; import { cn } from '@/utils/cn'; import { timeRanges } from '@/utils/constants'; -import { getRangeLabel } from '@/utils/getRangeLabel'; import { ChevronRight, MoreHorizontal, Trash } from 'lucide-react'; import Link from 'next/link'; @@ -74,16 +72,16 @@ export default function Dashboard() { {dashboard?.name} - {timeRanges.map((item) => { + {Object.values(timeRanges).map((key) => { return ( { - setRange((p) => (p === item.range ? null : item.range)); + setRange((p) => (p === key ? null : key)); }} > - {item.title} + {key} ); })} @@ -91,7 +89,7 @@ export default function Dashboard() {
{reports.map((report) => { - const chartRange = getRangeLabel(report.range); + const chartRange = timeRanges[report.range]; return (
{chartRange} - {range !== null && {getRangeLabel(range)}} + {range !== null && {range}}
)}
diff --git a/apps/web/src/pages/api/setup.ts b/apps/web/src/pages/api/setup.ts index a629dffb..f6dfd7f3 100644 --- a/apps/web/src/pages/api/setup.ts +++ b/apps/web/src/pages/api/setup.ts @@ -10,6 +10,7 @@ export default async function handler( ) { try { const counts = await db.$transaction([ + db.user.count(), db.organization.count(), db.project.count(), db.client.count(), @@ -25,6 +26,15 @@ export default async function handler( }, }); + const user = await db.user.create({ + data: { + name: 'Carl', + password: await hashPassword('password'), + email: 'lindesvard@gmail.com', + organization_id: organization.id, + }, + }); + const project = await db.project.create({ data: { name: 'Acme Website', diff --git a/apps/web/src/server/api/routers/chart.ts b/apps/web/src/server/api/routers/chart.ts index dd21b83a..b128adca 100644 --- a/apps/web/src/server/api/routers/chart.ts +++ b/apps/web/src/server/api/routers/chart.ts @@ -1,12 +1,14 @@ import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc'; import * as cache from '@/server/cache'; +import { getChartSql } from '@/server/chart-sql/getChartSql'; +import { isJsonPath, selectJsonPath } from '@/server/chart-sql/helpers'; import { db } from '@/server/db'; import { getUniqueEvents } from '@/server/services/event.service'; import { getProjectBySlug } from '@/server/services/project.service'; import type { IChartEvent, - IChartInputWithDates, IChartRange, + IGetChartDataInput, IInterval, } from '@/types'; import { getDaysOldDate } from '@/utils/date'; @@ -33,7 +35,12 @@ export const chartRouter = createTRPCRouter({ () => getUniqueEvents({ projectId: project.id }) ); - return events; + return [ + { + name: '*', + }, + ...events, + ]; }), properties: protectedProcedure @@ -124,12 +131,21 @@ export const chartRouter = createTRPCRouter({ chart: protectedProcedure .input(zChartInputWithDates.merge(z.object({ projectSlug: z.string() }))) .query(async ({ input: { projectSlug, events, ...input } }) => { + const { startDate, endDate } = + input.startDate && input.endDate + ? { + startDate: input.startDate, + endDate: input.endDate, + } + : getDatesFromRange(input.range); const project = await getProjectBySlug(projectSlug); const series: Awaited> = []; for (const event of events) { series.push( ...(await getChartData({ ...input, + startDate, + endDate, event, projectId: project.id, })) @@ -176,48 +192,18 @@ export const chartRouter = createTRPCRouter({ }), }); -function selectJsonPath(property: string) { - const jsonPath = property - .replace(/^properties\./, '') - .replace(/\.\*\./g, '.**.'); - return `jsonb_path_query(properties, '$.${jsonPath}')`; -} - -function isJsonPath(property: string) { - return property.startsWith('properties'); -} - interface ResultItem { label: string | null; count: number; date: string; } -function propertyNameToSql(name: string) { - if (name.includes('.')) { - const str = name - .split('.') - .map((item, index) => (index === 0 ? item : `'${item}'`)) - .join('->'); - const findLastOf = '->'; - const lastArrow = str.lastIndexOf(findLastOf); - if (lastArrow === -1) { - return str; - } - const first = str.slice(0, lastArrow); - const last = str.slice(lastArrow + findLastOf.length); - return `${first}->>${last}`; - } - - return name; -} - function getEventLegend(event: IChartEvent) { return event.displayName ?? `${event.name} (${event.id})`; } function getDatesFromRange(range: IChartRange) { - if (range === 0) { + if (range === 'today') { const startDate = new Date(); const endDate = new Date().toISOString(); startDate.setHours(0, 0, 0, 0); @@ -228,9 +214,9 @@ function getDatesFromRange(range: IChartRange) { }; } - if (isFloat(range)) { + if (range === '30min' || range === '1h') { const startDate = new Date( - Date.now() - 1000 * 60 * (range * 100) + Date.now() - 1000 * 60 * (range === '30min' ? 30 : 60) ).toISOString(); const endDate = new Date().toISOString(); @@ -240,7 +226,25 @@ function getDatesFromRange(range: IChartRange) { }; } - const startDate = getDaysOldDate(range); + let days = 1; + + if (range === '24h') { + days = 1; + } else if (range === '7d') { + days = 7; + } else if (range === '14d') { + days = 14; + } else if (range === '1m') { + days = 30; + } else if (range === '3m') { + days = 90; + } else if (range === '6m') { + days = 180; + } else if (range === '1y') { + days = 365; + } + + const startDate = getDaysOldDate(days); startDate.setUTCHours(0, 0, 0, 0); const endDate = new Date(); endDate.setUTCHours(23, 59, 59, 999); @@ -250,202 +254,14 @@ function getDatesFromRange(range: IChartRange) { }; } -function getChartSql({ - event, - chartType, - breakdowns, - interval, - startDate, - endDate, - projectId, -}: Omit & { - projectId: string; -}) { - const select = []; - const where = [`project_id = '${projectId}'`]; - const groupBy = []; - const orderBy = []; +async function getChartData(payload: IGetChartDataInput) { + let result = await db.$queryRawUnsafe(getChartSql(payload)); - if (event.segment === 'event') { - select.push(`count(*)::int as count`); - } else if (event.segment === 'user_average') { - select.push(`COUNT(*)::float / COUNT(DISTINCT profile_id)::float as count`); - } else { - select.push(`count(DISTINCT profile_id)::int as count`); - } - - switch (chartType) { - case 'bar': { - orderBy.push('count DESC'); - break; - } - case 'linear': { - select.push(`date_trunc('${interval}', "createdAt") as date`); - groupBy.push('date'); - orderBy.push('date'); - break; - } - } - - if (event) { - const { name, filters } = event; - where.push(`name = '${name}'`); - if (filters.length > 0) { - filters.forEach((filter) => { - const { name, value, operator } = filter; - switch (operator) { - case 'contains': { - if (name.includes('.*.') || name.endsWith('[*]')) { - // TODO: Make sure this works - // where.push( - // `properties @? '$.${name - // .replace(/^properties\./, '') - // .replace(/\.\*\./g, '[*].')} ? (@ like_regex "${value[0]}")'` - // ); - } else { - where.push( - `(${value - .map( - (val) => - `${propertyNameToSql(name)} like '%${String(val).replace( - /'/g, - "''" - )}%'` - ) - .join(' OR ')})` - ); - } - break; - } - case 'is': { - if (name.includes('.*.') || name.endsWith('[*]')) { - where.push( - `properties @? '$.${name - .replace(/^properties\./, '') - .replace(/\.\*\./g, '[*].')} ? (${value - .map((val) => `@ == "${val}"`) - .join(' || ')})'` - ); - } else { - where.push( - `${propertyNameToSql(name)} in (${value - .map((val) => `'${val}'`) - .join(', ')})` - ); - } - break; - } - case 'isNot': { - if (name.includes('.*.') || name.endsWith('[*]')) { - where.push( - `properties @? '$.${name - .replace(/^properties\./, '') - .replace(/\.\*\./g, '[*].')} ? (${value - .map((val) => `@ != "${val}"`) - .join(' && ')})'` - ); - } else if (name.includes('.')) { - where.push( - `${propertyNameToSql(name)} not in (${value - .map((val) => `'${val}'`) - .join(', ')})` - ); - } - break; - } - } - }); - } - } - - if (breakdowns.length) { - const breakdown = breakdowns[0]; - if (breakdown) { - if (isJsonPath(breakdown.name)) { - select.push(`${selectJsonPath(breakdown.name)} as label`); - } else { - select.push(`${breakdown.name} as label`); - } - groupBy.push(`label`); - } - } else { - if (event.name) { - select.push(`'${event.name}' as label`); - } - } - - if (startDate) { - where.push(`"createdAt" >= '${startDate}'`); - } - - if (endDate) { - where.push(`"createdAt" <= '${endDate}'`); - } - - const sql = [ - `SELECT ${select.join(', ')}`, - `FROM events`, - `WHERE ${where.join(' AND ')}`, - ]; - - if (groupBy.length) { - sql.push(`GROUP BY ${groupBy.join(', ')}`); - } - if (orderBy.length) { - sql.push(`ORDER BY ${orderBy.join(', ')}`); - } - - console.log('SQL ->', sql.join('\n')); - - return sql.join('\n'); -} - -type IGetChartDataInput = { - event: IChartEvent; -} & Omit; - -async function getChartData({ - chartType, - event, - breakdowns, - interval, - range, - startDate: _startDate, - endDate: _endDate, - projectId, -}: IGetChartDataInput & { - projectId: string; -}) { - const { startDate, endDate } = - _startDate && _endDate - ? { - startDate: _startDate, - endDate: _endDate, - } - : getDatesFromRange(range); - - const sql = getChartSql({ - chartType, - event, - breakdowns, - interval, - startDate, - endDate, - projectId, - }); - - let result = await db.$queryRawUnsafe(sql); - - if (result.length === 0 && breakdowns.length > 0) { + if (result.length === 0 && payload.breakdowns.length > 0) { result = await db.$queryRawUnsafe( getChartSql({ - chartType, - event, + ...payload, breakdowns: [], - interval, - startDate, - endDate, - projectId, }) ); } @@ -455,7 +271,7 @@ async function getChartData({ (acc, item) => { // item.label can be null when using breakdowns on a property // that doesn't exist on all events - const label = item.label?.trim() ?? event.id; + const label = item.label?.trim() ?? payload.event.id; if (label) { if (acc[label]) { acc[label]?.push(item); @@ -472,30 +288,35 @@ async function getChartData({ ); return Object.keys(series).map((key) => { - const legend = breakdowns.length ? key : getEventLegend(event); + const legend = payload.breakdowns.length + ? key + : getEventLegend(payload.event); const data = series[key] ?? []; return { name: legend, event: { - id: event.id, - name: event.name, + id: payload.event.id, + name: payload.event.name, }, metrics: { total: sum(data.map((item) => item.count)), average: round(average(data.map((item) => item.count))), }, data: - chartType === 'linear' - ? fillEmptySpotsInTimeline(data, interval, startDate, endDate).map( - (item) => { - return { - label: legend, - count: round(item.count), - date: new Date(item.date).toISOString(), - }; - } - ) + payload.chartType === 'linear' || payload.chartType === 'histogram' + ? fillEmptySpotsInTimeline( + data, + payload.interval, + payload.startDate, + payload.endDate + ).map((item) => { + return { + label: legend, + count: round(item.count), + date: new Date(item.date).toISOString(), + }; + }) : [], }; }); diff --git a/apps/web/src/server/api/routers/report.ts b/apps/web/src/server/api/routers/report.ts index 5e381df2..be8afb1e 100644 --- a/apps/web/src/server/api/routers/report.ts +++ b/apps/web/src/server/api/routers/report.ts @@ -9,7 +9,7 @@ import type { IChartInput, IChartRange, } from '@/types'; -import { alphabetIds } from '@/utils/constants'; +import { alphabetIds, timeRanges } from '@/utils/constants'; import { zChartInput } from '@/utils/validation'; import type { Report as DbReport } from '@prisma/client'; import { z } from 'zod'; @@ -48,7 +48,7 @@ function transformReport(report: DbReport): IChartInput & { id: string } { chartType: report.chart_type, interval: report.interval, name: report.name || 'Untitled', - range: (report.range as IChartRange) ?? 30, + range: report.range as IChartRange ?? timeRanges['1m'], }; } diff --git a/apps/web/src/server/api/routers/ui.ts b/apps/web/src/server/api/routers/ui.ts index d643ccfd..e0adc9a4 100644 --- a/apps/web/src/server/api/routers/ui.ts +++ b/apps/web/src/server/api/routers/ui.ts @@ -1,5 +1,4 @@ import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc'; -import { db } from '@/server/db'; import { z } from 'zod'; export const config = { @@ -15,7 +14,7 @@ export const uiRouter = createTRPCRouter({ url: z.string(), }) ) - .query(async ({ input: { url } }) => { + .query(({ input: { url } }) => { const parts = url.split('/').filter(Boolean); return parts; }), diff --git a/apps/web/src/server/chart-sql/getChartSql.ts b/apps/web/src/server/chart-sql/getChartSql.ts new file mode 100644 index 00000000..e141c8ee --- /dev/null +++ b/apps/web/src/server/chart-sql/getChartSql.ts @@ -0,0 +1,71 @@ +import type { IGetChartDataInput } from '@/types'; + +import { + createSqlBuilder, + getWhereClause, + isJsonPath, + selectJsonPath, +} from './helpers'; + +export function getChartSql({ + event, + breakdowns, + interval, + startDate, + endDate, + projectId, +}: IGetChartDataInput) { + const { sb, join, getWhere, getFrom, getSelect, getOrderBy, getGroupBy } = + createSqlBuilder(); + + sb.where.projectId = `project_id = '${projectId}'`; + if (event.name !== '*') { + sb.where.eventName = `name = '${event.name}'`; + } + sb.where.eventFilter = join(getWhereClause(event.filters), ' AND '); + + sb.select.count = `count(*)::int as count`; + sb.select.date = `date_trunc('${interval}', "createdAt") as date`; + sb.groupBy.date = 'date'; + sb.orderBy.date = 'date ASC'; + + if (startDate) { + sb.where.startDate = `"createdAt" >= '${startDate}'`; + } + + if (endDate) { + sb.where.endDate = `"createdAt" <= '${endDate}'`; + } + + const breakdown = breakdowns[0]!; + if (breakdown) { + if (isJsonPath(breakdown.name)) { + sb.select.label = `${selectJsonPath(breakdown.name)} as label`; + } else { + sb.select.label = `${breakdown.name} as label`; + } + sb.groupBy.label = `label`; + } + + if (event.segment === 'user') { + sb.select.count = `count(DISTINCT profile_id)::int as count`; + } + + if (event.segment === 'user_average') { + sb.select.count = `COUNT(*)::float / COUNT(DISTINCT profile_id)::float as count`; + } + + if (event.segment === 'one_event_per_user') { + sb.from = `( + SELECT DISTINCT on (profile_id) * from events WHERE ${join( + sb.where, + ' AND ' + )} + ORDER BY profile_id, "createdAt" DESC + ) as subQuery`; + + return `${getSelect()} ${getFrom()} ${getGroupBy()} ${getOrderBy()}`; + } + + return `${getSelect()} ${getFrom()} ${getWhere()} ${getGroupBy()} ${getOrderBy()}`; +} diff --git a/apps/web/src/server/chart-sql/helpers.ts b/apps/web/src/server/chart-sql/helpers.ts new file mode 100644 index 00000000..10b60396 --- /dev/null +++ b/apps/web/src/server/chart-sql/helpers.ts @@ -0,0 +1,140 @@ +import type { IChartEventFilter } from '@/types'; + +export function getWhereClause(filters: IChartEventFilter[]) { + const where: string[] = []; + if (filters.length > 0) { + filters.forEach((filter) => { + const { name, value, operator } = filter; + switch (operator) { + case 'contains': { + if (name.includes('.*.') || name.endsWith('[*]')) { + // TODO: Make sure this works + // where.push( + // `properties @? '$.${name + // .replace(/^properties\./, '') + // .replace(/\.\*\./g, '[*].')} ? (@ like_regex "${value[0]}")'` + // ); + } else { + where.push( + `(${value + .map( + (val) => + `${propertyNameToSql(name)} like '%${String(val).replace( + /'/g, + "''" + )}%'` + ) + .join(' OR ')})` + ); + } + break; + } + case 'is': { + if (name.includes('.*.') || name.endsWith('[*]')) { + where.push( + `properties @? '$.${name + .replace(/^properties\./, '') + .replace(/\.\*\./g, '[*].')} ? (${value + .map((val) => `@ == "${val}"`) + .join(' || ')})'` + ); + } else { + where.push( + `${propertyNameToSql(name)} in (${value + .map((val) => `'${val}'`) + .join(', ')})` + ); + } + break; + } + case 'isNot': { + if (name.includes('.*.') || name.endsWith('[*]')) { + where.push( + `properties @? '$.${name + .replace(/^properties\./, '') + .replace(/\.\*\./g, '[*].')} ? (${value + .map((val) => `@ != "${val}"`) + .join(' && ')})'` + ); + } else if (name.includes('.')) { + where.push( + `${propertyNameToSql(name)} not in (${value + .map((val) => `'${val}'`) + .join(', ')})` + ); + } + break; + } + } + }); + } + + return where; +} + +export function selectJsonPath(property: string) { + const jsonPath = property + .replace(/^properties\./, '') + .replace(/\.\*\./g, '.**.'); + return `jsonb_path_query(properties, '$.${jsonPath}')`; +} + +export function isJsonPath(property: string) { + return property.startsWith('properties'); +} + +export function propertyNameToSql(name: string) { + if (name.includes('.')) { + const str = name + .split('.') + .map((item, index) => (index === 0 ? item : `'${item}'`)) + .join('->'); + const findLastOf = '->'; + const lastArrow = str.lastIndexOf(findLastOf); + if (lastArrow === -1) { + return str; + } + const first = str.slice(0, lastArrow); + const last = str.slice(lastArrow + findLastOf.length); + return `${first}->>${last}`; + } + + return name; +} + +export function createSqlBuilder() { + const join = (obj: Record | string[], joiner: string) => + Object.values(obj).filter(Boolean).join(joiner); + + const sb: { + where: Record; + select: Record; + groupBy: Record; + orderBy: Record; + from: string; + } = { + where: {}, + from: 'events', + select: {}, + groupBy: {}, + orderBy: {}, + }; + + return { + sb, + join, + getWhere: () => + Object.keys(sb.where).length ? 'WHERE ' + join(sb.where, ' AND ') : '', + getFrom: () => `FROM ${sb.from}`, + getSelect: () => + 'SELECT ' + (Object.keys(sb.select).length ? join(sb.select, ', ') : '*'), + getGroupBy: () => + Object.keys(sb.groupBy).length + ? 'GROUP BY ' + join(sb.groupBy, ', ') + : '', + getOrderBy: () => + Object.keys(sb.orderBy).length + ? 'ORDER BY ' + join(sb.orderBy, ', ') + : '', + }; +} diff --git a/apps/web/src/types/index.ts b/apps/web/src/types/index.ts index c001fabf..2926ba87 100644 --- a/apps/web/src/types/index.ts +++ b/apps/web/src/types/index.ts @@ -27,7 +27,7 @@ export type IChartBreakdown = z.infer; export type IInterval = z.infer; export type IChartType = z.infer; export type IChartData = RouterOutputs['chart']['chart']; -export type IChartRange = (typeof timeRanges)[number]['range']; +export type IChartRange = keyof typeof timeRanges; export type IToolTipProps = Omit, 'payload'> & { payload?: T[]; }; @@ -36,3 +36,13 @@ export type IProject = Project; export type IClientWithProject = Client & { project: IProject; }; + +export type IGetChartDataInput = { + event: IChartEvent; + projectId: string; + startDate: string; + endDate: string; +} & Omit< + IChartInputWithDates, + 'events' | 'name' | 'startDate' | 'endDate' | 'range' +>; diff --git a/apps/web/src/utils/constants.ts b/apps/web/src/utils/constants.ts index e79a1b0b..c2b1c78b 100644 --- a/apps/web/src/utils/constants.ts +++ b/apps/web/src/utils/constants.ts @@ -8,6 +8,7 @@ export const operators = { export const chartTypes = { linear: 'Linear', bar: 'Bar', + histogram: 'Histogram', pie: 'Pie', metric: 'Metric', area: 'Area', @@ -33,19 +34,19 @@ export const alphabetIds = [ 'J', ] as const; -export const timeRanges = [ - { range: 0.3, title: '30m' }, - { range: 0.6, title: '1h' }, - { range: 0, title: 'Today' }, - { range: 1, title: '24h' }, - { range: 7, title: '7d' }, - { range: 14, title: '14d' }, - { range: 30, title: '30d' }, - { range: 90, title: '3mo' }, - { range: 180, title: '6mo' }, - { range: 365, title: '1y' }, -] as const; +export const timeRanges = { + '30min': '30min', + '1h': '1h', + today: 'today', + '24h': '24h', + '7d': '7d', + '14d': '14d', + '1m': '1m', + '3m': '3m', + '6m': '6m', + '1y': '1y', +} as const; -export function isMinuteIntervalEnabledByRange(range: number) { - return range === 0.3 || range === 0.6; +export function isMinuteIntervalEnabledByRange(range: keyof typeof timeRanges) { + return range === '30min' || range === '1h'; } diff --git a/apps/web/src/utils/getRangeLabel.ts b/apps/web/src/utils/getRangeLabel.ts deleted file mode 100644 index 29fdc0b7..00000000 --- a/apps/web/src/utils/getRangeLabel.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { IChartRange } from '@/types'; - -import { timeRanges } from './constants'; - -export function getRangeLabel(range: IChartRange) { - return timeRanges.find((item) => item.range === range)?.title ?? null; -} diff --git a/apps/web/src/utils/validation.ts b/apps/web/src/utils/validation.ts index dd5564d2..2281a12e 100644 --- a/apps/web/src/utils/validation.ts +++ b/apps/web/src/utils/validation.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; -import { chartTypes, intervals, operators } from './constants'; +import { chartTypes, intervals, operators, timeRanges } from './constants'; function objectToZodEnums(obj: Record): [K, ...K[]] { const [firstKey, ...otherKeys] = Object.keys(obj) as K[]; @@ -11,7 +11,7 @@ export const zChartEvent = z.object({ id: z.string(), name: z.string(), displayName: z.string().optional(), - segment: z.enum(['event', 'user', 'user_average']), + segment: z.enum(['event', 'user', 'user_average', 'one_event_per_user']), filters: z.array( z.object({ id: z.string(), @@ -39,17 +39,7 @@ export const zChartInput = z.object({ interval: zTimeInterval, events: zChartEvents, breakdowns: zChartBreakdowns, - range: z - .literal(0) - .or(z.literal(0.3)) - .or(z.literal(0.6)) - .or(z.literal(1)) - .or(z.literal(7)) - .or(z.literal(14)) - .or(z.literal(30)) - .or(z.literal(90)) - .or(z.literal(180)) - .or(z.literal(365)), + range: z.enum(objectToZodEnums(timeRanges)), }); export const zChartInputWithDates = zChartInput.extend({ diff --git a/packages/sdk/index.ts b/packages/sdk/index.ts index 6f5ccf75..4c42831e 100644 --- a/packages/sdk/index.ts +++ b/packages/sdk/index.ts @@ -353,7 +353,7 @@ export class Mixan { this.logger('Mixan: Clear, send remaining events and remove profileId'); this.eventBatcher.send(); this.options.removeItem('@mixan:profileId'); - this.options.removeItem('@mixan:session'); + this.options.removeItem('@mixan:lastEventAt'); this.profileId = undefined; this.setAnonymousUser(); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 32b77f5a..944153bf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -257,6 +257,9 @@ importers: '@types/bcrypt': specifier: ^5.0.0 version: 5.0.1 + '@types/lodash.debounce': + specifier: ^4.0.9 + version: 4.0.9 '@types/node': specifier: ^18.16.0 version: 18.18.8 @@ -2422,6 +2425,16 @@ packages: resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} dev: false + /@types/lodash.debounce@4.0.9: + resolution: {integrity: sha512-Ma5JcgTREwpLRwMM+XwBR7DaWe96nC38uCBDFKZWbNKD+osjVzdpnUSwBcqCptrp16sSOLBAUb50Car5I0TCsQ==} + dependencies: + '@types/lodash': 4.14.202 + dev: true + + /@types/lodash@4.14.202: + resolution: {integrity: sha512-OvlIYQK9tNneDlS0VN54LLd5uiPCBOp7gS5Z0f1mjoJYBrtStzgmJBxONW3U6OZqdtNzZPmn9BS/7WI7BFFcFQ==} + dev: true + /@types/node@18.18.8: resolution: {integrity: sha512-OLGBaaK5V3VRBS1bAkMVP2/W9B+H8meUfl866OrMNQqt7wDgdpWPp5o6gmIc9pB+lIQHSq4ZL8ypeH1vPxcPaQ==} dependencies: diff --git a/tooling/eslint/base.js b/tooling/eslint/base.js index 55bc515d..5954b0a1 100644 --- a/tooling/eslint/base.js +++ b/tooling/eslint/base.js @@ -31,14 +31,15 @@ const config = { { checksVoidReturn: { attributes: false } }, ], 'import/consistent-type-specifier-style': ['error', 'prefer-top-level'], - "@typescript-eslint/no-empty-function": "off", - "@typescript-eslint/no-floating-promises": "off", - "@typescript-eslint/no-explicit-any": "warn", - "@typescript-eslint/ban-ts-comment": "off", - "@typescript-eslint/no-unsafe-return": "off", - "@typescript-eslint/no-unsafe-assignment": "warn", - "@typescript-eslint/no-unsafe-member-access": "warn", - "@typescript-eslint/no-unsafe-argument": "warn" + '@typescript-eslint/no-empty-function': 'off', + '@typescript-eslint/no-floating-promises': 'off', + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/ban-ts-comment': 'off', + '@typescript-eslint/no-unsafe-return': 'off', + '@typescript-eslint/no-unsafe-assignment': 'warn', + '@typescript-eslint/no-unsafe-member-access': 'warn', + '@typescript-eslint/no-unsafe-argument': 'warn', + '@typescript-eslint/prefer-nullish-coalescing': 'off', }, ignorePatterns: [ '**/.eslintrc.cjs',