feat: implement caching for recent activity count to optimize performance and reduce database load
This commit is contained in:
@@ -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)
|
* Get recent activity count (for real-time updates)
|
||||||
* Returns count of activities in the last N minutes
|
* 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<number> {
|
public static async getRecentActivityCount(projectId: string, minutes = 5): Promise<number> {
|
||||||
|
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 since = new Date(Date.now() - minutes * 60 * 1000);
|
||||||
const dateFilter: Prisma.DateTimeFilter = {gte: since};
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ export const Keys = {
|
|||||||
stats(projectId: string, startTime: number | string, endTime: number | string): string {
|
stats(projectId: string, startTime: number | string, endTime: number | string): string {
|
||||||
return `activity:stats:${projectId}:${startTime}:${endTime}`;
|
return `activity:stats:${projectId}:${startTime}:${endTime}`;
|
||||||
},
|
},
|
||||||
|
recentCount(projectId: string, minutes: number): string {
|
||||||
|
return `activity:recent-count:${projectId}:${minutes}`;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Analytics: {
|
Analytics: {
|
||||||
timeseries(projectId: string, startDate: string, endDate: string): string {
|
timeseries(projectId: string, startDate: string, endDate: string): string {
|
||||||
|
|||||||
+550
-53
@@ -10,10 +10,31 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
Skeleton,
|
Skeleton,
|
||||||
} from '@plunk/ui';
|
} 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 {NextSeo} from 'next-seo';
|
||||||
import Link from 'next/link';
|
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 {ApiKeyDisplay} from '../components/ApiKeyDisplay';
|
||||||
import {DashboardLayout} from '../components/DashboardLayout';
|
import {DashboardLayout} from '../components/DashboardLayout';
|
||||||
import {QuickStart} from '../components/QuickStart';
|
import {QuickStart} from '../components/QuickStart';
|
||||||
@@ -28,6 +49,207 @@ import {useConfig} from '../lib/hooks/useConfig';
|
|||||||
import {useUser} from '../lib/hooks/useUser';
|
import {useUser} from '../lib/hooks/useUser';
|
||||||
import {network} from '../lib/network';
|
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 (
|
||||||
|
<p className="mt-1 text-xs text-neutral-400 tabular-nums">{label ?? 'No data yet'}</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="mt-2 flex items-center gap-2 text-xs">
|
||||||
|
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 font-medium tabular-nums ${bg} ${color}`}>
|
||||||
|
<Icon className="h-3 w-3" strokeWidth={2.5} />
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
<span className="text-neutral-400">vs previous 30d</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <motion.span>{rounded}</motion.span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<ActivityVisual['tone'], {bg: string; fg: string}> = {
|
||||||
|
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 (
|
||||||
|
<div className="inline-flex items-center gap-2 rounded-full border border-neutral-200 bg-white px-3 py-1.5 text-xs font-medium text-neutral-700">
|
||||||
|
<span className="relative flex h-2 w-2">
|
||||||
|
{isLive && (
|
||||||
|
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={`relative inline-flex h-2 w-2 rounded-full ${isLive ? 'bg-emerald-500' : 'bg-neutral-300'}`}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span className="tabular-nums">
|
||||||
|
{isLive ? `${count.toLocaleString()} ${count === 1 ? 'event' : 'events'} in the last 5 min` : 'Quiet right now'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<motion.div
|
||||||
|
layout
|
||||||
|
initial={{opacity: 0, y: -8}}
|
||||||
|
animate={{opacity: 1, y: 0}}
|
||||||
|
exit={{opacity: 0, y: 8}}
|
||||||
|
transition={{duration: 0.35, ease: [0.22, 1, 0.36, 1]}}
|
||||||
|
className="flex items-center gap-3 rounded-lg px-2 py-2 transition-colors hover:bg-neutral-50"
|
||||||
|
>
|
||||||
|
<div className={`flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md ${tone.bg}`}>
|
||||||
|
<Icon className={`h-4 w-4 ${tone.fg}`} />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<p className="truncate text-sm font-medium text-neutral-900">{title}</p>
|
||||||
|
<span className="flex-shrink-0 text-[11px] text-neutral-400">{visual.label}</span>
|
||||||
|
</div>
|
||||||
|
{subtitle && <p className="truncate text-xs text-neutral-500">{subtitle}</p>}
|
||||||
|
</div>
|
||||||
|
<span className="flex-shrink-0 tabular-nums text-xs text-neutral-400">
|
||||||
|
{relativeTime(new Date(activity.timestamp))}
|
||||||
|
</span>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function Index() {
|
export default function Index() {
|
||||||
const {activeProject} = useActiveProject();
|
const {activeProject} = useActiveProject();
|
||||||
const {totalContacts, totalEmailsSent, totalCampaigns, openRate, isLoading} = useDashboardStats();
|
const {totalContacts, totalEmailsSent, totalCampaigns, openRate, isLoading} = useDashboardStats();
|
||||||
@@ -41,29 +263,137 @@ export default function Index() {
|
|||||||
const [isResending, setIsResending] = useState(false);
|
const [isResending, setIsResending] = useState(false);
|
||||||
const [resendMessage, setResendMessage] = useState<string>('');
|
const [resendMessage, setResendMessage] = useState<string>('');
|
||||||
|
|
||||||
|
// 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<ActivityStats>(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<CursorPaginatedResponse<Activity>>('/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? [email protected]',
|
||||||
|
'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 = [
|
const stats = [
|
||||||
{
|
{
|
||||||
name: 'Total Contacts',
|
name: 'Total Contacts',
|
||||||
value: totalContacts.toLocaleString(),
|
value: totalContacts,
|
||||||
icon: Users,
|
icon: Users,
|
||||||
|
format: (n: number) => n.toLocaleString(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Emails Sent',
|
name: 'Emails Sent',
|
||||||
value: totalEmailsSent.toLocaleString(),
|
value: totalEmailsSent,
|
||||||
icon: Mail,
|
icon: Mail,
|
||||||
},
|
format: (n: number) => n.toLocaleString(),
|
||||||
{
|
|
||||||
name: 'Campaigns',
|
|
||||||
value: totalCampaigns.toLocaleString(),
|
|
||||||
icon: Send,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Open Rate',
|
name: 'Open Rate',
|
||||||
value: `${openRate.toFixed(1)}%`,
|
value: openRate,
|
||||||
icon: TrendingUp,
|
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() {
|
async function handleResendVerification() {
|
||||||
setIsResending(true);
|
setIsResending(true);
|
||||||
setResendMessage('');
|
setResendMessage('');
|
||||||
@@ -82,6 +412,9 @@ export default function Index() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const recentItems = recentActivity?.data ?? [];
|
||||||
|
const liveCount = recentCount?.count ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<NextSeo title="Dashboard" />
|
<NextSeo title="Dashboard" />
|
||||||
@@ -162,64 +495,228 @@ export default function Index() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div>
|
<motion.div
|
||||||
<h1 className="text-2xl sm:text-3xl font-bold text-neutral-900">Dashboard</h1>
|
initial={{opacity: 0, y: 8}}
|
||||||
</div>
|
animate={{opacity: 1, y: 0}}
|
||||||
|
transition={{duration: 0.5, ease: [0.22, 1, 0.36, 1]}}
|
||||||
|
className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-bold text-neutral-900 tracking-tight">{greeting}</h1>
|
||||||
|
<p className="mt-1.5 text-sm text-neutral-500">{subtitle}</p>
|
||||||
|
</div>
|
||||||
|
<LivePulse count={liveCount} />
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
{/* Stats Grid */}
|
{/* Stats Grid */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
{stats.map(stat => {
|
{stats.map((stat, index) => {
|
||||||
const Icon = stat.icon;
|
const Icon = stat.icon;
|
||||||
|
const isEmails = stat.name === 'Emails Sent';
|
||||||
|
const isOpenRate = stat.name === 'Open Rate';
|
||||||
return (
|
return (
|
||||||
<Card key={stat.name}>
|
<motion.div
|
||||||
<CardHeader>
|
key={stat.name}
|
||||||
<div className="flex items-center justify-between">
|
initial={{opacity: 0, y: 12}}
|
||||||
<CardDescription>{stat.name}</CardDescription>
|
animate={{opacity: 1, y: 0}}
|
||||||
<Icon className="h-4 w-4 text-neutral-500" />
|
transition={{
|
||||||
</div>
|
duration: 0.5,
|
||||||
<CardTitle className="text-2xl tabular-nums">
|
delay: 0.05 + index * 0.06,
|
||||||
{isLoading ? <Skeleton className="h-7 w-16" /> : stat.value}
|
ease: [0.22, 1, 0.36, 1],
|
||||||
</CardTitle>
|
}}
|
||||||
</CardHeader>
|
whileHover={{y: -2}}
|
||||||
</Card>
|
className="group"
|
||||||
|
>
|
||||||
|
<Card className="relative overflow-hidden h-full transition-colors duration-200 hover:border-neutral-300">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardDescription>{stat.name}</CardDescription>
|
||||||
|
<div className="flex h-7 w-7 items-center justify-center rounded-md bg-neutral-50 border border-neutral-200/60 transition-colors duration-200 group-hover:bg-neutral-900 group-hover:border-neutral-900">
|
||||||
|
<Icon className="h-3.5 w-3.5 text-neutral-500 transition-colors duration-200 group-hover:text-white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-2xl tabular-nums">
|
||||||
|
{isLoading ? (
|
||||||
|
<Skeleton className="h-7 w-16" />
|
||||||
|
) : (
|
||||||
|
<AnimatedNumber value={stat.value} format={stat.format} />
|
||||||
|
)}
|
||||||
|
</CardTitle>
|
||||||
|
{isEmails && !isLoading && previousStats && <TrendChip trend={emailsTrend} />}
|
||||||
|
{isOpenRate && !isLoading && previousStats && <TrendChip trend={openRateTrend} />}
|
||||||
|
</CardHeader>
|
||||||
|
{isEmails && showMilestone && (
|
||||||
|
<div className="px-6 pb-4 -mt-1">
|
||||||
|
<div className="flex items-center justify-between text-[11px] text-neutral-500 mb-1.5">
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<Sparkles className="h-3 w-3 text-neutral-400" />
|
||||||
|
Next milestone
|
||||||
|
</span>
|
||||||
|
<span className="tabular-nums font-medium text-neutral-700">
|
||||||
|
{formatCompact(totalEmailsSent)} / {formatCompact(emailsTarget)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1 w-full overflow-hidden rounded-full bg-neutral-100">
|
||||||
|
<motion.div
|
||||||
|
className="h-full rounded-full bg-neutral-900"
|
||||||
|
initial={{width: 0}}
|
||||||
|
animate={{width: `${milestoneProgress}%`}}
|
||||||
|
transition={{duration: 1.2, ease: [0.22, 1, 0.36, 1], delay: 0.4}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{/* Deliverability health */}
|
||||||
|
<motion.div
|
||||||
|
initial={{opacity: 0, y: 12}}
|
||||||
|
animate={{opacity: 1, y: 0}}
|
||||||
|
transition={{duration: 0.5, delay: 0.05 + stats.length * 0.06, ease: [0.22, 1, 0.36, 1]}}
|
||||||
|
whileHover={{y: -2}}
|
||||||
|
className="group"
|
||||||
|
>
|
||||||
|
<Card className="relative overflow-hidden h-full transition-colors duration-200 hover:border-neutral-300">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardDescription>Deliverability</CardDescription>
|
||||||
|
<div className="inline-flex items-center gap-1.5 rounded-full bg-neutral-50 border border-neutral-200/60 px-2 py-0.5">
|
||||||
|
<span className="relative flex h-1.5 w-1.5">
|
||||||
|
{hasDelivData && worstLevel === 'healthy' && (
|
||||||
|
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-70" />
|
||||||
|
)}
|
||||||
|
<span className={`relative inline-flex h-1.5 w-1.5 rounded-full ${healthDot}`} />
|
||||||
|
</span>
|
||||||
|
<span className={`text-[11px] font-medium ${healthText}`}>{healthLabel}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-2xl tabular-nums">
|
||||||
|
{!securityMetrics ? (
|
||||||
|
<Skeleton className="h-7 w-20" />
|
||||||
|
) : hasDelivData ? (
|
||||||
|
<AnimatedNumber value={deliveryRate} format={n => `${n.toFixed(1)}%`} />
|
||||||
|
) : (
|
||||||
|
<span className="text-neutral-400">—</span>
|
||||||
|
)}
|
||||||
|
</CardTitle>
|
||||||
|
<p className="mt-1 text-xs text-neutral-500">
|
||||||
|
{hasDelivData ? 'delivered' : 'No emails sent yet'}
|
||||||
|
{hasDelivData && <span className="text-neutral-400"> · {delivWindowLabel}</span>}
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
{hasDelivData && (
|
||||||
|
<div className="px-6 pb-4 -mt-1">
|
||||||
|
<div className="flex items-center gap-4 text-[11px] text-neutral-500 tabular-nums">
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<ShieldCheck className="h-3 w-3 text-neutral-400" />
|
||||||
|
Bounce <span className="font-medium text-neutral-700">{bounceRate.toFixed(2)}%</span>
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<AlertCircle className="h-3 w-3 text-neutral-400" />
|
||||||
|
Complaint <span className="font-medium text-neutral-700">{complaintRate.toFixed(3)}%</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 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. */}
|
||||||
<div className={`grid grid-cols-1 gap-6 ${bannerActive ? '' : 'lg:grid-cols-2'}`}>
|
<div className={`grid grid-cols-1 gap-6 ${bannerActive ? '' : 'lg:grid-cols-2'}`}>
|
||||||
{/* Quick Start — hidden when the persistent onboarding banner is guiding the user */}
|
|
||||||
{!bannerActive && <QuickStart setupState={setupState} isLoading={isLoadingSetupState} />}
|
{!bannerActive && <QuickStart setupState={setupState} isLoading={isLoadingSetupState} />}
|
||||||
|
|
||||||
{/* API Keys */}
|
<div className={!bannerActive ? 'lg:relative' : ''}>
|
||||||
<Card>
|
<Card
|
||||||
<CardHeader>
|
className={`flex flex-col h-full ${
|
||||||
<CardTitle>API Keys</CardTitle>
|
!bannerActive ? 'lg:absolute lg:inset-0' : ''
|
||||||
<CardDescription>Use these keys to integrate with Plunk</CardDescription>
|
}`}
|
||||||
</CardHeader>
|
>
|
||||||
<CardContent>
|
<CardHeader>
|
||||||
<div className="space-y-4">
|
<div className="flex items-center justify-between">
|
||||||
{activeProject ? (
|
<div>
|
||||||
<>
|
<CardTitle>Recent activity</CardTitle>
|
||||||
<ApiKeyDisplay
|
<CardDescription>Live feed of what’s happening across your project</CardDescription>
|
||||||
label="Public Key"
|
</div>
|
||||||
value={activeProject.public}
|
<Button asChild variant="ghost" size="sm">
|
||||||
description="Use this key for client-side integrations"
|
<Link href="/activity">View all</Link>
|
||||||
/>
|
</Button>
|
||||||
<ApiKeyDisplay
|
</div>
|
||||||
label="Secret Key"
|
</CardHeader>
|
||||||
value={activeProject.secret}
|
<CardContent className="flex-1 min-h-0 overflow-y-auto">
|
||||||
description="Keep this key secure and never expose it publicly"
|
{!recentActivity ? (
|
||||||
isSecret
|
<div className="space-y-2">
|
||||||
/>
|
{[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(i => (
|
||||||
</>
|
<div key={i} className="flex items-center gap-3 px-2 py-2">
|
||||||
|
<Skeleton className="h-8 w-8 rounded-md" />
|
||||||
|
<div className="flex-1 space-y-1.5">
|
||||||
|
<Skeleton className="h-3.5 w-2/5" />
|
||||||
|
<Skeleton className="h-3 w-1/4" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-3 w-12" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : recentItems.length === 0 ? (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center gap-2 py-10 text-center">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-neutral-100">
|
||||||
|
<Inbox className="h-5 w-5 text-neutral-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium text-neutral-700">Nothing has happened yet</p>
|
||||||
|
<p className="text-xs text-neutral-500 max-w-xs">
|
||||||
|
Send your first email or trigger an event and you’ll see it land here in real time.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-neutral-500">No project selected</p>
|
<div className="space-y-0.5">
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{recentItems.map(activity => (
|
||||||
|
<CompactActivityRow key={activity.id} activity={activity} />
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</CardContent>
|
||||||
</CardContent>
|
</Card>
|
||||||
</Card>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* API Keys — full-width slim band with the two keys side-by-side */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>API Keys</CardTitle>
|
||||||
|
<CardDescription>Use these keys to integrate with Plunk</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{activeProject ? (
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 md:gap-6">
|
||||||
|
<ApiKeyDisplay
|
||||||
|
label="Public Key"
|
||||||
|
value={activeProject.public}
|
||||||
|
description="Use this key for client-side integrations"
|
||||||
|
/>
|
||||||
|
<ApiKeyDisplay
|
||||||
|
label="Secret Key"
|
||||||
|
value={activeProject.secret}
|
||||||
|
description="Keep this key secure and never expose it publicly"
|
||||||
|
isSecret
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-neutral-500">No project selected</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user