fix(ts)
This commit is contained in:
@@ -23,6 +23,7 @@ import {
|
|||||||
PieChartIcon,
|
PieChartIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
Trash,
|
Trash,
|
||||||
|
TrendingUpIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
@@ -113,6 +114,7 @@ export function ListDashboards({ dashboards }: ListDashboardsProps) {
|
|||||||
funnel: ConeIcon,
|
funnel: ConeIcon,
|
||||||
area: AreaChartIcon,
|
area: AreaChartIcon,
|
||||||
retention: ChartScatterIcon,
|
retention: ChartScatterIcon,
|
||||||
|
conversion: TrendingUpIcon,
|
||||||
}[report.chartType];
|
}[report.chartType];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -218,7 +218,8 @@ const { Tooltip, TooltipProvider } = createChartTooltip<
|
|||||||
metric: (typeof TITLES)[number];
|
metric: (typeof TITLES)[number];
|
||||||
interval: IInterval;
|
interval: IInterval;
|
||||||
}
|
}
|
||||||
>(({ context: { metric, interval }, data }) => {
|
>(({ context: { metric, interval }, data: dataArray }) => {
|
||||||
|
const data = dataArray[0]!;
|
||||||
const formatDate = useFormatDateInterval(interval);
|
const formatDate = useFormatDateInterval(interval);
|
||||||
const number = useNumber();
|
const number = useNumber();
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { PencilIcon } from 'lucide-react';
|
import { PencilIcon } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -10,6 +10,7 @@ type Props = {
|
|||||||
const EditReportName = ({ name }: Props) => {
|
const EditReportName = ({ name }: Props) => {
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [newName, setNewName] = useState(name);
|
const [newName, setNewName] = useState(name);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const onSubmit = () => {
|
const onSubmit = () => {
|
||||||
if (newName === name) {
|
if (newName === name) {
|
||||||
@@ -29,11 +30,19 @@ const EditReportName = ({ name }: Props) => {
|
|||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEditing) {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}
|
||||||
|
}, [isEditing]);
|
||||||
|
|
||||||
if (isEditing) {
|
if (isEditing) {
|
||||||
return (
|
return (
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<input
|
<input
|
||||||
|
ref={inputRef}
|
||||||
type="text"
|
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}
|
value={newName}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
|
|||||||
395
packages/db/src/services/insights.service.ts
Normal file
395
packages/db/src/services/insights.service.ts
Normal 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user