Files
stats/apps/web/src/server/api/routers/chart.ts
Carl-Gerhard Lindesvärd 13d7ad2a8c web: round values and add average
2023-12-13 10:15:32 +01:00

625 lines
16 KiB
TypeScript

import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
import * as cache from '@/server/cache';
import { db } from '@/server/db';
import { getUniqueEvents } from '@/server/services/event.service';
import { getProjectBySlug } from '@/server/services/project.service';
import type {
IChartEvent,
IChartInputWithDates,
IChartRange,
IInterval,
} from '@/types';
import { getDaysOldDate } from '@/utils/date';
import { average, isFloat, round, sum } from '@/utils/math';
import { toDots } from '@/utils/object';
import { zChartInputWithDates } from '@/utils/validation';
import { last, pipe, sort, uniq } from 'ramda';
import { z } from 'zod';
export const config = {
api: {
responseLimit: false,
},
};
export const chartRouter = createTRPCRouter({
events: protectedProcedure
.input(z.object({ projectSlug: z.string() }))
.query(async ({ input: { projectSlug } }) => {
const project = await getProjectBySlug(projectSlug);
const events = await cache.getOr(
`events_${project.id}`,
1000 * 60 * 60 * 24,
() => getUniqueEvents({ projectId: project.id })
);
return events;
}),
properties: protectedProcedure
.input(z.object({ event: z.string().optional(), projectSlug: z.string() }))
.query(async ({ input: { projectSlug, event } }) => {
const project = await getProjectBySlug(projectSlug);
const events = await cache.getOr(
`events_${project.id}_${event ?? 'all'}`,
1000 * 60 * 60,
() =>
db.event.findMany({
take: 500,
where: {
project_id: project.id,
...(event
? {
name: event,
}
: {}),
},
})
);
const properties = events
.reduce((acc, event) => {
const properties = event as Record<string, unknown>;
const dotNotation = toDots(properties);
return [...acc, ...Object.keys(dotNotation)];
}, [] as string[])
.map((item) => item.replace(/\.([0-9]+)\./g, '.*.'))
.map((item) => item.replace(/\.([0-9]+)/g, '[*]'));
return pipe(
sort<string>((a, b) => a.length - b.length),
uniq
)(properties);
}),
values: protectedProcedure
.input(
z.object({
event: z.string(),
property: z.string(),
projectSlug: z.string(),
})
)
.query(async ({ input: { event, property, projectSlug } }) => {
const intervalInDays = 180;
const project = await getProjectBySlug(projectSlug);
if (isJsonPath(property)) {
const events = await db.$queryRawUnsafe<{ value: string }[]>(
`SELECT ${selectJsonPath(
property
)} AS value from events WHERE project_id = '${
project.id
}' AND name = '${event}' AND "createdAt" >= NOW() - INTERVAL '${intervalInDays} days'`
);
return {
values: uniq(events.map((item) => item.value)),
};
} else {
const events = await db.event.findMany({
where: {
project_id: project.id,
name: event,
[property]: {
not: null,
},
createdAt: {
gte: new Date(
new Date().getTime() - 1000 * 60 * 60 * 24 * intervalInDays
),
},
},
distinct: property as any,
select: {
[property]: true,
},
});
return {
values: uniq(events.map((item) => item[property]!)),
};
}
}),
chart: protectedProcedure
.input(zChartInputWithDates.merge(z.object({ projectSlug: z.string() })))
.query(async ({ input: { projectSlug, events, ...input } }) => {
const project = await getProjectBySlug(projectSlug);
const series: Awaited<ReturnType<typeof getChartData>> = [];
for (const event of events) {
series.push(
...(await getChartData({
...input,
event,
projectId: project.id,
}))
);
}
const sorted = [...series].sort((a, b) => {
if (input.chartType === 'linear') {
const sumA = a.data.reduce((acc, item) => acc + item.count, 0);
const sumB = b.data.reduce((acc, item) => acc + item.count, 0);
return sumB - sumA;
} else {
return b.metrics.total - a.metrics.total;
}
});
const meta = {
highest: sorted[0]?.metrics.total ?? 0,
lowest: last(sorted)?.metrics.total ?? 0,
};
return {
events: Object.entries(
series.reduce(
(acc, item) => {
if (acc[item.event.id]) {
acc[item.event.id] += item.metrics.total;
} else {
acc[item.event.id] = item.metrics.total;
}
return acc;
},
{} as Record<(typeof series)[number]['event']['id'], number>
)
).map(([id, count]) => ({
count,
...events.find((event) => event.id === id)!,
})),
series: sorted.map((item) => ({
...item,
meta,
})),
};
}),
});
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.name} (${event.id})`;
}
function getDatesFromRange(range: IChartRange) {
if (range === 0) {
const startDate = new Date();
const endDate = new Date().toISOString();
startDate.setHours(0, 0, 0, 0);
return {
startDate: startDate.toISOString(),
endDate: endDate,
};
}
if (isFloat(range)) {
const startDate = new Date(
Date.now() - 1000 * 60 * (range * 100)
).toISOString();
const endDate = new Date().toISOString();
return {
startDate,
endDate,
};
}
const startDate = getDaysOldDate(range);
startDate.setUTCHours(0, 0, 0, 0);
const endDate = new Date();
endDate.setUTCHours(23, 59, 59, 999);
return {
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
};
}
function getChartSql({
event,
chartType,
breakdowns,
interval,
startDate,
endDate,
projectId,
}: Omit<IGetChartDataInput, 'range'> & {
projectId: string;
}) {
const select = [];
const where = [`project_id = '${projectId}'`];
const groupBy = [];
const orderBy = [];
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<IChartInputWithDates, 'events' | 'name'>;
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<ResultItem[]>(sql);
if (result.length === 0 && breakdowns.length > 0) {
result = await db.$queryRawUnsafe<ResultItem[]>(
getChartSql({
chartType,
event,
breakdowns: [],
interval,
startDate,
endDate,
projectId,
})
);
}
// group by sql label
const series = result.reduce(
(acc, item) => {
// item.label can be null when using breakdowns on a property
// that doesn't exist on all events
const label = item.label?.trim() ?? event.id;
if (label) {
if (acc[label]) {
acc[label]?.push(item);
} else {
acc[label] = [item];
}
}
return {
...acc,
};
},
{} as Record<string, ResultItem[]>
);
return Object.keys(series).map((key) => {
const legend = breakdowns.length ? key : getEventLegend(event);
const data = series[key] ?? [];
return {
name: legend,
event: {
id: event.id,
name: 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(),
};
}
)
: [],
};
});
}
function fillEmptySpotsInTimeline(
items: ResultItem[],
interval: IInterval,
startDate: string,
endDate: string
) {
const result = [];
const clonedStartDate = new Date(startDate);
const clonedEndDate = new Date(endDate);
const today = new Date();
if (interval === 'minute') {
clonedStartDate.setUTCSeconds(0, 0);
clonedEndDate.setUTCMinutes(clonedEndDate.getUTCMinutes() + 1, 0, 0);
} else if (interval === 'hour') {
clonedStartDate.setUTCMinutes(0, 0, 0);
clonedEndDate.setUTCMinutes(0, 0, 0);
} else {
clonedStartDate.setUTCHours(0, 0, 0, 0);
clonedEndDate.setUTCHours(0, 0, 0, 0);
}
if (interval === 'month') {
clonedStartDate.setUTCDate(1);
clonedEndDate.setUTCDate(1);
}
// Force if interval is month and the start date is the same month as today
const shouldForce = () =>
interval === 'month' &&
clonedStartDate.getUTCFullYear() === today.getUTCFullYear() &&
clonedStartDate.getUTCMonth() === today.getUTCMonth();
let prev = undefined;
while (
shouldForce() ||
clonedStartDate.getTime() <= clonedEndDate.getTime()
) {
if (prev === clonedStartDate.getTime()) {
console.log('GET OUT NOW!');
break;
}
prev = clonedStartDate.getTime();
const getYear = (date: Date) => date.getUTCFullYear();
const getMonth = (date: Date) => date.getUTCMonth();
const getDay = (date: Date) => date.getUTCDate();
const getHour = (date: Date) => date.getUTCHours();
const getMinute = (date: Date) => date.getUTCMinutes();
const item = items.find((item) => {
const date = new Date(item.date);
if (interval === 'month') {
return (
getYear(date) === getYear(clonedStartDate) &&
getMonth(date) === getMonth(clonedStartDate)
);
}
if (interval === 'day') {
return (
getYear(date) === getYear(clonedStartDate) &&
getMonth(date) === getMonth(clonedStartDate) &&
getDay(date) === getDay(clonedStartDate)
);
}
if (interval === 'hour') {
return (
getYear(date) === getYear(clonedStartDate) &&
getMonth(date) === getMonth(clonedStartDate) &&
getDay(date) === getDay(clonedStartDate) &&
getHour(date) === getHour(clonedStartDate)
);
}
if (interval === 'minute') {
return (
getYear(date) === getYear(clonedStartDate) &&
getMonth(date) === getMonth(clonedStartDate) &&
getDay(date) === getDay(clonedStartDate) &&
getHour(date) === getHour(clonedStartDate) &&
getMinute(date) === getMinute(clonedStartDate)
);
}
});
if (item) {
result.push({
...item,
date: clonedStartDate.toISOString(),
});
} else {
result.push({
date: clonedStartDate.toISOString(),
count: 0,
label: null,
});
}
switch (interval) {
case 'day': {
clonedStartDate.setUTCDate(clonedStartDate.getUTCDate() + 1);
break;
}
case 'hour': {
clonedStartDate.setUTCHours(clonedStartDate.getUTCHours() + 1);
break;
}
case 'minute': {
clonedStartDate.setUTCMinutes(clonedStartDate.getUTCMinutes() + 1);
break;
}
case 'month': {
clonedStartDate.setUTCMonth(clonedStartDate.getUTCMonth() + 1);
break;
}
}
}
return sort(function (a, b) {
return new Date(a.date).getTime() - new Date(b.date).getTime();
}, result);
}