Files
stats/apps/web/src/server/api/routers/chart.helpers.ts
Carl-Gerhard Lindesvärd 447fa5896e sdk changes
2024-02-11 21:31:12 +01:00

265 lines
6.9 KiB
TypeScript

import { getChartSql } from '@/server/services/chart.service';
import type {
IChartEvent,
IChartInput,
IGetChartDataInput,
IInterval,
} from '@/types';
import { alphabetIds, NOT_SET_VALUE } from '@/utils/constants';
import { round } from '@/utils/math';
import * as mathjs from 'mathjs';
import { sort } from 'ramda';
import { chQuery } from '@mixan/db';
export type GetChartDataResult = Awaited<ReturnType<typeof getChartData>>;
export interface ResultItem {
label: string | null;
count: number | null;
date: string;
}
function getEventLegend(event: IChartEvent) {
return event.displayName ?? `${event.name} (${event.id})`;
}
function fillEmptySpotsInTimeline(
items: ResultItem[],
interval: IInterval,
startDate: string,
endDate: string
) {
const result = [];
const clonedStartDate = new Date(startDate);
const clonedEndDate = new Date(endDate);
const today = new Date();
if (interval === 'minute') {
clonedStartDate.setUTCSeconds(0, 0);
clonedEndDate.setUTCMinutes(clonedEndDate.getUTCMinutes() + 1, 0, 0);
} else if (interval === 'hour') {
clonedStartDate.setUTCMinutes(0, 0, 0);
clonedEndDate.setUTCMinutes(0, 0, 0);
} else {
clonedStartDate.setUTCHours(0, 0, 0, 0);
clonedEndDate.setUTCHours(0, 0, 0, 0);
}
if (interval === 'month') {
clonedStartDate.setUTCDate(1);
clonedEndDate.setUTCDate(1);
}
// Force if interval is month and the start date is the same month as today
const shouldForce = () =>
interval === 'month' &&
clonedStartDate.getUTCFullYear() === today.getUTCFullYear() &&
clonedStartDate.getUTCMonth() === today.getUTCMonth();
let prev = undefined;
while (
shouldForce() ||
clonedStartDate.getTime() <= clonedEndDate.getTime()
) {
if (prev === clonedStartDate.getTime()) {
console.log('GET OUT NOW!');
break;
}
prev = clonedStartDate.getTime();
const getYear = (date: Date) => date.getUTCFullYear();
const getMonth = (date: Date) => date.getUTCMonth();
const getDay = (date: Date) => date.getUTCDate();
const getHour = (date: Date) => date.getUTCHours();
const getMinute = (date: Date) => date.getUTCMinutes();
const item = items.find((item) => {
const date = new Date(item.date);
if (interval === 'month') {
return (
getYear(date) === getYear(clonedStartDate) &&
getMonth(date) === getMonth(clonedStartDate)
);
}
if (interval === 'day') {
return (
getYear(date) === getYear(clonedStartDate) &&
getMonth(date) === getMonth(clonedStartDate) &&
getDay(date) === getDay(clonedStartDate)
);
}
if (interval === 'hour') {
return (
getYear(date) === getYear(clonedStartDate) &&
getMonth(date) === getMonth(clonedStartDate) &&
getDay(date) === getDay(clonedStartDate) &&
getHour(date) === getHour(clonedStartDate)
);
}
if (interval === 'minute') {
return (
getYear(date) === getYear(clonedStartDate) &&
getMonth(date) === getMonth(clonedStartDate) &&
getDay(date) === getDay(clonedStartDate) &&
getHour(date) === getHour(clonedStartDate) &&
getMinute(date) === getMinute(clonedStartDate)
);
}
return false;
});
if (item) {
result.push({
...item,
date: clonedStartDate.toISOString(),
});
} else {
result.push({
date: clonedStartDate.toISOString(),
count: 0,
label: null,
});
}
switch (interval) {
case 'day': {
clonedStartDate.setUTCDate(clonedStartDate.getUTCDate() + 1);
break;
}
case 'hour': {
clonedStartDate.setUTCHours(clonedStartDate.getUTCHours() + 1);
break;
}
case 'minute': {
clonedStartDate.setUTCMinutes(clonedStartDate.getUTCMinutes() + 1);
break;
}
case 'month': {
clonedStartDate.setUTCMonth(clonedStartDate.getUTCMonth() + 1);
break;
}
}
}
return sort(function (a, b) {
return new Date(a.date).getTime() - new Date(b.date).getTime();
}, result);
}
export function withFormula(
{ formula }: IChartInput,
series: GetChartDataResult
) {
if (!formula) {
return series;
}
if (!series) {
return series;
}
if (!series[0]) {
return series;
}
if (!series[0].data) {
return series;
}
return [
{
...series[0],
data: series[0].data.map((item, dIndex) => {
const scope = series.reduce((acc, item) => {
return {
...acc,
[item.event.id]: item.data[dIndex]?.count ?? 0,
};
}, {});
const count = mathjs.parse(formula).compile().evaluate(scope) as number;
return {
...item,
count:
Number.isNaN(count) || !Number.isFinite(count)
? null
: round(count, 2),
};
}),
},
];
}
export async function getChartData(payload: IGetChartDataInput) {
let result = await chQuery<ResultItem>(getChartSql(payload));
if (result.length === 0 && payload.breakdowns.length > 0) {
result = await chQuery<ResultItem>(
getChartSql({
...payload,
breakdowns: [],
})
);
}
// group by sql label
const series = result.reduce(
(acc, item) => {
// item.label can be null when using breakdowns on a property
// that doesn't exist on all events
const label = item.label?.trim() || NOT_SET_VALUE;
if (label) {
if (acc[label]) {
acc[label]?.push(item);
} else {
acc[label] = [item];
}
}
return {
...acc,
};
},
{} as Record<string, ResultItem[]>
);
return Object.keys(series).map((key) => {
// If we have breakdowns, we want to use the breakdown key as the legend
// But only if it successfully broke it down, otherwise we use the getEventLabel
const serieName =
payload.breakdowns.length && !alphabetIds.includes(key as 'A')
? key
: getEventLegend(payload.event);
const data =
payload.chartType === 'area' ||
payload.chartType === 'linear' ||
payload.chartType === 'histogram' ||
payload.chartType === 'metric' ||
payload.chartType === 'pie' ||
payload.chartType === 'bar'
? fillEmptySpotsInTimeline(
series[key] ?? [],
payload.interval,
payload.startDate,
payload.endDate
).map((item) => {
return {
label: serieName,
count: item.count ? round(item.count) : null,
date: new Date(item.date).toISOString(),
};
})
: (series[key] ?? []).map((item) => ({
label: item.label,
count: item.count ? round(item.count) : null,
date: new Date(item.date).toISOString(),
}));
return {
name: serieName,
event: payload.event,
data,
};
});
}