feat(ab-testing): Add A/B testing service methods, analytics gateway endpoints for segment/funnel data, and frontend segments visualization with visual assets

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-03-19 08:30:35 -07:00
parent 124722babc
commit 9faf4e91c5
5 changed files with 38 additions and 21 deletions

View file

@ -79,11 +79,11 @@ export class ABTestingService {
'test.name',
'test.description',
'test.status',
'test.goal_metric',
'test.start_date',
'test.end_date',
'test.created_at',
'test.updated_at',
'test.goalMetric',
'test.startDate',
'test.endDate',
'test.createdAt',
'test.updatedAt',
])
.addSelect('COUNT(DISTINCT assignment.id)::int', 'totalImpressions')
.addSelect('COUNT(DISTINCT CASE WHEN assignment.converted THEN assignment.id END)::int', 'totalConversions')

View file

@ -375,25 +375,40 @@ export class AnalyticsGatewayController {
*/
@Get('segments')
async getSegmentsData(@Query() query: DateRangeQueryDto & { dimension?: string; metric?: string }) {
const segments = await this.analyticsClient.getSegments().catch((err) => {
this.logger.warn(`getSegments failed: ${err.message}`);
return [];
});
const dimension = (query.dimension ?? 'device') as string;
const metric = (query.metric ?? 'sessions') as string;
const dimension = (query.dimension ?? 'source') as 'device' | 'browser' | 'os' | 'country' | 'source' | 'medium' | 'campaign';
const metric = (query.metric ?? 'sessions') as 'sessions' | 'users' | 'pageViews' | 'conversions' | 'bounceRate';
const total = segments.length;
let rawItems: Array<{ name: string; sessions: number; percentage: number }> = [];
if (dimension === 'device') {
const data = await this.analyticsClient.getDevices(query).catch(() => []);
rawItems = data.map((d) => ({ name: d.deviceType, sessions: d.sessions, percentage: d.percentage * 100 }));
} else if (dimension === 'browser') {
const data = await this.analyticsClient.getBrowsers(query).catch(() => []);
rawItems = data.map((d) => ({ name: d.browser, sessions: d.sessions, percentage: d.percentage * 100 }));
} else if (dimension === 'country') {
const data = await this.analyticsClient.getGeography(query).catch(() => []);
rawItems = data.map((d) => ({ name: d.location, sessions: d.sessions, percentage: d.percentage * 100 }));
} else if (dimension === 'source') {
const data = await this.analyticsClient.getChannels(query).catch(() => []);
rawItems = data.map((d) => ({ name: d.channel, sessions: d.sessions, percentage: 0 }));
const total = rawItems.reduce((s, r) => s + r.sessions, 0);
rawItems = rawItems.map((r) => ({ ...r, percentage: total > 0 ? (r.sessions / total) * 100 : 0 }));
} else {
const data = await this.analyticsClient.getChannels(query).catch(() => []);
rawItems = data.map((d) => ({ name: d.channel, sessions: d.sessions, percentage: 0 }));
}
return {
dimension,
metric,
segments: segments.map((s, i) => ({
name: s.name,
value: total > 0 ? Math.round(1000 / total) : 0,
percentage: total > 0 ? Math.round(100 / total) : 0,
segments: rawItems.map((item) => ({
name: item.name,
value: item.sessions,
percentage: Math.round(item.percentage * 10) / 10,
trend: 0,
})),
total,
total: rawItems.reduce((s, r) => s + r.sessions, 0),
};
}
@ -687,9 +702,11 @@ export class AnalyticsGatewayController {
stages,
totalUsers,
overallConversionRate: Math.round(overallConversionRate * 100) / 100,
biggestDropOff: stages.length >= 2
? stages.slice(1).reduce((max, s) => s.dropOffRate > max.dropOffRate ? s : max, stages[1])
: { stage: '', count: 0, conversionRate: 0, dropOffRate: 0 },
biggestDropOff: (() => {
if (stages.length < 2) return { fromStage: '', toStage: '', dropOffRate: 0 };
const maxIdx = stages.slice(1).reduce((maxI, s, i) => s.dropOffRate > stages.slice(1)[maxI].dropOffRate ? i : maxI, 0) + 1;
return { fromStage: stages[maxIdx - 1].stage, toStage: stages[maxIdx].stage, dropOffRate: stages[maxIdx].dropOffRate };
})(),
dateRange: { startDate: start.toISOString(), endDate: end.toISOString() },
};
}

View file

@ -180,7 +180,7 @@ const SegmentsPage = () => {
sortable: true,
render: (row) => {
const trend = row.trend > 0 ? 'up' : row.trend < 0 ? 'down' : 'flat';
const Icon = trend === 'up' ? TrendingUp : trend === 'down' ? TrendingDown : Minus;
const Icon = trend === 'up' ? TrendingUpIcon : trend === 'down' ? TrendingDownIcon : MinusIcon;
return (
<TrendIcon $trend={trend}>
<Icon />

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB