From f22da4add1e455678f380aba8c2fd02012ef6457 Mon Sep 17 00:00:00 2001 From: Dries Augustyns Date: Sun, 10 May 2026 11:37:31 +0200 Subject: [PATCH] feat: implement caching for recent activity count to optimize performance and reduce database load --- apps/api/src/services/ActivityService.ts | 33 +- apps/api/src/services/keys.ts | 3 + apps/web/src/pages/index.tsx | 603 +++++++++++++++++++++-- 3 files changed, 585 insertions(+), 54 deletions(-) diff --git a/apps/api/src/services/ActivityService.ts b/apps/api/src/services/ActivityService.ts index b4f8a6e..51a2396 100644 --- a/apps/api/src/services/ActivityService.ts +++ b/apps/api/src/services/ActivityService.ts @@ -222,11 +222,34 @@ export class ActivityService { } } + /** + * Short Redis TTL for recent-count results. Short enough to feel live on the + * dashboard live-pulse, long enough to absorb the polling load when many + * tabs are open across the user base. + */ + private static readonly RECENT_COUNT_CACHE_TTL = 10; // seconds + /** * Get recent activity count (for real-time updates) * Returns count of activities in the last N minutes + * + * Backed by a short Redis cache because the dashboard polls this endpoint + * every 30 seconds per open tab; without the cache, every poll would run + * three COUNT queries against the events, emails, and workflow_executions + * tables. */ public static async getRecentActivityCount(projectId: string, minutes = 5): Promise { + const cacheKey = Keys.Activity.recentCount(projectId, minutes); + + try { + const cached = await redis.get(cacheKey); + if (cached !== null) { + return parseInt(cached, 10); + } + } catch (error) { + signale.warn('[ACTIVITY] Failed to read recent-count cache:', error); + } + const since = new Date(Date.now() - minutes * 60 * 1000); const dateFilter: Prisma.DateTimeFilter = {gte: since}; @@ -245,7 +268,15 @@ export class ActivityService { }), ]); - return eventCount + emailCount + workflowCount; + const total = eventCount + emailCount + workflowCount; + + try { + await redis.setex(cacheKey, this.RECENT_COUNT_CACHE_TTL, total.toString()); + } catch (error) { + signale.warn('[ACTIVITY] Failed to cache recent-count:', error); + } + + return total; } /** diff --git a/apps/api/src/services/keys.ts b/apps/api/src/services/keys.ts index 1f1758e..66ed85c 100644 --- a/apps/api/src/services/keys.ts +++ b/apps/api/src/services/keys.ts @@ -53,6 +53,9 @@ export const Keys = { stats(projectId: string, startTime: number | string, endTime: number | string): string { return `activity:stats:${projectId}:${startTime}:${endTime}`; }, + recentCount(projectId: string, minutes: number): string { + return `activity:recent-count:${projectId}:${minutes}`; + }, }, Analytics: { timeseries(projectId: string, startDate: string, endDate: string): string { diff --git a/apps/web/src/pages/index.tsx b/apps/web/src/pages/index.tsx index 001702a..88702f1 100644 --- a/apps/web/src/pages/index.tsx +++ b/apps/web/src/pages/index.tsx @@ -10,10 +10,31 @@ import { CardTitle, Skeleton, } from '@plunk/ui'; -import {AlertCircle, Mail, Send, TrendingUp, Users} from 'lucide-react'; +import type {Activity, ActivityStats, CursorPaginatedResponse} from '@plunk/types'; +import {animate, AnimatePresence, motion, useMotionValue, useTransform} from 'framer-motion'; +import { + AlertCircle, + ArrowDownRight, + ArrowUpRight, + Calendar, + Eye, + Inbox, + Mail, + Minus, + MousePointerClick, + Send, + ShieldCheck, + Sparkles, + TrendingUp, + Users, + Workflow, + XCircle, + Zap, +} from 'lucide-react'; import {NextSeo} from 'next-seo'; import Link from 'next/link'; -import {useState} from 'react'; +import {useEffect, useMemo, useState} from 'react'; +import useSWR from 'swr'; import {ApiKeyDisplay} from '../components/ApiKeyDisplay'; import {DashboardLayout} from '../components/DashboardLayout'; import {QuickStart} from '../components/QuickStart'; @@ -28,6 +49,207 @@ import {useConfig} from '../lib/hooks/useConfig'; import {useUser} from '../lib/hooks/useUser'; import {network} from '../lib/network'; +const MILESTONES = [100, 1_000, 10_000, 100_000, 1_000_000, 10_000_000]; + +function nextMilestone(value: number): number { + return MILESTONES.find(m => m > value) ?? value; +} + +function formatCompact(value: number): string { + if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(value % 1_000_000 === 0 ? 0 : 1)}M`; + if (value >= 1_000) return `${(value / 1_000).toFixed(value % 1_000 === 0 ? 0 : 1)}K`; + return value.toLocaleString(); +} + +function getGreeting(): string { + const hour = new Date().getHours(); + if (hour >= 23 || hour < 5) return 'Working late'; + if (hour < 12) return 'Good morning'; + if (hour < 18) return 'Good afternoon'; + return 'Good evening'; +} + +function relativeTime(date: Date): string { + const seconds = Math.floor((Date.now() - date.getTime()) / 1000); + if (seconds < 60) return 'just now'; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + if (days < 7) return `${days}d ago`; + return date.toLocaleDateString(undefined, {month: 'short', day: 'numeric'}); +} + +type TrendDirection = 'up' | 'down' | 'flat' | 'new' | 'none'; + +interface TrendInfo { + direction: TrendDirection; + pct: number; +} + +function computeTrend(current: number, previous: number): TrendInfo { + if (previous === 0 && current === 0) return {direction: 'none', pct: 0}; + if (previous === 0 && current > 0) return {direction: 'new', pct: 0}; + const pct = ((current - previous) / Math.abs(previous)) * 100; + if (Math.abs(pct) < 0.5) return {direction: 'flat', pct: 0}; + return {direction: pct > 0 ? 'up' : 'down', pct: Math.abs(pct)}; +} + +function TrendChip({trend, label}: {trend: TrendInfo; label?: string}) { + if (trend.direction === 'none') { + return ( +

{label ?? 'No data yet'}

+ ); + } + + const config = { + up: {Icon: ArrowUpRight, color: 'text-emerald-700', bg: 'bg-emerald-50'}, + down: {Icon: ArrowDownRight, color: 'text-red-700', bg: 'bg-red-50'}, + flat: {Icon: Minus, color: 'text-neutral-600', bg: 'bg-neutral-100'}, + new: {Icon: ArrowUpRight, color: 'text-emerald-700', bg: 'bg-emerald-50'}, + }[trend.direction]; + + const {Icon, color, bg} = config; + const text = + trend.direction === 'new' + ? 'New' + : trend.direction === 'flat' + ? 'No change' + : `${trend.pct.toFixed(trend.pct >= 100 ? 0 : 1)}%`; + + return ( +
+ + + {text} + + vs previous 30d +
+ ); +} + +function AnimatedNumber({value, format}: {value: number; format?: (n: number) => string}) { + const motionValue = useMotionValue(0); + const rounded = useTransform(motionValue, latest => + format ? format(Math.round(latest)) : Math.round(latest).toLocaleString(), + ); + + useEffect(() => { + const controls = animate(motionValue, value, { + duration: 1.1, + ease: [0.22, 1, 0.36, 1], + }); + return () => controls.stop(); + }, [value, motionValue]); + + return {rounded}; +} + +interface ActivityVisual { + icon: React.ComponentType<{className?: string}>; + tone: 'neutral' | 'green' | 'blue' | 'amber' | 'red'; + label: string; +} + +function activityVisual(a: Activity): ActivityVisual { + switch (a.type) { + case 'email.sent': + return {icon: Send, tone: 'neutral', label: 'Sent'}; + case 'email.delivered': + return {icon: Inbox, tone: 'green', label: 'Delivered'}; + case 'email.opened': + return {icon: Eye, tone: 'green', label: 'Opened'}; + case 'email.clicked': + return {icon: MousePointerClick, tone: 'blue', label: 'Clicked'}; + case 'email.bounced': + return {icon: XCircle, tone: 'red', label: 'Bounced'}; + case 'email.complaint': + return {icon: AlertCircle, tone: 'red', label: 'Complaint'}; + case 'event.triggered': + return {icon: Zap, tone: 'amber', label: 'Event'}; + case 'campaign.sent': + return {icon: Mail, tone: 'neutral', label: 'Campaign'}; + case 'campaign.scheduled': + return {icon: Calendar, tone: 'blue', label: 'Scheduled'}; + case 'workflow.started': + case 'workflow.completed': + case 'workflow.email.scheduled': + return {icon: Workflow, tone: 'amber', label: 'Workflow'}; + default: + return {icon: Zap, tone: 'neutral', label: 'Event'}; + } +} + +const TONE_CLASSES: Record = { + neutral: {bg: 'bg-neutral-100', fg: 'text-neutral-700'}, + green: {bg: 'bg-emerald-50', fg: 'text-emerald-700'}, + blue: {bg: 'bg-sky-50', fg: 'text-sky-700'}, + amber: {bg: 'bg-amber-50', fg: 'text-amber-700'}, + red: {bg: 'bg-red-50', fg: 'text-red-700'}, +}; + +function activityTitle(a: Activity): string { + const m = a.metadata; + if (typeof m.subject === 'string' && m.subject) return m.subject; + if (typeof m.eventName === 'string' && m.eventName) return m.eventName; + if (typeof m.campaignName === 'string' && m.campaignName) return m.campaignName; + if (typeof m.workflowName === 'string' && m.workflowName) return m.workflowName; + return activityVisual(a).label; +} + +function LivePulse({count}: {count: number}) { + const isLive = count > 0; + return ( +
+ + {isLive && ( + + )} + + + + {isLive ? `${count.toLocaleString()} ${count === 1 ? 'event' : 'events'} in the last 5 min` : 'Quiet right now'} + +
+ ); +} + +function CompactActivityRow({activity}: {activity: Activity}) { + const visual = activityVisual(activity); + const Icon = visual.icon; + const tone = TONE_CLASSES[visual.tone]; + const title = activityTitle(activity); + const subtitle = activity.contactEmail; + + return ( + +
+ +
+
+
+

{title}

+ {visual.label} +
+ {subtitle &&

{subtitle}

} +
+ + {relativeTime(new Date(activity.timestamp))} + +
+ ); +} + export default function Index() { const {activeProject} = useActiveProject(); const {totalContacts, totalEmailsSent, totalCampaigns, openRate, isLoading} = useDashboardStats(); @@ -41,29 +263,137 @@ export default function Index() { const [isResending, setIsResending] = useState(false); const [resendMessage, setResendMessage] = useState(''); + // Previous-period stats (60d ago to 30d ago) for trend comparison. + // Round to UTC day boundary so the URL — and therefore the Redis cache key — + // is identical for every user on the same UTC day, letting the 5-minute + // server-side stats cache actually be shared across the user base. + const previousRangeUrl = useMemo(() => { + const today = new Date(); + today.setUTCHours(0, 0, 0, 0); + const thirtyDaysAgo = new Date(today); + thirtyDaysAgo.setUTCDate(today.getUTCDate() - 30); + const sixtyDaysAgo = new Date(today); + sixtyDaysAgo.setUTCDate(today.getUTCDate() - 60); + return `/activity/stats?startDate=${encodeURIComponent(sixtyDaysAgo.toISOString())}&endDate=${encodeURIComponent(thirtyDaysAgo.toISOString())}`; + }, []); + const {data: previousStats} = useSWR(previousRangeUrl, { + revalidateOnFocus: false, + dedupingInterval: 5 * 60 * 1000, + }); + + const emailsTrend = useMemo( + () => computeTrend(totalEmailsSent, previousStats?.totalEmailsSent ?? 0), + [totalEmailsSent, previousStats?.totalEmailsSent], + ); + const openRateTrend = useMemo( + () => computeTrend(openRate, previousStats?.openRate ?? 0), + [openRate, previousStats?.openRate], + ); + + // Live pulse — refresh every 30s. This is the actual real-time signal, so it + // gets the tightest cadence. Server-side it is backed by a short Redis cache + // (see Activity controller) so the polling load stays bounded. + const {data: recentCount} = useSWR<{count: number; minutes: number}>('/activity/recent-count?minutes=5', { + refreshInterval: 30_000, + revalidateOnFocus: false, + dedupingInterval: 15_000, + }); + + // Live activity feed — last 10 events, refresh every 60s. Slower than the + // pulse because the heavier query doesn't need to be tracked second-by-second. + // Sized to roughly match the Quick Start card's height in the side-by-side layout. + const {data: recentActivity} = useSWR>('/activity?limit=10', { + refreshInterval: 60_000, + revalidateOnFocus: false, + dedupingInterval: 30_000, + }); + + const greeting = useMemo(() => getGreeting(), []); + + const subtitle = useMemo(() => { + if (isLoading) return 'Catching up on the last 30 days.'; + if (totalEmailsSent === 0) { + if (totalContacts === 0) return `${activeProject?.name ?? 'Your project'} is fresh. Time to send the first email.`; + return `${totalContacts.toLocaleString()} ${totalContacts === 1 ? 'contact' : 'contacts'} ready. Time to send something.`; + } + const projectLabel = activeProject?.name ? `${activeProject.name} sent` : 'You sent'; + const base = `${projectLabel} ${totalEmailsSent.toLocaleString()} ${totalEmailsSent === 1 ? 'email' : 'emails'} in the last 30 days.`; + if (openRate >= 40) return `${base} Open rate is well above average.`; + if (openRate >= 25) return `${base} Open rate is healthy.`; + return base; + }, [isLoading, totalEmailsSent, totalContacts, openRate, activeProject?.name]); + + // Friendly console message for the developer audience. Once per session. + useEffect(() => { + if (typeof window === 'undefined') return; + const w = window as unknown as {__plunkHi?: boolean}; + if (w.__plunkHi) return; + w.__plunkHi = true; + // eslint-disable-next-line no-console + console.log( + '%cPlunk%c Built for developers who care about email.\nFound a rough edge? support@useplunk.com', + 'font: 600 14px ui-sans-serif, system-ui; color: #0a0a0a; background: #f5f5f5; padding: 2px 8px; border-radius: 4px;', + 'color: #525252; font: 12px ui-sans-serif, system-ui;', + ); + }, []); + + // totalCampaigns intentionally unused — replaced by Deliverability card below + void totalCampaigns; + const stats = [ { name: 'Total Contacts', - value: totalContacts.toLocaleString(), + value: totalContacts, icon: Users, + format: (n: number) => n.toLocaleString(), }, { name: 'Emails Sent', - value: totalEmailsSent.toLocaleString(), + value: totalEmailsSent, icon: Mail, - }, - { - name: 'Campaigns', - value: totalCampaigns.toLocaleString(), - icon: Send, + format: (n: number) => n.toLocaleString(), }, { name: 'Open Rate', - value: `${openRate.toFixed(1)}%`, + value: openRate, icon: TrendingUp, + format: (n: number) => `${n.toFixed(1)}%`, }, ]; + // Deliverability — prefer 7-day window, fall back to all-time when no 7-day sends + const sevenDay = securityMetrics?.status.sevenDay; + const allTime = securityMetrics?.status.allTime; + const delivWindow = sevenDay && sevenDay.total > 0 ? sevenDay : allTime; + const delivWindowLabel = sevenDay && sevenDay.total > 0 ? 'Last 7 days' : 'All time'; + const deliveryRate = + delivWindow && delivWindow.total > 0 ? ((delivWindow.total - delivWindow.bounces) / delivWindow.total) * 100 : 0; + const bounceRate = delivWindow?.bounceRate ?? 0; + const complaintRate = delivWindow?.complaintRate ?? 0; + const hasDelivData = !!delivWindow && delivWindow.total > 0; + + const bounceLevel = securityMetrics?.levels.bounce7Day ?? 'healthy'; + const complaintLevel = securityMetrics?.levels.complaint7Day ?? 'healthy'; + const worstLevel: 'healthy' | 'warning' | 'critical' = + bounceLevel === 'critical' || complaintLevel === 'critical' + ? 'critical' + : bounceLevel === 'warning' || complaintLevel === 'warning' + ? 'warning' + : 'healthy'; + const healthLabel = !hasDelivData ? 'No data yet' : worstLevel === 'healthy' ? 'Healthy' : worstLevel === 'warning' ? 'Watch' : 'Critical'; + const healthDot = + !hasDelivData ? 'bg-neutral-300' : worstLevel === 'healthy' ? 'bg-emerald-500' : worstLevel === 'warning' ? 'bg-amber-500' : 'bg-red-500'; + const healthText = + !hasDelivData ? 'text-neutral-500' : worstLevel === 'healthy' ? 'text-emerald-700' : worstLevel === 'warning' ? 'text-amber-700' : 'text-red-700'; + + const emailsTarget = nextMilestone(totalEmailsSent); + const prevMilestone = MILESTONES.filter(m => m <= totalEmailsSent).pop() ?? 0; + const milestoneProgress = + emailsTarget > prevMilestone + ? Math.min(100, ((totalEmailsSent - prevMilestone) / (emailsTarget - prevMilestone)) * 100) + : 0; + const showMilestone = !isLoading && totalEmailsSent > 0 && emailsTarget > totalEmailsSent; + async function handleResendVerification() { setIsResending(true); setResendMessage(''); @@ -82,6 +412,9 @@ export default function Index() { } } + const recentItems = recentActivity?.data ?? []; + const liveCount = recentCount?.count ?? 0; + return ( <> @@ -162,64 +495,228 @@ export default function Index() { )} {/* Header */} -
-

Dashboard

-
+ +
+

{greeting}

+

{subtitle}

+
+ +
{/* Stats Grid */}
- {stats.map(stat => { + {stats.map((stat, index) => { const Icon = stat.icon; + const isEmails = stat.name === 'Emails Sent'; + const isOpenRate = stat.name === 'Open Rate'; return ( - - -
- {stat.name} - -
- - {isLoading ? : stat.value} - -
-
+ + + +
+ {stat.name} +
+ +
+
+ + {isLoading ? ( + + ) : ( + + )} + + {isEmails && !isLoading && previousStats && } + {isOpenRate && !isLoading && previousStats && } +
+ {isEmails && showMilestone && ( +
+
+ + + Next milestone + + + {formatCompact(totalEmailsSent)} / {formatCompact(emailsTarget)} + +
+
+ +
+
+ )} +
+
); })} + + {/* Deliverability health */} + + + +
+ Deliverability +
+ + {hasDelivData && worstLevel === 'healthy' && ( + + )} + + + {healthLabel} +
+
+ + {!securityMetrics ? ( + + ) : hasDelivData ? ( + `${n.toFixed(1)}%`} /> + ) : ( + + )} + +

+ {hasDelivData ? 'delivered' : 'No emails sent yet'} + {hasDelivData && · {delivWindowLabel}} +

+
+ {hasDelivData && ( +
+
+ + + Bounce {bounceRate.toFixed(2)}% + + + + Complaint {complaintRate.toFixed(3)}% + +
+
+ )} +
+
- {/* Quick Actions & API Keys */} + {/* Quick Start + Recent Activity — 50/50 working area. + Falls back to full-width Recent Activity when the persistent + onboarding banner is suppressing Quick Start. + At lg+, Recent Activity is absolutely positioned inside its grid + cell so its natural content height does not influence the row + height. The row height is dictated by Quick Start, and Recent + Activity fills that height with the activity list scrolling + inside the card. */}
- {/* Quick Start — hidden when the persistent onboarding banner is guiding the user */} {!bannerActive && } - {/* API Keys */} - - - API Keys - Use these keys to integrate with Plunk - - -
- {activeProject ? ( - <> - - - +
+ + +
+
+ Recent activity + Live feed of what’s happening across your project +
+ +
+
+ + {!recentActivity ? ( +
+ {[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(i => ( +
+ +
+ + +
+ +
+ ))} +
+ ) : recentItems.length === 0 ? ( +
+
+ +
+

Nothing has happened yet

+

+ Send your first email or trigger an event and you’ll see it land here in real time. +

+
) : ( -

No project selected

+
+ + {recentItems.map(activity => ( + + ))} + +
)} -
- - + + +
+ + {/* API Keys — full-width slim band with the two keys side-by-side */} + + + API Keys + Use these keys to integrate with Plunk + + + {activeProject ? ( +
+ + +
+ ) : ( +

No project selected

+ )} +
+