feat: implement caching for recent activity count to optimize performance and reduce database load

This commit is contained in:
Dries Augustyns
2026-05-10 11:37:31 +02:00
parent de6335e999
commit f22da4add1
3 changed files with 585 additions and 54 deletions
+32 -1
View File
@@ -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<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 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;
}
/**
+3
View File
@@ -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 {
+550 -53
View File
@@ -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 (
<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() {
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<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 = [
{
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 (
<>
<NextSeo title="Dashboard" />
@@ -162,64 +495,228 @@ export default function Index() {
)}
{/* Header */}
<div>
<h1 className="text-2xl sm:text-3xl font-bold text-neutral-900">Dashboard</h1>
</div>
<motion.div
initial={{opacity: 0, y: 8}}
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 */}
<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 isEmails = stat.name === 'Emails Sent';
const isOpenRate = stat.name === 'Open Rate';
return (
<Card key={stat.name}>
<CardHeader>
<div className="flex items-center justify-between">
<CardDescription>{stat.name}</CardDescription>
<Icon className="h-4 w-4 text-neutral-500" />
</div>
<CardTitle className="text-2xl tabular-nums">
{isLoading ? <Skeleton className="h-7 w-16" /> : stat.value}
</CardTitle>
</CardHeader>
</Card>
<motion.div
key={stat.name}
initial={{opacity: 0, y: 12}}
animate={{opacity: 1, y: 0}}
transition={{
duration: 0.5,
delay: 0.05 + index * 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>{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>
{/* 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'}`}>
{/* Quick Start — hidden when the persistent onboarding banner is guiding the user */}
{!bannerActive && <QuickStart setupState={setupState} isLoading={isLoadingSetupState} />}
{/* API Keys */}
<Card>
<CardHeader>
<CardTitle>API Keys</CardTitle>
<CardDescription>Use these keys to integrate with Plunk</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{activeProject ? (
<>
<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 className={!bannerActive ? 'lg:relative' : ''}>
<Card
className={`flex flex-col h-full ${
!bannerActive ? 'lg:absolute lg:inset-0' : ''
}`}
>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Recent activity</CardTitle>
<CardDescription>Live feed of whats happening across your project</CardDescription>
</div>
<Button asChild variant="ghost" size="sm">
<Link href="/activity">View all</Link>
</Button>
</div>
</CardHeader>
<CardContent className="flex-1 min-h-0 overflow-y-auto">
{!recentActivity ? (
<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 youll 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>
</Card>
</CardContent>
</Card>
</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>
</DashboardLayout>
</>