feat(trends): Add real-time trend update support with new calculation methods

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-04-07 13:40:12 -07:00
parent 0781dd9628
commit 332854bb29

View file

@ -22,6 +22,8 @@ export interface TrendsResult {
};
}
const VALID_GRANULARITIES = new Set<string>(Object.values(TimeGranularity));
@Injectable()
export class TrendsService {
constructor(
@ -32,12 +34,66 @@ export class TrendsService {
async getTrends(query: TrendsQueryDto): Promise<TrendsResult> {
const { metric, startDate, endDate, granularity = 'day' } = query;
// Whitelist granularity before interpolating into SQL
const safeBucket = VALID_GRANULARITIES.has(granularity) ? granularity : 'day';
const bucketExpr = `date_trunc('${safeBucket}', timestamp AT TIME ZONE 'UTC')`;
let rawSql: string | null = null;
if (metric === MetricType.PAGE_VIEWS) {
rawSql = `
SELECT ${bucketExpr} AS timestamp, COUNT(*)::bigint AS value
FROM raw_events
WHERE "eventType" IN ('pageview', 'pageView')
AND timestamp BETWEEN $1 AND $2
GROUP BY ${bucketExpr}
ORDER BY timestamp ASC
`;
} else if (metric === MetricType.SESSIONS) {
rawSql = `
SELECT ${bucketExpr} AS timestamp, COUNT(DISTINCT "sessionId")::bigint AS value
FROM raw_events
WHERE timestamp BETWEEN $1 AND $2
GROUP BY ${bucketExpr}
ORDER BY timestamp ASC
`;
} else if (metric === MetricType.UNIQUE_VISITORS) {
rawSql = `
SELECT ${bucketExpr} AS timestamp,
COUNT(DISTINCT COALESCE("userId", "sessionId"))::bigint AS value
FROM raw_events
WHERE timestamp BETWEEN $1 AND $2
GROUP BY ${bucketExpr}
ORDER BY timestamp ASC
`;
}
if (rawSql) {
const rows = await this.metricsRepository.query(rawSql, [startDate, endDate]) as Array<{
timestamp: Date;
value: string;
}>;
const values = rows.map((r) => Number(r.value));
const total = values.reduce((sum, v) => sum + v, 0);
return {
metric,
granularity: safeBucket,
data: rows.map((r) => ({ timestamp: r.timestamp, value: Number(r.value), count: Number(r.value) })),
summary: {
total,
average: values.length > 0 ? total / values.length : 0,
min: values.length > 0 ? Math.min(...values) : 0,
max: values.length > 0 ? Math.max(...values) : 0,
},
};
}
// Fallback: pre-aggregated table for other metrics (bounce_rate, avg_session_duration, etc.)
const data = await this.metricsRepository.find({
where: {
metricType: metric as MetricType,
granularity: granularity as TimeGranularity,
granularity: safeBucket as TimeGranularity,
timestamp: Between(new Date(startDate), new Date(endDate)),
dimension: undefined,
},
order: { timestamp: 'ASC' },
});
@ -47,7 +103,7 @@ export class TrendsService {
return {
metric,
granularity,
granularity: safeBucket,
data: data.map((d) => ({
timestamp: d.timestamp,
value: Number(d.value),