fix: improve types for chart/reports

This commit is contained in:
Carl-Gerhard Lindesvärd
2026-01-12 16:29:26 +01:00
parent 347a01a941
commit 13bd16b207
33 changed files with 310 additions and 215 deletions

View File

@@ -0,0 +1,88 @@
import type { IReportOptions } from '@openpanel/validation';
import { db } from '../index';
import { printBoxMessage } from './helpers';
export async function up() {
printBoxMessage('🔄 Migrating Legacy Fields to Options', []);
// Get all reports
const reports = await db.report.findMany({
select: {
id: true,
chartType: true,
funnelGroup: true,
funnelWindow: true,
criteria: true,
options: true,
name: true,
},
});
let migratedCount = 0;
let skippedCount = 0;
for (const report of reports) {
const currentOptions = report.options as IReportOptions | null | undefined;
// Skip if options already exists and is valid
if (currentOptions && typeof currentOptions === 'object' && 'type' in currentOptions) {
skippedCount++;
continue;
}
let newOptions: IReportOptions | null = null;
// Migrate based on chart type
if (report.chartType === 'funnel') {
// Only create options if we have legacy fields to migrate
if (report.funnelGroup || report.funnelWindow !== null) {
newOptions = {
type: 'funnel',
funnelGroup: report.funnelGroup ?? undefined,
funnelWindow: report.funnelWindow ?? undefined,
};
}
} else if (report.chartType === 'retention') {
// Only create options if we have criteria to migrate
if (report.criteria) {
newOptions = {
type: 'retention',
criteria: report.criteria as 'on_or_after' | 'on' | undefined,
};
}
} else if (report.chartType === 'sankey') {
// Sankey should already have options, but if not, skip
skippedCount++;
continue;
}
// Only update if we have new options to set
if (newOptions) {
console.log(
`Migrating report ${report.name} (${report.id}) - chartType: ${report.chartType}`,
);
await db.report.update({
where: { id: report.id },
data: {
options: newOptions,
// Set legacy fields to null after migration
funnelGroup: null,
funnelWindow: null,
criteria: report.chartType === 'retention' ? null : report.criteria,
},
});
migratedCount++;
} else {
skippedCount++;
}
}
printBoxMessage('✅ Migration Complete', [
`Migrated: ${migratedCount} reports`,
`Skipped: ${skippedCount} reports (already migrated or no legacy fields)`,
]);
}

View File

@@ -53,9 +53,6 @@ export async function fetch(plan: Plan): Promise<ConcreteSeries[]> {
previous: plan.input.previous ?? false,
limit: plan.input.limit,
offset: plan.input.offset,
criteria: plan.input.criteria,
funnelGroup: plan.input.funnelGroup,
funnelWindow: plan.input.funnelWindow,
};
// Execute query

View File

@@ -4,7 +4,7 @@ import { alphabetIds } from '@openpanel/constants';
import type {
FinalChart,
IChartEventItem,
IChartInput,
IReportInput,
} from '@openpanel/validation';
import { chQuery } from '../clickhouse/client';
import {
@@ -26,7 +26,7 @@ import type { ConcreteSeries } from './types';
* Chart Engine - Main entry point
* Executes the pipeline: normalize -> plan -> fetch -> compute -> format
*/
export async function executeChart(input: IChartInput): Promise<FinalChart> {
export async function executeChart(input: IReportInput): Promise<FinalChart> {
// Stage 1: Normalize input
const normalized = await normalize(input);
@@ -83,7 +83,7 @@ export async function executeChart(input: IChartInput): Promise<FinalChart> {
* Executes a simplified pipeline: normalize -> fetch aggregate -> format
*/
export async function executeAggregateChart(
input: IChartInput,
input: IReportInput,
): Promise<FinalChart> {
// Stage 1: Normalize input
const normalized = await normalize(input);

View File

@@ -2,8 +2,8 @@ import { alphabetIds } from '@openpanel/constants';
import type {
IChartEvent,
IChartEventItem,
IChartInput,
IChartInputWithDates,
IReportInput,
IReportInputWithDates,
} from '@openpanel/validation';
import { getChartStartEndDate } from '../services/chart.service';
import { getSettingsForProject } from '../services/organization.service';
@@ -15,8 +15,8 @@ export type NormalizedInput = Awaited<ReturnType<typeof normalize>>;
* Normalize a chart input into a clean structure with dates and normalized series
*/
export async function normalize(
input: IChartInput,
): Promise<IChartInputWithDates & { series: SeriesDefinition[] }> {
input: IReportInput,
): Promise<IReportInputWithDates & { series: SeriesDefinition[] }> {
const { timezone } = await getSettingsForProject(input.projectId);
const { startDate, endDate } = getChartStartEndDate(
{

View File

@@ -4,8 +4,8 @@ import type {
IChartEventFilter,
IChartEventItem,
IChartFormula,
IChartInput,
IChartInputWithDates,
IReportInput,
IReportInputWithDates,
} from '@openpanel/validation';
/**
@@ -50,7 +50,7 @@ export type ConcreteSeries = {
export type Plan = {
concreteSeries: ConcreteSeries[];
definitions: SeriesDefinition[];
input: IChartInputWithDates;
input: IReportInputWithDates;
timezone: string;
};

View File

@@ -3,7 +3,7 @@ import sqlstring from 'sqlstring';
import { DateTime, stripLeadingAndTrailingSlashes } from '@openpanel/common';
import type {
IChartEventFilter,
IChartInput,
IReportInput,
IChartRange,
IGetChartDataInput,
} from '@openpanel/validation';
@@ -973,7 +973,7 @@ export function getChartStartEndDate(
startDate,
endDate,
range,
}: Pick<IChartInput, 'endDate' | 'startDate' | 'range'>,
}: Pick<IReportInput, 'endDate' | 'startDate' | 'range'>,
timezone: string,
) {
if (startDate && endDate) {

View File

@@ -1,5 +1,5 @@
import { NOT_SET_VALUE } from '@openpanel/constants';
import type { IChartEvent, IChartInput } from '@openpanel/validation';
import type { IChartEvent, IChartBreakdown, IReportInput } from '@openpanel/validation';
import { omit } from 'ramda';
import { TABLE_NAMES, ch } from '../clickhouse/client';
import { clix } from '../clickhouse/query-builder';
@@ -16,21 +16,23 @@ export class ConversionService {
projectId,
startDate,
endDate,
funnelGroup,
funnelWindow = 24,
options,
series,
breakdowns = [],
limit,
interval,
timezone,
}: Omit<IChartInput, 'range' | 'previous' | 'metric' | 'chartType'> & {
}: Omit<IReportInput, 'range' | 'previous' | 'metric' | 'chartType'> & {
timezone: string;
}) {
const funnelOptions = options?.type === 'funnel' ? options : undefined;
const funnelGroup = funnelOptions?.funnelGroup;
const funnelWindow = funnelOptions?.funnelWindow ?? 24;
const group = funnelGroup === 'profile_id' ? 'profile_id' : 'session_id';
const breakdownColumns = breakdowns.map(
(b, index) => `${getSelectPropertyKey(b.name)} as b_${index}`,
(b: IChartBreakdown, index: number) => `${getSelectPropertyKey(b.name)} as b_${index}`,
);
const breakdownGroupBy = breakdowns.map((b, index) => `b_${index}`);
const breakdownGroupBy = breakdowns.map((b: IChartBreakdown, index: number) => `b_${index}`);
const events = onlyReportEvents(series);

View File

@@ -2,7 +2,7 @@ import { ifNaN } from '@openpanel/common';
import type {
IChartEvent,
IChartEventItem,
IChartInput,
IReportInput,
} from '@openpanel/validation';
import { last, reverse, uniq } from 'ramda';
import sqlstring from 'sqlstring';
@@ -185,16 +185,19 @@ export class FunnelService {
startDate,
endDate,
series,
funnelWindow = 24,
funnelGroup,
options,
breakdowns = [],
limit,
timezone = 'UTC',
}: IChartInput & { timezone: string; events?: IChartEvent[] }) {
}: IReportInput & { timezone: string; events?: IChartEvent[] }) {
if (!startDate || !endDate) {
throw new Error('startDate and endDate are required');
}
const funnelOptions = options?.type === 'funnel' ? options : undefined;
const funnelWindow = funnelOptions?.funnelWindow ?? 24;
const funnelGroup = funnelOptions?.funnelGroup;
const eventSeries = onlyReportEvents(series);
if (eventSeries.length === 0) {

View File

@@ -8,8 +8,8 @@ import type {
IChartEventFilter,
IChartEventItem,
IChartLineType,
IChartProps,
IChartRange,
IReport,
IReportOptions,
} from '@openpanel/validation';
@@ -65,23 +65,22 @@ export function transformReportEventItem(
export function transformReport(
report: DbReport & { layout?: ReportLayout | null },
): IChartProps & {
): IReport & {
id: string;
layout?: ReportLayout | null;
} {
// Parse options from JSON field, fallback to legacy fields for backward compatibility
const options = report.options as IReportOptions | null | undefined;
return {
id: report.id,
projectId: report.projectId,
series:
(report.events as IChartEventItem[]).map(transformReportEventItem) ?? [],
breakdowns: report.breakdowns as IChartBreakdown[],
name: report.name || 'Untitled',
chartType: report.chartType,
lineType: (report.lineType as IChartLineType) ?? lineTypes.monotone,
interval: report.interval,
name: report.name || 'Untitled',
series:
(report.events as IChartEventItem[]).map(transformReportEventItem) ?? [],
breakdowns: report.breakdowns as IChartBreakdown[],
range:
report.range in deprecated_timeRanges
? '30d'
@@ -90,15 +89,8 @@ export function transformReport(
formula: report.formula ?? undefined,
metric: report.metric ?? 'sum',
unit: report.unit ?? undefined,
criteria: (report.criteria ?? 'on_or_after') as
| 'on_or_after'
| 'on'
| undefined,
layout: report.layout ?? undefined,
options: options ?? undefined,
// Depercated, just for frontend backward compatibility (will be removed)
funnelGroup: report.funnelGroup ?? undefined,
funnelWindow: report.funnelWindow ?? undefined,
};
}