diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/dashboards/list-dashboards/list-dashboards.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/dashboards/list-dashboards/list-dashboards.tsx index 24ef40b6..6c5c3dbb 100644 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/dashboards/list-dashboards/list-dashboards.tsx +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/dashboards/list-dashboards/list-dashboards.tsx @@ -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 ( diff --git a/apps/dashboard/src/components/overview/overview-metrics.tsx b/apps/dashboard/src/components/overview/overview-metrics.tsx index 1a23bfe2..9c77efd4 100644 --- a/apps/dashboard/src/components/overview/overview-metrics.tsx +++ b/apps/dashboard/src/components/overview/overview-metrics.tsx @@ -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(); diff --git a/apps/dashboard/src/components/report/edit-report-name.tsx b/apps/dashboard/src/components/report/edit-report-name.tsx index 8cf39e00..6c7359dd 100644 --- a/apps/dashboard/src/components/report/edit-report-name.tsx +++ b/apps/dashboard/src/components/report/edit-report-name.tsx @@ -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(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 (
{ if (e.key === 'Enter') { diff --git a/packages/db/src/services/insights.service.ts b/packages/db/src/services/insights.service.ts new file mode 100644 index 00000000..23bc3506 --- /dev/null +++ b/packages/db/src/services/insights.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(); + }); + } +}