Files
stats/packages/db/src/engine/compute.ts
Carl-Gerhard Lindesvärd 06fb6c4f3c wip
2025-11-21 11:21:17 +01:00

166 lines
5.6 KiB
TypeScript

import { round } from '@openpanel/common';
import { alphabetIds } from '@openpanel/constants';
import type { IChartFormula } from '@openpanel/validation';
import * as mathjs from 'mathjs';
import type { ConcreteSeries } from './types';
/**
* Compute formula series from fetched event series
* Formulas reference event series using alphabet IDs (A, B, C, etc.)
*/
export function compute(
fetchedSeries: ConcreteSeries[],
definitions: Array<{
type: 'event' | 'formula';
id?: string;
formula?: string;
}>,
): ConcreteSeries[] {
const results: ConcreteSeries[] = [...fetchedSeries];
// Process formulas in order (they can reference previous formulas)
definitions.forEach((definition, formulaIndex) => {
if (definition.type !== 'formula') {
return;
}
const formula = definition as IChartFormula;
if (!formula.formula) {
return;
}
// Group ALL series (events + previously computed formulas) by breakdown signature
// Series with the same breakdown values should be computed together
const seriesByBreakdown = new Map<string, ConcreteSeries[]>();
// Include both fetched event series AND previously computed formulas
const allSeries = [
...fetchedSeries,
...results.filter((s) => s.definitionIndex < formulaIndex),
];
allSeries.forEach((serie) => {
// Create breakdown signature: skip first name part (event/formula name) and use breakdown values
// If name.length === 1, it means no breakdowns (just event name)
// If name.length > 1, name[0] is event name, name[1+] are breakdown values
const breakdownSignature =
serie.name.length > 1 ? serie.name.slice(1).join(':::') : '';
if (!seriesByBreakdown.has(breakdownSignature)) {
seriesByBreakdown.set(breakdownSignature, []);
}
seriesByBreakdown.get(breakdownSignature)!.push(serie);
});
// Compute formula for each breakdown group
for (const [breakdownSignature, breakdownSeries] of seriesByBreakdown) {
// Map series by their definition index for formula evaluation
const seriesByIndex = new Map<number, ConcreteSeries>();
breakdownSeries.forEach((serie) => {
seriesByIndex.set(serie.definitionIndex, serie);
});
// Get all unique dates across all series in this breakdown group
const allDates = new Set<string>();
breakdownSeries.forEach((serie) => {
serie.data.forEach((item) => {
allDates.add(item.date);
});
});
const sortedDates = Array.from(allDates).sort(
(a, b) => new Date(a).getTime() - new Date(b).getTime(),
);
// Calculate formula for each date
const formulaData = sortedDates.map((date) => {
const scope: Record<string, number> = {};
// Build scope using alphabet IDs (A, B, C, etc.)
definitions.slice(0, formulaIndex).forEach((depDef, depIndex) => {
const readableId = alphabetIds[depIndex];
if (!readableId) {
return;
}
// Find the series for this dependency in the current breakdown group
const depSeries = seriesByIndex.get(depIndex);
if (depSeries) {
const dataPoint = depSeries.data.find((d) => d.date === date);
scope[readableId] = dataPoint?.count ?? 0;
} else {
// Could be a formula from a previous breakdown group - find it in results
// Match by definitionIndex AND breakdown signature
const formulaSerie = results.find(
(s) =>
s.definitionIndex === depIndex &&
'type' in s.definition &&
s.definition.type === 'formula' &&
s.name.slice(1).join(':::') === breakdownSignature,
);
if (formulaSerie) {
const dataPoint = formulaSerie.data.find((d) => d.date === date);
scope[readableId] = dataPoint?.count ?? 0;
} else {
scope[readableId] = 0;
}
}
});
// Evaluate formula
let count: number;
try {
count = mathjs
.parse(formula.formula)
.compile()
.evaluate(scope) as number;
} catch (error) {
count = 0;
}
return {
date,
count:
Number.isNaN(count) || !Number.isFinite(count)
? 0
: round(count, 2),
total_count: breakdownSeries[0]?.data.find((d) => d.date === date)
?.total_count,
};
});
// Create concrete series for this formula
const templateSerie = breakdownSeries[0]!;
// Extract breakdown values from template series name
// name[0] is event/formula name, name[1+] are breakdown values
const breakdownValues =
templateSerie.name.length > 1 ? templateSerie.name.slice(1) : [];
const formulaName =
breakdownValues.length > 0
? [formula.displayName || formula.formula, ...breakdownValues]
: [formula.displayName || formula.formula];
const formulaSeries: ConcreteSeries = {
id: `formula-${formula.id ?? formulaIndex}-${breakdownSignature || 'default'}`,
definitionId:
formula.id ?? alphabetIds[formulaIndex] ?? `formula-${formulaIndex}`,
definitionIndex: formulaIndex,
name: formulaName,
context: {
filters: templateSerie.context.filters,
breakdownValue: templateSerie.context.breakdownValue,
breakdowns: templateSerie.context.breakdowns,
},
data: formulaData,
definition: formula,
};
results.push(formulaSeries);
}
});
return results;
}