This commit is contained in:
Carl-Gerhard Lindesvärd
2025-03-28 09:31:12 +01:00
parent 56c7283ec6
commit d4fcc82fc3
4 changed files with 409 additions and 2 deletions

View File

@@ -23,6 +23,7 @@ import {
PieChartIcon,
PlusIcon,
Trash,
TrendingUpIcon,
} from 'lucide-react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
@@ -113,6 +114,7 @@ export function ListDashboards({ dashboards }: ListDashboardsProps) {
funnel: ConeIcon,
area: AreaChartIcon,
retention: ChartScatterIcon,
conversion: TrendingUpIcon,
}[report.chartType];
return (

View File

@@ -218,7 +218,8 @@ const { Tooltip, TooltipProvider } = createChartTooltip<
metric: (typeof TITLES)[number];
interval: IInterval;
}
>(({ context: { metric, interval }, data }) => {
>(({ context: { metric, interval }, data: dataArray }) => {
const data = dataArray[0]!;
const formatDate = useFormatDateInterval(interval);
const number = useNumber();

View File

@@ -1,7 +1,7 @@
'use client';
import { PencilIcon } from 'lucide-react';
import { useState } from 'react';
import { useEffect, useRef, useState } from 'react';
type Props = {
name?: string;
@@ -10,6 +10,7 @@ type Props = {
const EditReportName = ({ name }: Props) => {
const [isEditing, setIsEditing] = useState(false);
const [newName, setNewName] = useState(name);
const inputRef = useRef<HTMLInputElement>(null);
const onSubmit = () => {
if (newName === name) {
@@ -29,11 +30,19 @@ const EditReportName = ({ name }: Props) => {
setIsEditing(false);
};
useEffect(() => {
if (isEditing) {
inputRef.current?.focus();
}
}, [isEditing]);
if (isEditing) {
return (
<div className="flex">
<input
ref={inputRef}
type="text"
className="w-full rounded-md border border-input p-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
value={newName}
onKeyDown={(e) => {
if (e.key === 'Enter') {

View File

@@ -0,0 +1,395 @@
import type { ClickHouseClient } from '@clickhouse/client';
import { Query, createQuery } from '../clickhouse/query-builder';
export interface Insight {
type: string;
message: string;
data: any;
}
interface TrafficSpikeResult {
referrer_name: string;
date: string;
visitor_count: number;
avg_previous_7_days: number;
}
interface EventSurgeResult {
date: string;
event_count: number;
avg_previous_7_days: number;
}
interface NewVisitorTrendResult {
month: string;
new_visitors: number;
prev_month_visitors: number;
}
interface ReferralSourceResult {
referrer_name: string;
count: number;
percentage: number;
}
interface SessionDurationResult {
week: string;
avg_duration: number;
prev_week_duration: number;
}
interface TopContentResult {
path: string;
view_count: number;
unique_viewers: number;
}
interface BounceRateResult {
month: string;
bounce_rate: number;
prev_month_bounce_rate: number;
}
interface ReturningVisitorResult {
quarter: string;
returning_visitors: number;
prev_quarter_visitors: number;
}
interface GeographicShiftResult {
country: string;
visitor_count: number;
prev_week_count: number;
}
interface EventCompletionResult {
event_name: string;
month: string;
completion_count: number;
prev_month_count: number;
}
export class InsightsService {
constructor(private client: ClickHouseClient) {}
private async getTrafficSpikes(projectId: string): Promise<Insight[]> {
const query = createQuery(this.client)
.select([
'referrer_name',
'toDate(created_at) as date',
'COUNT(*) as visitor_count',
'avg(COUNT(*)) OVER (ORDER BY date ROWS BETWEEN 7 PRECEDING AND 1 PRECEDING) as avg_previous_7_days',
])
.from('events')
.where(
'created_at',
'>=',
new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
)
.where('project_id', '=', projectId)
.groupBy(['referrer_name', 'date'])
.having('visitor_count', '>', 'avg_previous_7_days * 2')
.orderBy('visitor_count', 'DESC');
const results = await query.execute();
return (results as TrafficSpikeResult[]).map((result) => ({
type: 'traffic_spike',
message: `Your website experienced a significant increase in visitors from ${result.referrer_name} on ${result.date}.`,
data: result,
}));
}
private async getEventSurges(projectId: string): Promise<Insight[]> {
const query = createQuery(this.client)
.select([
'toDate(created_at) as date',
'COUNT(*) as event_count',
'avg(COUNT(*)) OVER (ORDER BY date ROWS BETWEEN 7 PRECEDING AND 1 PRECEDING) as avg_previous_7_days',
])
.from('events')
.where(
'created_at',
'>=',
new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
)
.where('project_id', '=', projectId)
.groupBy(['date'])
.having('event_count', '>', 'avg_previous_7_days * 1.3')
.orderBy('event_count', 'DESC');
const results = await query.execute();
return (results as EventSurgeResult[]).map((result) => ({
type: 'event_surge',
message: `There was a surge in events recorded on ${result.date}, marking a ${Math.round((result.event_count / result.avg_previous_7_days - 1) * 100)}% increase from the previous average.`,
data: result,
}));
}
private async getNewVisitorTrends(projectId: string): Promise<Insight[]> {
const query = createQuery(this.client)
.select([
'toMonth(created_at) as month',
'COUNT(DISTINCT device_id) as new_visitors',
'lag(COUNT(DISTINCT device_id)) OVER (ORDER BY month) as prev_month_visitors',
])
.from('sessions')
.where(
'created_at',
'>=',
new Date(Date.now() - 60 * 24 * 60 * 60 * 1000),
)
.where('project_id', '=', projectId)
.where('is_new', '=', true)
.groupBy(['month'])
.having('new_visitors', '>', 'prev_month_visitors * 1.2')
.orderBy('month', 'DESC');
const results = await query.execute();
return (results as NewVisitorTrendResult[]).map((result) => ({
type: 'new_visitor_trend',
message: `This month, you saw a ${Math.round((result.new_visitors / result.prev_month_visitors - 1) * 100)}% increase in new visitors compared to last month.`,
data: result,
}));
}
private async getReferralSourceHighlights(
projectId: string,
): Promise<Insight[]> {
const query = createQuery(this.client)
.select([
'referrer_name',
'COUNT(*) as count',
'COUNT(*) / sum(COUNT(*)) OVER () as percentage',
])
.from('sessions')
.where('created_at', '>=', new Date(Date.now() - 7 * 24 * 60 * 60 * 1000))
.where('project_id', '=', projectId)
.groupBy(['referrer_name'])
.having('percentage', '>=', 0.5)
.orderBy('count', 'DESC');
const results = await query.execute();
return (results as ReferralSourceResult[]).map((result) => ({
type: 'referral_source',
message: `${result.referrer_name} was your top referral source this week, contributing to ${Math.round(result.percentage * 100)}% of the total traffic.`,
data: result,
}));
}
private async getSessionDurationChanges(
projectId: string,
): Promise<Insight[]> {
const query = createQuery(this.client)
.select([
'toWeek(created_at) as week',
'avg(duration) as avg_duration',
'lag(avg(duration)) OVER (ORDER BY week) as prev_week_duration',
])
.from('sessions')
.where(
'created_at',
'>=',
new Date(Date.now() - 14 * 24 * 60 * 60 * 1000),
)
.where('project_id', '=', projectId)
.groupBy(['week'])
.having('avg_duration', '>', 'prev_week_duration * 1.25')
.orderBy('week', 'DESC');
const results = await query.execute();
return (results as SessionDurationResult[]).map((result) => ({
type: 'session_duration',
message: `Users spent ${Math.round((result.avg_duration / result.prev_week_duration - 1) * 100)}% more time on average per session this week compared to last week.`,
data: result,
}));
}
private async getTopPerformingContent(projectId: string): Promise<Insight[]> {
const query = createQuery(this.client)
.select([
'path',
'COUNT(*) as view_count',
'COUNT(DISTINCT device_id) as unique_viewers',
])
.from('events')
.where(
'created_at',
'>=',
new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
)
.where('project_id', '=', projectId)
.groupBy(['path'])
.orderBy('view_count', 'DESC')
.limit(1);
const results = await query.execute();
return (results as TopContentResult[]).map((result) => ({
type: 'top_content',
message: `Your content at "${result.path}" was the most viewed content this month with ${result.view_count} views from ${result.unique_viewers} unique viewers.`,
data: result,
}));
}
private async getBounceRateImprovements(
projectId: string,
): Promise<Insight[]> {
const query = createQuery(this.client)
.select([
'toMonth(created_at) as month',
'sum(is_bounce) / COUNT(*) as bounce_rate',
'lag(sum(is_bounce) / COUNT(*)) OVER (ORDER BY month) as prev_month_bounce_rate',
])
.from('sessions')
.where(
'created_at',
'>=',
new Date(Date.now() - 60 * 24 * 60 * 60 * 1000),
)
.where('project_id', '=', projectId)
.groupBy(['month'])
.having('bounce_rate', '<', 'prev_month_bounce_rate * 0.85')
.orderBy('month', 'DESC');
const results = await query.execute();
return (results as BounceRateResult[]).map((result) => ({
type: 'bounce_rate',
message: `The bounce rate decreased by ${Math.round((1 - result.bounce_rate / result.prev_month_bounce_rate) * 100)}% this month, indicating more engaging content.`,
data: result,
}));
}
private async getReturningVisitorTrends(
projectId: string,
): Promise<Insight[]> {
const query = createQuery(this.client)
.select([
'toQuarter(created_at) as quarter',
'COUNT(DISTINCT device_id) as returning_visitors',
'lag(COUNT(DISTINCT device_id)) OVER (ORDER BY quarter) as prev_quarter_visitors',
])
.from('sessions')
.where(
'created_at',
'>=',
new Date(Date.now() - 180 * 24 * 60 * 60 * 1000),
)
.where('project_id', '=', projectId)
.where('is_returning', '=', true)
.groupBy(['quarter'])
.having('returning_visitors', '>', 'prev_quarter_visitors * 1.1')
.orderBy('quarter', 'DESC');
const results = await query.execute();
return (results as ReturningVisitorResult[]).map((result) => ({
type: 'returning_visitors',
message: `Returning visitors increased by ${Math.round((result.returning_visitors / result.prev_quarter_visitors - 1) * 100)}% this quarter, showing growing user loyalty.`,
data: result,
}));
}
private async getGeographicInterestShifts(
projectId: string,
): Promise<Insight[]> {
const query = createQuery(this.client)
.select([
'country',
'COUNT(*) as visitor_count',
'lag(COUNT(*)) OVER (ORDER BY toWeek(created_at)) as prev_week_count',
])
.from('sessions')
.where(
'created_at',
'>=',
new Date(Date.now() - 14 * 24 * 60 * 60 * 1000),
)
.where('project_id', '=', projectId)
.groupBy(['country', 'toWeek(created_at)'])
.having('visitor_count', '>', 'prev_week_count * 1.5')
.orderBy('visitor_count', 'DESC');
const results = await query.execute();
return (results as GeographicShiftResult[]).map((result) => ({
type: 'geographic_shift',
message: `There was a noticeable increase in traffic from ${result.country} this week.`,
data: result,
}));
}
private async getEventCompletionChanges(
projectId: string,
): Promise<Insight[]> {
const query = createQuery(this.client)
.select([
'event_name',
'toMonth(created_at) as month',
'COUNT(*) as completion_count',
'lag(COUNT(*)) OVER (ORDER BY month) as prev_month_count',
])
.from('events')
.where(
'created_at',
'>=',
new Date(Date.now() - 60 * 24 * 60 * 60 * 1000),
)
.where('project_id', '=', projectId)
.where('status', '=', 'completed')
.groupBy(['event_name', 'month'])
.having('completion_count', '>', 'prev_month_count * 1.05')
.orderBy('month', 'DESC');
const results = await query.execute();
return (results as EventCompletionResult[]).map((result) => ({
type: 'event_completion',
message: `The completion rate for your "${result.event_name}" event increased by ${Math.round((result.completion_count / result.prev_month_count - 1) * 100)}% this month.`,
data: result,
}));
}
async generateInsights(projectId: string): Promise<Insight[]> {
const [
trafficSpikes,
eventSurges,
newVisitorTrends,
referralSources,
sessionDurations,
topContent,
bounceRates,
returningVisitors,
geographicShifts,
eventCompletions,
] = await Promise.all([
this.getTrafficSpikes(projectId),
this.getEventSurges(projectId),
this.getNewVisitorTrends(projectId),
this.getReferralSourceHighlights(projectId),
this.getSessionDurationChanges(projectId),
this.getTopPerformingContent(projectId),
this.getBounceRateImprovements(projectId),
this.getReturningVisitorTrends(projectId),
this.getGeographicInterestShifts(projectId),
this.getEventCompletionChanges(projectId),
]);
return [
...trafficSpikes,
...eventSurges,
...newVisitorTrends,
...referralSources,
...sessionDurations,
...topContent,
...bounceRates,
...returningVisitors,
...geographicShifts,
...eventCompletions,
].sort((a, b) => {
// Sort by most recent data first
const dateA = new Date(
a.data.date || a.data.month || a.data.week || a.data.quarter,
);
const dateB = new Date(
b.data.date || b.data.month || b.data.week || b.data.quarter,
);
return dateB.getTime() - dateA.getTime();
});
}
}