From 5b7dae615aef4970d61589c55e5eb76385047d42 Mon Sep 17 00:00:00 2001 From: Dave Augustus Date: Mon, 9 Mar 2026 13:04:28 +0530 Subject: [PATCH] feat: add blog analytics dashboard with collapsible UI - Add useGetBlogStats hook with typed BlogAnalytics interface and time_range support - Add BlogAnalyticsDashboard with collapsible slide-down panel - Show inline stats summary (reads, unique readers, avg read time) near blog date - Full dashboard: stat cards, daily/monthly activity charts, countries, cities, platforms, referrers, read time distribution - Country normalization (US/USA/United States merged) - Filter out local network entries from analytics - Time range selector (24h/48h/7d/30d/90d/1y) - Responsive layout with mobile-friendly grid --- .../src/app/blog/[slug]/BlogPageClient.tsx | 19 +- .../components/BlogAnalyticsDashboard.tsx | 499 ++++++++++++++++++ .../src/app/blog/components/BlogStats.tsx | 17 + .../src/hooks/blog/useGetBlogStats.ts | 51 ++ 4 files changed, 581 insertions(+), 5 deletions(-) create mode 100644 apps/the_monkeys/src/app/blog/components/BlogAnalyticsDashboard.tsx create mode 100644 apps/the_monkeys/src/app/blog/components/BlogStats.tsx create mode 100644 apps/the_monkeys/src/hooks/blog/useGetBlogStats.ts diff --git a/apps/the_monkeys/src/app/blog/[slug]/BlogPageClient.tsx b/apps/the_monkeys/src/app/blog/[slug]/BlogPageClient.tsx index e3627e62..109e89a8 100644 --- a/apps/the_monkeys/src/app/blog/[slug]/BlogPageClient.tsx +++ b/apps/the_monkeys/src/app/blog/[slug]/BlogPageClient.tsx @@ -16,13 +16,16 @@ import { import { SocialSnapshotCard } from '@/components/social/SocialSnapshot'; import { TopicLinksContainerCompact } from '@/components/topics/topicsContainer'; import { UserInfoCardBlogPage } from '@/components/user/userInfo'; +import useAuth from '@/hooks/auth/useAuth'; import useGetPublishedBlogDetailByBlogId from '@/hooks/blog/useGetPublishedBlogDetailByBlogId'; import useGetProfileInfoById from '@/hooks/user/useGetProfileInfoByUserId'; import { purifyHTMLString } from '@/utils/purifyHTML'; import moment from 'moment'; +import { BlogAnalyticsDashboard } from '../components/BlogAnalyticsDashboard'; import { BlogReactionsContainer } from '../components/BlogReactions'; import { BlogRecommendations } from '../components/BlogRecommendations'; +import { BlogStats } from '../components/BlogStats'; const Editor = dynamic(() => import('@/components/editor/preview'), { ssr: false, @@ -39,7 +42,9 @@ const BlogPageClient = ({ urlBlogId, fullSlug }: BlogPageClientProps) => { useGetPublishedBlogDetailByBlogId(urlBlogId); const authorId = blog?.owner_account_id; + const { data: session } = useAuth(); const { user } = useGetProfileInfoById(authorId); + const isOwner = !!session?.account_id && session.account_id === authorId; useEffect(() => { const startTime = Date.now(); @@ -135,11 +140,13 @@ const BlogPageClient = ({ urlBlogId, fullSlug }: BlogPageClientProps) => { <>
-

- {moment(date).format('MMM DD, yyyy')} - {' / '} - {moment(date).utc().format('hh:mm A')} UTC -

+
+

+ {moment(date).format('MMM DD, yyyy')} + {' / '} + {moment(date).utc().format('hh:mm A')} UTC +

+
{
+ +
diff --git a/apps/the_monkeys/src/app/blog/components/BlogAnalyticsDashboard.tsx b/apps/the_monkeys/src/app/blog/components/BlogAnalyticsDashboard.tsx new file mode 100644 index 00000000..55e895c7 --- /dev/null +++ b/apps/the_monkeys/src/app/blog/components/BlogAnalyticsDashboard.tsx @@ -0,0 +1,499 @@ +'use client'; + +import { useCallback, useMemo, useRef, useState } from 'react'; + +import Icon from '@/components/icon'; +import useGetBlogStats, { + BlogAnalytics, + TimeRange, +} from '@/hooks/blog/useGetBlogStats'; + +// Normalize country names: US, USA, United States → United States +const COUNTRY_ALIASES: Record = { + us: 'United States', + usa: 'United States', + 'united states': 'United States', + ca: 'Canada', + canada: 'Canada', + in: 'India', + india: 'India', + uk: 'United Kingdom', + gb: 'United Kingdom', + 'united kingdom': 'United Kingdom', +}; + +function normalizeCountries( + raw: Record +): Record { + const merged: Record = {}; + for (const [key, count] of Object.entries(raw)) { + const trimmed = key.trim(); + if (!trimmed) continue; + const normalized = COUNTRY_ALIASES[trimmed.toLowerCase()] || trimmed; + merged[normalized] = (merged[normalized] || 0) + count; + } + return merged; +} + +function formatDuration(ms: number): string { + if (ms < 1000) return `${Math.round(ms)}ms`; + const seconds = Math.round(ms / 1000); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + const remaining = seconds % 60; + return remaining > 0 ? `${minutes}m ${remaining}s` : `${minutes}m`; +} + +const LOCAL_NETWORK_RE = + /^(localhost|127\.\d+\.\d+\.\d+|10\.\d+\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+|192\.168\.\d+\.\d+|0\.0\.0\.0|\[::1\]|::1)$/i; + +function isLocalEntry(key: string): boolean { + const trimmed = key.trim().toLowerCase(); + if (!trimmed) return true; + if (LOCAL_NETWORK_RE.test(trimmed)) return true; + // Catch referrer URLs pointing to local hosts + try { + const hostname = new URL(trimmed).hostname; + return LOCAL_NETWORK_RE.test(hostname); + } catch { + return false; + } +} + +function filterLocal(data: Record): Record { + const filtered: Record = {}; + for (const [key, value] of Object.entries(data)) { + if (!isLocalEntry(key)) filtered[key] = value; + } + return filtered; +} + +function formatReferrer(url: string): string { + if (!url) return 'Direct / Unknown'; + try { + const parsed = new URL(url); + return parsed.hostname + (parsed.pathname !== '/' ? parsed.pathname : ''); + } catch { + return url; + } +} + +// Horizontal bar used across sections +const HBar = ({ + label, + value, + maxValue, +}: { + label: string; + value: number; + maxValue: number; +}) => { + const pct = maxValue > 0 ? (value / maxValue) * 100 : 0; + return ( +
+
+ {label} + {value} +
+
+
+
+
+ ); +}; + +// Sparkline-style daily activity bars +const DailyActivityChart = ({ + dailyActivity, +}: { + dailyActivity: Record; +}) => { + const entries = useMemo(() => { + return Object.entries(dailyActivity) + .sort(([a], [b]) => a.localeCompare(b)) + .slice(-28); // last 28 days + }, [dailyActivity]); + + const maxVal = Math.max(...entries.map(([, v]) => v), 1); + + return ( +
+
+ {entries.map(([date, count]) => { + const height = count > 0 ? Math.max((count / maxVal) * 100, 8) : 3; + return ( +
+
0 + ? 'bg-brand-orange/80 hover:bg-brand-orange' + : 'bg-foreground-light/30 dark:bg-foreground-dark/30' + }`} + style={{ height: `${height}%` }} + title={`${date}: ${count}`} + /> +
+ ); + })} +
+
+ {entries[0]?.[0]?.slice(5)} + {entries[entries.length - 1]?.[0]?.slice(5)} +
+
+ ); +}; + +// Monthly activity bars (all-time, month granularity) +const MonthlyActivityChart = ({ + monthlyActivity, +}: { + monthlyActivity: Record; +}) => { + const entries = useMemo(() => { + return Object.entries(monthlyActivity).sort(([a], [b]) => + a.localeCompare(b) + ); + }, [monthlyActivity]); + + const maxVal = Math.max(...entries.map(([, v]) => v), 1); + + if (entries.length === 0) return null; + + return ( +
+
+ {entries.map(([month, count]) => { + const height = count > 0 ? Math.max((count / maxVal) * 100, 6) : 3; + return ( +
+
0 + ? 'bg-brand-orange/60 hover:bg-brand-orange' + : 'bg-foreground-light/20 dark:bg-foreground-dark/20' + }`} + style={{ height: `${height}%` }} + title={`${month}: ${count}`} + /> +
+ ); + })} +
+
+ {entries[0]?.[0]} + {entries[entries.length - 1]?.[0]} +
+
+ ); +}; + +const TIME_RANGE_OPTIONS: { label: string; value: TimeRange }[] = [ + { label: 'Default', value: '' }, + { label: '24h', value: '24h' }, + { label: '48h', value: '48h' }, + { label: '7d', value: '7d' }, + { label: '30d', value: '30d' }, + { label: '90d', value: '90d' }, + { label: '1y', value: '1y' }, +]; + +// Section wrapper +const StatSection = ({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) => ( +
+
+ {title} +
+ {children} +
+); + +// Top-level stat card +const StatCard = ({ + label, + value, +}: { + label: string; + value: string | number; +}) => ( +
+

{value}

+

{label}

+
+); + +const AnalyticsContent = ({ + analytics, + readCount, +}: { + analytics: BlogAnalytics; + readCount: number; +}) => { + const countries = useMemo( + () => filterLocal(normalizeCountries(analytics.countries)), + [analytics.countries] + ); + + const sortedCountries = useMemo( + () => Object.entries(countries).sort(([, a], [, b]) => b - a), + [countries] + ); + + const sortedCities = useMemo( + () => + Object.entries(filterLocal(analytics.cities)) + .filter(([key]) => key.trim()) + .sort(([, a], [, b]) => b - a), + [analytics.cities] + ); + + const sortedReferrers = useMemo( + () => + Object.entries(filterLocal(analytics.referrers)).sort( + ([, a], [, b]) => b - a + ), + [analytics.referrers] + ); + + const sortedPlatforms = useMemo( + () => + Object.entries(analytics.platforms) + .map(([k, v]) => [k.replace('PLATFORM_', ''), v] as [string, number]) + .sort(([, a], [, b]) => b - a), + [analytics.platforms] + ); + + const countryMax = sortedCountries[0]?.[1] ?? 1; + const cityMax = sortedCities[0]?.[1] ?? 1; + const referrerMax = sortedReferrers[0]?.[1] ?? 1; + const platformMax = sortedPlatforms[0]?.[1] ?? 1; + + return ( +
+ {/* Top-level stats grid */} +
+ + + + +
+ + {/* Daily Activity */} + {analytics.daily_activity && + Object.keys(analytics.daily_activity).length > 0 && ( + + + + )} + + {/* Two-column layout for geo + referrers */} +
+ {/* Countries */} + {sortedCountries.length > 0 && ( + +
+ {sortedCountries.map(([country, count]) => ( + + ))} +
+
+ )} + + {/* Cities */} + {sortedCities.length > 0 && ( + +
+ {sortedCities.map(([city, count]) => ( + + ))} +
+
+ )} +
+ +
+ {/* Platforms */} + {sortedPlatforms.length > 0 && ( + +
+ {sortedPlatforms.map(([platform, count]) => ( + + ))} +
+
+ )} + + {/* Read Time Distribution */} + {analytics.read_time_distribution && ( + +
+ {Object.entries(analytics.read_time_distribution) + .sort(([, a], [, b]) => b - a) + .map(([bucket, count]) => ( + + ))} +
+
+ )} +
+ + {/* Monthly Activity (all-time) */} + {analytics.monthly_activity && + Object.keys(analytics.monthly_activity).length > 0 && ( + + + + )} + + {/* Referrers */} + {sortedReferrers.length > 0 && ( + +
+ {sortedReferrers.map(([referrer, count]) => ( + + ))} +
+
+ )} + + {/* Bottom stats */} +
+ + + +
+
+ ); +}; + +export const BlogAnalyticsDashboard = ({ blogId }: { blogId?: string }) => { + const [open, setOpen] = useState(false); + const [timeRange, setTimeRange] = useState(''); + const contentRef = useRef(null); + const { stats, statsLoading, statsError } = useGetBlogStats( + blogId, + timeRange + ); + + const toggle = useCallback(() => setOpen((prev) => !prev), []); + + if (statsLoading || statsError || !stats?.analytics) return null; + + const { read_count, analytics } = stats; + + return ( +
+ {/* Trigger bar */} + + + {/* Collapsible panel */} +
+
+
+ {/* Time range selector */} +
+
Analytics
+
+ {TIME_RANGE_OPTIONS.map((opt) => ( + + ))} +
+
+ + +
+
+
+
+ ); +}; diff --git a/apps/the_monkeys/src/app/blog/components/BlogStats.tsx b/apps/the_monkeys/src/app/blog/components/BlogStats.tsx new file mode 100644 index 00000000..237640fc --- /dev/null +++ b/apps/the_monkeys/src/app/blog/components/BlogStats.tsx @@ -0,0 +1,17 @@ +import Icon from '@/components/icon'; +import useGetBlogStats from '@/hooks/blog/useGetBlogStats'; + +export const BlogStats = ({ blogId }: { blogId?: string }) => { + const { stats, statsLoading, statsError } = useGetBlogStats(blogId); + + if (statsLoading || statsError) return null; + + return ( +
+ + + {stats?.read_count ?? 0} {stats?.read_count === 1 ? 'read' : 'reads'} + +
+ ); +}; diff --git a/apps/the_monkeys/src/hooks/blog/useGetBlogStats.ts b/apps/the_monkeys/src/hooks/blog/useGetBlogStats.ts new file mode 100644 index 00000000..efa32b61 --- /dev/null +++ b/apps/the_monkeys/src/hooks/blog/useGetBlogStats.ts @@ -0,0 +1,51 @@ +import { authFetcherV2 } from '@/services/fetcher'; +import { useQuery } from '@tanstack/react-query'; + +export const BLOG_STATS_QUERY_KEY = 'blog-stats'; + +export type TimeRange = '24h' | '48h' | '7d' | '30d' | '90d' | '1y' | ''; + +export interface BlogAnalytics { + avg_read_time_ms: number; + bounces: number; + cities: Record; + countries: Record; + daily_activity: Record; + engagement_rate: number; + hourly_activity: Record; + monthly_activity: Record; + platforms: Record; + read_time_distribution: Record; + realtime_views: Record; + referrers: Record; + total_likes: number; + unique_readers: number; + valid_views: number; +} + +export interface BlogStatsResponse { + blog_id: string; + read_count: number; + analytics: BlogAnalytics | null; +} + +const useGetBlogStats = ( + blogId: string | undefined, + timeRange: TimeRange = '' +) => { + const params = timeRange ? `?time_range=${timeRange}` : ''; + const { data, isLoading, isError } = useQuery({ + queryKey: [BLOG_STATS_QUERY_KEY, blogId, timeRange], + queryFn: () => authFetcherV2(`/blog/${blogId}/stats${params}`), + enabled: !!blogId, + staleTime: 60 * 1000, + }); + + return { + stats: data, + statsLoading: isLoading, + statsError: isError, + }; +}; + +export default useGetBlogStats;