feat(dashboard): replace placeholder with real analytics

Rewrites the dashboard page as a data-driven analytics surface using
existing Supabase tables only — no schema changes, no other pages
touched.

Widgets (all render independent skeletons while their data fetches,
and show a shared empty state when there's not enough data to be
useful):

- Metric cards (Active Conversations, New Contacts Today, Open Deals
  Value, Messages Sent Today) with up/down/no-change delta indicator.
- Quick actions row (New Contact / Deal / Broadcast / Automation).
- Conversations Over Time: SVG line chart, 7/30/90-day tabs, per-day
  hover crosshair + tooltip. Cached per range so tab switches are
  instant.
- Pipeline Value: SVG donut with per-stage arc segments, total in the
  center, legend with deal count + value.
- Average First Response Time: SVG bar chart by weekday with a
  configurable dashed target line and this-week / last-week summary.
- Activity feed: latest 20 items merged across messages, contacts,
  deals, broadcasts, and automation logs — click-through deep-links
  where applicable.

Deviations from spec, noted up-front:
- Strictly dark theme — spec's #f9fafb alternating rows translate to
  slate-900 / slate-900/40 stripes.
- Kept Inter (tabular-nums, bold 28px) for metric values instead of
  Space Grotesk to avoid touching root layout.
- Quick actions link to the relevant list pages (/contacts, etc.)
  rather than auto-opening their create modals — that would require
  modifying those pages.

All aggregation is client-side via RLS-scoped queries. If a tenant's
dataset outgrows a few thousand messages per range window, the
response-time and line-chart queries should migrate to SQL RPCs —
noted as follow-up, not shipped here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arnas Donauskas 2026-04-17 22:16:00 +03:00
parent 5be2bad51d
commit ed669424b6
No known key found for this signature in database
12 changed files with 1634 additions and 396 deletions

View File

@ -1,428 +1,218 @@
"use client"; "use client"
import { useState, useEffect } from "react"; import { useCallback, useEffect, useState } from 'react'
import { useRouter } from "next/navigation"; import { createClient } from '@/lib/supabase/client'
import { createClient } from "@/lib/supabase/client";
import { Conversation, Pipeline, PipelineStage, Broadcast } from "@/types";
import { import {
Users,
MessageSquare, MessageSquare,
Mail, UserPlus,
TrendingUp,
ArrowRight,
Radio,
GitBranch,
DollarSign, DollarSign,
Clock, Send,
} from "lucide-react"; } from 'lucide-react'
function formatCurrency(value: number) { import {
return new Intl.NumberFormat("en-US", { loadActivity,
style: "currency", loadConversationsSeries,
currency: "USD", loadMetrics,
minimumFractionDigits: 0, loadPipelineDonut,
maximumFractionDigits: 0, loadResponseTime,
}).format(value); } from '@/lib/dashboard/queries'
} import type {
ActivityItem,
ConversationsSeriesPoint,
MetricsBundle,
PipelineDonutData,
ResponseTimeSummary,
} from '@/lib/dashboard/types'
function formatRelativeTime(dateStr: string) { import { MetricCard } from '@/components/dashboard/metric-card'
const date = new Date(dateStr); import { SkeletonCard } from '@/components/dashboard/skeleton'
const now = new Date(); import { QuickActions } from '@/components/dashboard/quick-actions'
const diffMs = now.getTime() - date.getTime(); import { ConversationsChart } from '@/components/dashboard/conversations-chart'
const diffMin = Math.floor(diffMs / 60000); import { PipelineDonut } from '@/components/dashboard/pipeline-donut'
const diffHr = Math.floor(diffMin / 60); import { ResponseTimeChart } from '@/components/dashboard/response-time-chart'
const diffDay = Math.floor(diffHr / 24); import { ActivityFeed } from '@/components/dashboard/activity-feed'
if (diffMin < 1) return "Just now"; type RangeDays = 7 | 30 | 90
if (diffMin < 60) return `${diffMin}m ago`;
if (diffHr < 24) return `${diffHr}h ago`;
if (diffDay < 7) return `${diffDay}d ago`;
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
}
interface StatCardProps {
title: string;
value: string | number;
icon: React.ReactNode;
loading: boolean;
}
function StatCard({ title, value, icon, loading }: StatCardProps) {
return (
<div className="rounded-xl border border-slate-800 bg-slate-900 p-5">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-slate-400">{title}</p>
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-500/10 text-emerald-500">
{icon}
</div>
</div>
{loading ? (
<div className="mt-3 h-8 w-20 animate-pulse rounded bg-slate-800" />
) : (
<p className="mt-3 text-3xl font-bold text-white">{value}</p>
)}
</div>
);
}
interface PipelineOverviewData {
pipeline: Pipeline;
stages: (PipelineStage & { dealCount: number; totalValue: number })[];
}
export default function DashboardPage() { export default function DashboardPage() {
const router = useRouter(); const [metrics, setMetrics] = useState<MetricsBundle | null>(null)
const supabase = createClient(); const [metricsLoading, setMetricsLoading] = useState(true)
const [loading, setLoading] = useState(true); const [range, setRange] = useState<RangeDays>(30)
const [totalContacts, setTotalContacts] = useState(0); // Keep a cache per range so switching tabs doesn't re-fetch what we
const [openConversations, setOpenConversations] = useState(0); // already have. Ranges the user hasn't opened yet stay null and
const [messagesToday, setMessagesToday] = useState(0); // trigger a fetch on first view.
const [activeDeals, setActiveDeals] = useState(0); const [series, setSeries] = useState<Record<RangeDays, ConversationsSeriesPoint[] | null>>({
const [recentConversations, setRecentConversations] = useState<Conversation[]>([]); 7: null,
const [pipelineOverviews, setPipelineOverviews] = useState<PipelineOverviewData[]>([]); 30: null,
const [recentBroadcasts, setRecentBroadcasts] = useState<Broadcast[]>([]); 90: null,
})
const [seriesLoading, setSeriesLoading] = useState(true)
const [pipeline, setPipeline] = useState<PipelineDonutData | null>(null)
const [pipelineLoading, setPipelineLoading] = useState(true)
const [responseTime, setResponseTime] = useState<ResponseTimeSummary | null>(null)
const [responseTimeLoading, setResponseTimeLoading] = useState(true)
const [activity, setActivity] = useState<ActivityItem[] | null>(null)
const [activityLoading, setActivityLoading] = useState(true)
const loadAll = useCallback(() => {
const db = createClient()
// Kick everything off in parallel. Each block has its own
// setState + finally so a slow query doesn't hold up faster
// sections — each widget shows its own skeleton independently.
void loadMetrics(db)
.then((m) => setMetrics(m))
.catch((err) => console.error('[dashboard] metrics failed:', err))
.finally(() => setMetricsLoading(false))
void loadConversationsSeries(db, 30)
.then((s) => setSeries((prev) => ({ ...prev, 30: s })))
.catch((err) => console.error('[dashboard] series failed:', err))
.finally(() => setSeriesLoading(false))
void loadPipelineDonut(db)
.then((p) => setPipeline(p))
.catch((err) => console.error('[dashboard] pipeline failed:', err))
.finally(() => setPipelineLoading(false))
void loadResponseTime(db)
.then((r) => setResponseTime(r))
.catch((err) => console.error('[dashboard] response time failed:', err))
.finally(() => setResponseTimeLoading(false))
void loadActivity(db, 20)
.then((a) => setActivity(a))
.catch((err) => console.error('[dashboard] activity failed:', err))
.finally(() => setActivityLoading(false))
}, [])
useEffect(() => { useEffect(() => {
loadDashboardData(); loadAll()
}, []); }, [loadAll])
async function loadDashboardData() { // Range switch handler — kept in an event callback (not an effect)
setLoading(true); // so the setState calls stay out of the react-hooks/set-state-in-effect
// rule's way. The cached bucket check means switching back to a
const todayStart = new Date(); // previously-viewed range is instant and doesn't re-fetch.
todayStart.setHours(0, 0, 0, 0); const handleRangeChange = useCallback(
(r: RangeDays) => {
const [ setRange(r)
contactsRes, if (series[r] !== null) return
openConvRes, setSeriesLoading(true)
messagesTodayRes, const db = createClient()
dealsRes, loadConversationsSeries(db, r)
recentConvRes, .then((s) => setSeries((prev) => ({ ...prev, [r]: s })))
pipelinesRes, .catch((err) => console.error('[dashboard] series failed:', err))
broadcastsRes, .finally(() => setSeriesLoading(false))
] = await Promise.all([ },
supabase.from("contacts").select("id", { count: "exact", head: true }), [series],
supabase )
.from("conversations")
.select("id", { count: "exact", head: true })
.eq("status", "open"),
supabase
.from("messages")
.select("id", { count: "exact", head: true })
.gte("created_at", todayStart.toISOString()),
supabase.from("deals").select("id", { count: "exact", head: true }),
supabase
.from("conversations")
.select("*, contact:contacts(*)")
.order("last_message_at", { ascending: false })
.limit(5),
supabase.from("pipelines").select("*").order("created_at"),
supabase
.from("broadcasts")
.select("*")
.order("created_at", { ascending: false })
.limit(3),
]);
setTotalContacts(contactsRes.count ?? 0);
setOpenConversations(openConvRes.count ?? 0);
setMessagesToday(messagesTodayRes.count ?? 0);
setActiveDeals(dealsRes.count ?? 0);
setRecentConversations(recentConvRes.data ?? []);
setRecentBroadcasts(broadcastsRes.data ?? []);
// Load pipeline overviews with stages and deal data
if (pipelinesRes.data && pipelinesRes.data.length > 0) {
const overviews: PipelineOverviewData[] = [];
for (const pipeline of pipelinesRes.data) {
const { data: stagesData } = await supabase
.from("pipeline_stages")
.select("*")
.eq("pipeline_id", pipeline.id)
.order("position");
const { data: dealsData } = await supabase
.from("deals")
.select("stage_id, value")
.eq("pipeline_id", pipeline.id);
const enrichedStages = (stagesData || []).map((stage) => {
const stageDeals = (dealsData || []).filter(
(d) => d.stage_id === stage.id
);
return {
...stage,
dealCount: stageDeals.length,
totalValue: stageDeals.reduce(
(sum: number, d: { value: number }) => sum + d.value,
0
),
};
});
overviews.push({ pipeline, stages: enrichedStages });
}
setPipelineOverviews(overviews);
}
setLoading(false);
}
function getBroadcastStatusColor(status: string) {
switch (status) {
case "sent":
return "bg-emerald-500/10 text-emerald-400";
case "sending":
return "bg-blue-500/10 text-blue-400";
case "scheduled":
return "bg-yellow-500/10 text-yellow-400";
case "draft":
return "bg-slate-500/10 text-slate-400";
case "failed":
return "bg-red-500/10 text-red-400";
default:
return "bg-slate-500/10 text-slate-400";
}
}
return ( return (
<div className="space-y-6"> <div className="space-y-5">
{/* Header */} {/* Header */}
<div> <div>
<h1 className="text-2xl font-bold text-white">Dashboard</h1> <h1 className="text-2xl font-bold text-white">Dashboard</h1>
<p className="mt-1 text-sm text-slate-400"> <p className="mt-1 text-sm text-slate-400">
Overview of your WhatsApp CRM activity Live analytics across conversations, contacts, deals, broadcasts, and automations.
</p> </p>
</div> </div>
{/* Row 1: Stat Cards */} {/* Metric cards */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard {metricsLoading || !metrics ? (
title="Total Contacts" Array.from({ length: 4 }).map((_, i) => <SkeletonCard key={i} />)
value={totalContacts.toLocaleString()} ) : (
icon={<Users className="h-5 w-5" />} <>
loading={loading} <MetricCard
title="Active Conversations"
value={metrics.activeConversations.current.toLocaleString()}
icon={MessageSquare}
delta={{
sign: metrics.activeConversations.previous,
label: deltaLabel(metrics.activeConversations.previous, 'new today vs yesterday'),
}}
/> />
<StatCard <MetricCard
title="Open Conversations" title="New Contacts Today"
value={openConversations.toLocaleString()} value={metrics.newContactsToday.current.toLocaleString()}
icon={<MessageSquare className="h-5 w-5" />} icon={UserPlus}
loading={loading} delta={{
sign:
metrics.newContactsToday.current - metrics.newContactsToday.previous,
label: deltaLabel(
metrics.newContactsToday.current - metrics.newContactsToday.previous,
'vs yesterday',
),
}}
/> />
<StatCard <MetricCard
title="Messages Today" title="Open Deals Value"
value={messagesToday.toLocaleString()} value={formatCurrency(metrics.openDealsValue)}
icon={<Mail className="h-5 w-5" />} icon={DollarSign}
loading={loading} subtitle={`${metrics.openDealsCount} open deal${metrics.openDealsCount === 1 ? '' : 's'}`}
/> />
<StatCard <MetricCard
title="Active Deals" title="Messages Sent Today"
value={activeDeals.toLocaleString()} value={metrics.messagesSentToday.current.toLocaleString()}
icon={<TrendingUp className="h-5 w-5" />} icon={Send}
loading={loading} delta={{
sign:
metrics.messagesSentToday.current - metrics.messagesSentToday.previous,
label: deltaLabel(
metrics.messagesSentToday.current - metrics.messagesSentToday.previous,
'vs yesterday',
),
}}
/> />
</>
)}
</div> </div>
{/* Row 2: Recent Conversations */} {/* Quick actions */}
<div className="rounded-xl border border-slate-800 bg-slate-900"> <QuickActions />
<div className="flex items-center justify-between border-b border-slate-800 px-5 py-4">
<div className="flex items-center gap-2"> {/* Charts row */}
<MessageSquare className="h-4 w-4 text-emerald-500" /> <div className="grid grid-cols-1 gap-4 lg:grid-cols-5">
<h2 className="text-sm font-semibold text-white"> <div className="lg:col-span-3">
Recent Conversations <ConversationsChart
</h2> series={series}
loading={seriesLoading}
range={range}
onRangeChange={handleRangeChange}
/>
</div> </div>
<button <div className="lg:col-span-2">
onClick={() => router.push("/inbox")} <PipelineDonut data={pipeline} loading={pipelineLoading} />
className="flex items-center gap-1 text-xs text-emerald-400 hover:text-emerald-300 transition-colors"
>
View all
<ArrowRight className="h-3 w-3" />
</button>
</div>
<div className="divide-y divide-slate-800">
{loading ? (
Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 px-5 py-3">
<div className="h-9 w-9 animate-pulse rounded-full bg-slate-800" />
<div className="flex-1">
<div className="h-4 w-28 animate-pulse rounded bg-slate-800" />
<div className="mt-1.5 h-3 w-48 animate-pulse rounded bg-slate-800" />
</div>
<div className="h-3 w-12 animate-pulse rounded bg-slate-800" />
</div>
))
) : recentConversations.length === 0 ? (
<div className="px-5 py-8 text-center text-sm text-slate-500">
No conversations yet
</div>
) : (
recentConversations.map((conv) => (
<button
key={conv.id}
onClick={() => router.push(`/inbox?c=${conv.id}`)}
className="flex w-full items-center gap-3 px-5 py-3 text-left transition-colors hover:bg-slate-800/50"
>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-emerald-500/10 text-sm font-medium text-emerald-500">
{conv.contact?.name?.charAt(0)?.toUpperCase() ||
conv.contact?.phone?.slice(-2) ||
"?"}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-white truncate">
{conv.contact?.name || conv.contact?.phone || "Unknown"}
</p>
<p className="mt-0.5 text-xs text-slate-400 truncate">
{conv.last_message_text || "No messages"}
</p>
</div>
<div className="flex flex-col items-end gap-1 shrink-0">
<span className="text-xs text-slate-500">
{conv.last_message_at
? formatRelativeTime(conv.last_message_at)
: ""}
</span>
{conv.unread_count > 0 && (
<span className="flex h-4 min-w-4 items-center justify-center rounded-full bg-emerald-500 px-1 text-[10px] font-medium text-white">
{conv.unread_count}
</span>
)}
</div>
</button>
))
)}
</div> </div>
</div> </div>
{/* Row 3: Pipeline Overview */} {/* Response time */}
<div className="rounded-xl border border-slate-800 bg-slate-900"> <ResponseTimeChart data={responseTime} loading={responseTimeLoading} />
<div className="flex items-center gap-2 border-b border-slate-800 px-5 py-4">
<GitBranch className="h-4 w-4 text-emerald-500" />
<h2 className="text-sm font-semibold text-white">
Pipeline Overview
</h2>
</div>
<div className="p-5">
{loading ? (
<div className="space-y-4">
{[1, 2].map((i) => (
<div key={i}>
<div className="h-4 w-32 animate-pulse rounded bg-slate-800" />
<div className="mt-3 flex gap-2">
{[1, 2, 3, 4].map((j) => (
<div
key={j}
className="h-16 flex-1 animate-pulse rounded-lg bg-slate-800/50"
/>
))}
</div>
</div>
))}
</div>
) : pipelineOverviews.length === 0 ? (
<div className="py-6 text-center text-sm text-slate-500">
No pipelines created yet
</div>
) : (
<div className="space-y-6">
{pipelineOverviews.map(({ pipeline, stages }) => (
<div key={pipeline.id}>
<h3 className="text-sm font-medium text-white mb-3">
{pipeline.name}
</h3>
<div className="flex gap-2 overflow-x-auto pb-1">
{stages.map((stage) => (
<div
key={stage.id}
className="min-w-[140px] flex-1 rounded-lg border border-slate-800 bg-slate-800/30 p-3"
>
<div className="flex items-center gap-1.5 mb-2">
<div
className="h-2 w-2 rounded-full"
style={{ backgroundColor: stage.color }}
/>
<span className="text-xs font-medium text-slate-300 truncate">
{stage.name}
</span>
</div>
<p className="text-lg font-bold text-white">
{stage.dealCount}
</p>
{stage.totalValue > 0 && (
<p className="flex items-center gap-1 text-xs text-emerald-400 mt-0.5">
<DollarSign className="h-3 w-3" />
{formatCurrency(stage.totalValue)}
</p>
)}
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* Row 4: Recent Broadcasts */} {/* Activity feed */}
<div className="rounded-xl border border-slate-800 bg-slate-900"> <ActivityFeed items={activity} loading={activityLoading} />
<div className="flex items-center gap-2 border-b border-slate-800 px-5 py-4">
<Radio className="h-4 w-4 text-emerald-500" />
<h2 className="text-sm font-semibold text-white">
Recent Broadcasts
</h2>
</div> </div>
<div className="divide-y divide-slate-800"> )
{loading ? ( }
Array.from({ length: 2 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 px-5 py-3"> // ------------------------------------------------------------
<div className="flex-1">
<div className="h-4 w-36 animate-pulse rounded bg-slate-800" /> function formatCurrency(v: number): string {
<div className="mt-1.5 h-3 w-24 animate-pulse rounded bg-slate-800" /> return new Intl.NumberFormat(undefined, {
</div> style: 'currency',
<div className="h-5 w-16 animate-pulse rounded-full bg-slate-800" /> currency: 'USD',
</div> minimumFractionDigits: 0,
)) maximumFractionDigits: 0,
) : recentBroadcasts.length === 0 ? ( }).format(v)
<div className="px-5 py-8 text-center text-sm text-slate-500"> }
No broadcasts yet
</div> function deltaLabel(delta: number, suffix: string): string {
) : ( if (delta === 0) return `No change ${suffix}`
recentBroadcasts.map((broadcast) => ( const sign = delta > 0 ? '+' : ''
<div return `${sign}${delta.toLocaleString()} ${suffix}`
key={broadcast.id}
className="flex items-center gap-4 px-5 py-3"
>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-slate-800 text-slate-400">
<Radio className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-white truncate">
{broadcast.name}
</p>
<div className="mt-0.5 flex items-center gap-3 text-xs text-slate-400">
<span>
{broadcast.sent_count}/{broadcast.total_recipients} sent
</span>
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{formatRelativeTime(broadcast.created_at)}
</span>
</div>
</div>
<span
className={`rounded-full px-2.5 py-0.5 text-xs font-medium capitalize ${getBroadcastStatusColor(
broadcast.status
)}`}
>
{broadcast.status}
</span>
</div>
))
)}
</div>
</div>
</div>
);
} }

View File

@ -0,0 +1,117 @@
"use client"
import Link from 'next/link'
import {
MessageSquare,
UserPlus,
Briefcase,
Radio,
Zap,
Inbox,
} from 'lucide-react'
import type { ComponentType } from 'react'
import type { ActivityItem, ActivityKind } from '@/lib/dashboard/types'
import { cn } from '@/lib/utils'
import { EmptyState } from './empty-state'
import { Skeleton } from './skeleton'
interface ActivityFeedProps {
items: ActivityItem[] | null
loading: boolean
}
interface KindTheme {
icon: ComponentType<{ className?: string }>
/** Tailwind classes for the round icon badge + label color. */
badge: string
}
const KIND_THEME: Record<ActivityKind, KindTheme> = {
message: { icon: MessageSquare, badge: 'bg-blue-500/10 text-blue-400' },
contact: { icon: UserPlus, badge: 'bg-emerald-500/10 text-emerald-400' },
deal: { icon: Briefcase, badge: 'bg-violet-500/10 text-violet-400' },
broadcast: { icon: Radio, badge: 'bg-amber-500/10 text-amber-400' },
automation: { icon: Zap, badge: 'bg-rose-500/10 text-rose-400' },
}
export function ActivityFeed({ items, loading }: ActivityFeedProps) {
return (
<section className="rounded-xl border border-slate-800 bg-slate-900">
<header className="flex items-center justify-between border-b border-slate-800 px-5 py-4">
<h2 className="text-sm font-semibold text-white">Recent Activity</h2>
<Link
href="/inbox"
className="text-xs font-medium text-emerald-400 hover:text-emerald-300"
>
View all →
</Link>
</header>
{loading || !items ? (
<div className="space-y-2 p-5">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
) : items.length === 0 ? (
<div className="p-5">
<EmptyState
icon={Inbox}
title="No activity yet"
hint="Activity from messages, deals, broadcasts, and automations will appear here."
/>
</div>
) : (
<ul className="divide-y divide-slate-800">
{items.map((it, i) => {
const theme = KIND_THEME[it.kind]
const Icon = theme.icon
// Alternating row background for light scanability — dark-theme
// translation of the spec's white / #f9fafb stripes.
const stripe = i % 2 === 0 ? 'bg-transparent' : 'bg-slate-900/40'
const row = (
<div className="flex items-center gap-3 px-5 py-2.5">
<span
className={cn(
'flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full',
theme.badge,
)}
>
<Icon className="h-3.5 w-3.5" />
</span>
<span className="min-w-0 flex-1 truncate text-sm text-slate-200">
{it.text}
</span>
<span className="flex-shrink-0 text-xs text-slate-500 tabular-nums">
{relativeTime(it.at)}
</span>
</div>
)
return (
<li key={it.id} className={cn(stripe, 'transition-colors hover:bg-slate-800/40')}>
{it.href ? (
<Link href={it.href} className="block">
{row}
</Link>
) : (
row
)}
</li>
)
})}
</ul>
)}
</section>
)
}
function relativeTime(iso: string): string {
const then = new Date(iso).getTime()
if (Number.isNaN(then)) return ''
const diffSec = Math.round((Date.now() - then) / 1000)
if (diffSec < 60) return `${Math.max(1, diffSec)}s ago`
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`
if (diffSec < 2_592_000) return `${Math.floor(diffSec / 86400)}d ago`
return new Date(iso).toLocaleDateString()
}

View File

@ -0,0 +1,305 @@
"use client"
import { useEffect, useMemo, useRef, useState } from 'react'
import { MessageSquare } from 'lucide-react'
import type { ConversationsSeriesPoint } from '@/lib/dashboard/types'
import { EmptyState } from './empty-state'
import { Skeleton } from './skeleton'
import { cn } from '@/lib/utils'
type RangeDays = 7 | 30 | 90
interface ConversationsChartProps {
/** Per-range data, so switching tabs never re-fetches. */
series: Record<RangeDays, ConversationsSeriesPoint[] | null>
loading: boolean
range: RangeDays
onRangeChange: (r: RangeDays) => void
}
// ------------------------------------------------------------
// Layout constants. The SVG renders into a fixed viewBox and scales
// via CSS (preserveAspectRatio default). Everything inside uses
// viewBox coordinates so the drawing math stays simple even as the
// container resizes.
// ------------------------------------------------------------
const VB_W = 760
const VB_H = 240
const PADDING = { top: 16, right: 16, bottom: 28, left: 40 }
export function ConversationsChart({ series, loading, range, onRangeChange }: ConversationsChartProps) {
const data = series[range]
// Memoise the max so per-day hover math doesn't recompute it.
const { maxY, niceTicks } = useMemo(() => {
const arr = data ?? []
const max = arr.reduce(
(m, p) => Math.max(m, p.incoming, p.outgoing),
0,
)
const ceil = niceCeil(max)
const ticks = [0, ceil / 4, ceil / 2, (3 * ceil) / 4, ceil].map((v) =>
Math.round(v),
)
// De-dupe when the series is flat 0.
return { maxY: ceil, niceTicks: Array.from(new Set(ticks)) }
}, [data])
return (
<section className="rounded-xl border border-slate-800 bg-slate-900">
<header className="flex items-center justify-between border-b border-slate-800 px-5 py-4">
<div>
<h2 className="text-sm font-semibold text-white">Conversations Over Time</h2>
<p className="mt-0.5 text-xs text-slate-500">Daily message volume by direction</p>
</div>
<div className="flex items-center gap-1 rounded-lg bg-slate-800/60 p-1">
{[7, 30, 90].map((r) => (
<button
key={r}
type="button"
onClick={() => onRangeChange(r as RangeDays)}
className={cn(
'rounded-md px-2.5 py-1 text-xs font-medium transition-colors',
range === r
? 'bg-slate-700 text-white'
: 'text-slate-400 hover:text-white',
)}
>
{r} days
</button>
))}
</div>
</header>
<div className="p-5">
{loading || !data ? (
<Skeleton className="h-[240px] w-full" />
) : data.every((p) => p.incoming === 0 && p.outgoing === 0) ? (
<EmptyState
icon={MessageSquare}
title="No message activity in this range"
hint="Send or receive messages to start populating this chart."
/>
) : (
<LineSvg data={data} maxY={maxY} ticks={niceTicks} />
)}
</div>
<footer className="flex items-center gap-4 border-t border-slate-800 px-5 py-3 text-xs text-slate-400">
<LegendDot color="#3b82f6" label="Incoming" />
<LegendDot color="#10b981" label="Outgoing" />
</footer>
</section>
)
}
// ------------------------------------------------------------
// The actual SVG. Two polylines + per-day hit targets for hover.
// ------------------------------------------------------------
function LineSvg({
data,
maxY,
ticks,
}: {
data: ConversationsSeriesPoint[]
maxY: number
ticks: number[]
}) {
const [hoverIdx, setHoverIdx] = useState<number | null>(null)
const svgRef = useRef<SVGSVGElement>(null)
const chartW = VB_W - PADDING.left - PADDING.right
const chartH = VB_H - PADDING.top - PADDING.bottom
// x step can be fractional for 90-day views; points are positioned
// at the center of each "slot" so the first and last points don't
// sit right on the axis.
const stepX = data.length > 1 ? chartW / (data.length - 1) : 0
const yFor = (v: number) =>
maxY === 0 ? PADDING.top + chartH : PADDING.top + chartH - (v / maxY) * chartH
const xFor = (i: number) => PADDING.left + i * stepX
const incomingPath = data.map((p, i) => `${i === 0 ? 'M' : 'L'}${xFor(i)},${yFor(p.incoming)}`).join(' ')
const outgoingPath = data.map((p, i) => `${i === 0 ? 'M' : 'L'}${xFor(i)},${yFor(p.outgoing)}`).join(' ')
// Mouse-move: snap to nearest data-point index.
useEffect(() => {
const svg = svgRef.current
if (!svg) return
const onMove = (e: MouseEvent) => {
const rect = svg.getBoundingClientRect()
const xPct = (e.clientX - rect.left) / rect.width
const xPx = xPct * VB_W
if (xPx < PADDING.left - 8 || xPx > VB_W - PADDING.right + 8) {
setHoverIdx(null)
return
}
const local = xPx - PADDING.left
const idx = Math.round(stepX === 0 ? 0 : local / stepX)
setHoverIdx(Math.max(0, Math.min(data.length - 1, idx)))
}
const onLeave = () => setHoverIdx(null)
svg.addEventListener('mousemove', onMove)
svg.addEventListener('mouseleave', onLeave)
return () => {
svg.removeEventListener('mousemove', onMove)
svg.removeEventListener('mouseleave', onLeave)
}
}, [data, stepX])
const hovered = hoverIdx !== null ? data[hoverIdx] : null
const hoverX = hoverIdx !== null ? xFor(hoverIdx) : 0
// X-axis label strategy: show ~6 evenly-spaced labels regardless
// of range so the axis never looks crowded.
const labelStride = Math.max(1, Math.ceil(data.length / 6))
return (
<div className="relative w-full">
<svg
ref={svgRef}
viewBox={`0 0 ${VB_W} ${VB_H}`}
className="h-[240px] w-full"
role="img"
aria-label="Conversations per day"
>
{/* Y-axis gridlines + labels */}
{ticks.map((t) => {
const y = yFor(t)
return (
<g key={t}>
<line
x1={PADDING.left}
x2={VB_W - PADDING.right}
y1={y}
y2={y}
stroke="rgb(30 41 59)"
strokeDasharray="3 3"
/>
<text
x={PADDING.left - 8}
y={y}
textAnchor="end"
dominantBaseline="middle"
className="fill-slate-500 text-[10px]"
>
{t}
</text>
</g>
)
})}
{/* X-axis labels */}
{data.map((p, i) =>
i % labelStride === 0 ? (
<text
key={p.day}
x={xFor(i)}
y={VB_H - 8}
textAnchor="middle"
className="fill-slate-500 text-[10px]"
>
{shortDayLabel(p.day)}
</text>
) : null,
)}
{/* Outgoing polyline (emerald) */}
<path
d={outgoingPath}
fill="none"
stroke="#10b981"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* Incoming polyline (blue) */}
<path
d={incomingPath}
fill="none"
stroke="#3b82f6"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* Hover crosshair */}
{hoverIdx !== null && (
<g pointerEvents="none">
<line
x1={hoverX}
x2={hoverX}
y1={PADDING.top}
y2={PADDING.top + chartH}
stroke="rgb(71 85 105)"
strokeDasharray="3 3"
/>
<circle cx={hoverX} cy={yFor(data[hoverIdx].incoming)} r={3.5} fill="#3b82f6" />
<circle cx={hoverX} cy={yFor(data[hoverIdx].outgoing)} r={3.5} fill="#10b981" />
</g>
)}
</svg>
{/* Tooltip — absolute-positioned div so we get crisp text, not SVG */}
{hovered && hoverIdx !== null && (
<div
className="pointer-events-none absolute top-0 z-10 -translate-x-1/2 rounded-md border border-slate-700 bg-slate-950 px-2.5 py-1.5 text-[11px] shadow-lg"
style={{ left: `${(hoverX / VB_W) * 100}%` }}
>
<div className="font-medium text-white">{longDayLabel(hovered.day)}</div>
<div className="mt-1 flex flex-col gap-0.5">
<span className="flex items-center gap-1.5 text-blue-300">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-blue-500" />
{hovered.incoming} incoming
</span>
<span className="flex items-center gap-1.5 text-emerald-300">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-emerald-500" />
{hovered.outgoing} outgoing
</span>
</div>
</div>
)}
</div>
)
}
function LegendDot({ color, label }: { color: string; label: string }) {
return (
<span className="flex items-center gap-1.5">
<span className="inline-block h-1.5 w-1.5 rounded-full" style={{ background: color }} />
{label}
</span>
)
}
function shortDayLabel(key: string): string {
// key is YYYY-MM-DD; return "Apr 17"-style. Using Date with an
// appended time avoids timezone-shift surprises across midnight.
const [y, m, d] = key.split('-').map(Number)
const date = new Date(y, m - 1, d)
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
}
function longDayLabel(key: string): string {
const [y, m, d] = key.split('-').map(Number)
const date = new Date(y, m - 1, d)
return date.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })
}
/**
* Round `max` up to a "nice" number so Y-axis ticks feel natural
* (1, 2, 5, 10, 20, 50, …). Keeps the chart readable even when the
* series is small (max=3 becomes ceil=4, not 3).
*/
function niceCeil(max: number): number {
if (max <= 0) return 4
const pow = Math.pow(10, Math.floor(Math.log10(max)))
const normalised = max / pow
let nice: number
if (normalised <= 1) nice = 1
else if (normalised <= 2) nice = 2
else if (normalised <= 5) nice = 5
else nice = 10
return nice * pow
}

View File

@ -0,0 +1,36 @@
import { BarChart3 } from 'lucide-react'
import type { ComponentType } from 'react'
import { cn } from '@/lib/utils'
/**
* Shared empty-state panel for charts that can't render meaningfully
* without a minimum amount of data. Kept minimal and uniform so the
* three empty states on the dashboard don't each feel like a
* different widget.
*/
export function EmptyState({
title = 'Not enough data yet',
hint,
icon: Icon = BarChart3,
className,
}: {
title?: string
hint?: string
icon?: ComponentType<{ className?: string }>
className?: string
}) {
return (
<div
className={cn(
'flex h-full min-h-40 flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-slate-800 bg-slate-900/40 px-4 py-6 text-center',
className,
)}
>
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-slate-800 text-slate-500">
<Icon className="h-5 w-5" />
</div>
<p className="text-sm font-medium text-slate-300">{title}</p>
{hint && <p className="max-w-xs text-xs text-slate-500">{hint}</p>}
</div>
)
}

View File

@ -0,0 +1,57 @@
import { ArrowDown, ArrowUp, Minus } from 'lucide-react'
import type { ComponentType } from 'react'
import { cn } from '@/lib/utils'
interface MetricCardProps {
title: string
/** Pre-formatted value for display (e.g. "42" or "$1,250"). */
value: string
icon: ComponentType<{ className?: string }>
/**
* Delta-mode secondary row: arrow + delta text. Omit when the metric
* doesn't have a sensible comparison (e.g. total pipeline value).
*/
delta?: {
/** Positive / negative / zero drives arrow + color. */
sign: number
/** Pre-formatted delta, e.g. "+3 vs yesterday". */
label: string
}
/** Used instead of `delta` when the metric has a static subtitle. */
subtitle?: string
}
export function MetricCard({ title, value, icon: Icon, delta, subtitle }: MetricCardProps) {
return (
<div className="rounded-xl border border-slate-800 bg-slate-900 p-5">
<div className="flex items-start justify-between">
<p className="text-sm font-medium text-slate-400">{title}</p>
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-800 text-slate-500">
<Icon className="h-4 w-4" />
</div>
</div>
<p className="mt-3 text-[28px] leading-none font-bold tabular-nums text-white">
{value}
</p>
{delta ? <DeltaRow sign={delta.sign} label={delta.label} /> : subtitle ? (
<p className="mt-2 text-sm text-slate-500">{subtitle}</p>
) : null}
</div>
)
}
function DeltaRow({ sign, label }: { sign: number; label: string }) {
const tone =
sign > 0
? 'text-emerald-400'
: sign < 0
? 'text-red-400'
: 'text-slate-500'
const Arrow = sign > 0 ? ArrowUp : sign < 0 ? ArrowDown : Minus
return (
<div className={cn('mt-2 flex items-center gap-1 text-sm', tone)}>
<Arrow className="h-4 w-4" aria-hidden />
<span className="tabular-nums">{label}</span>
</div>
)
}

View File

@ -0,0 +1,144 @@
"use client"
import { GitBranch } from 'lucide-react'
import type { PipelineDonutData } from '@/lib/dashboard/types'
import { EmptyState } from './empty-state'
import { Skeleton } from './skeleton'
interface PipelineDonutProps {
data: PipelineDonutData | null
loading: boolean
}
export function PipelineDonut({ data, loading }: PipelineDonutProps) {
return (
<section className="flex flex-col rounded-xl border border-slate-800 bg-slate-900">
<header className="border-b border-slate-800 px-5 py-4">
<h2 className="text-sm font-semibold text-white">Pipeline Value</h2>
<p className="mt-0.5 text-xs text-slate-500">
Open deals by stage
</p>
</header>
<div className="flex flex-1 flex-col p-5">
{loading || !data ? (
<Skeleton className="h-56 w-full" />
) : data.stages.length === 0 ? (
<EmptyState
icon={GitBranch}
title="No open deals yet"
hint="Create deals in Pipelines to see stage breakdowns here."
/>
) : (
<>
<Donut data={data} />
<ul className="mt-5 space-y-2">
{data.stages.map((s) => (
<li key={s.id} className="flex items-center gap-3 text-xs">
<span
className="h-2.5 w-2.5 flex-shrink-0 rounded-full"
style={{ background: s.color }}
aria-hidden
/>
<span className="flex-1 truncate text-slate-300">{s.name}</span>
<span className="text-slate-500 tabular-nums">
{s.dealCount} deal{s.dealCount === 1 ? '' : 's'}
</span>
<span className="w-20 text-right text-slate-300 tabular-nums">
{formatCurrencyShort(s.totalValue)}
</span>
</li>
))}
</ul>
</>
)}
</div>
</section>
)
}
// ------------------------------------------------------------
// SVG ring. 200×200 viewBox, 12px ring width. We draw one <path>
// per stage using an SVG arc from startAngle → endAngle. Gaps
// between segments are implied by a thin slate-900 stroke between
// them for a cleaner look.
// ------------------------------------------------------------
function Donut({ data }: { data: PipelineDonutData }) {
const size = 200
const r = 80
const ringWidth = 18
const cx = size / 2
const cy = size / 2
// Small slices would render as slivers that disappear into stroke
// rounding. We give each stage a floor share purely for rendering,
// but keep the labels/legend honest with the actual totals.
const totalRaw = data.totalValue || 1
const minFrac = 0.02
const rawShares = data.stages.map((s) => s.totalValue / totalRaw)
const floored = rawShares.map((x) => Math.max(x, minFrac))
const floorSum = floored.reduce((a, b) => a + b, 0)
const shares = floored.map((x) => x / floorSum)
// Build a cumulative-offset array, then map stages → arc paths. Using
// a pre-computed offsets array avoids the Next 16 React Compiler's
// "Cannot reassign variable after render completes" rule.
const offsets: number[] = [0]
for (let i = 0; i < shares.length; i++) offsets.push(offsets[i] + shares[i])
const segments = data.stages.map((s, i) => {
const start = offsets[i] * Math.PI * 2 - Math.PI / 2
const end = offsets[i + 1] * Math.PI * 2 - Math.PI / 2
return { path: arcPath(cx, cy, r, start, end), color: s.color, id: s.id }
})
return (
<div className="flex items-center justify-center">
<svg viewBox={`0 0 ${size} ${size}`} className="h-48 w-48" role="img" aria-label="Pipeline value by stage">
{/* background ring */}
<circle cx={cx} cy={cy} r={r} fill="none" stroke="rgb(30 41 59)" strokeWidth={ringWidth} />
{segments.map((seg) => (
<path
key={seg.id}
d={seg.path}
fill="none"
stroke={seg.color}
strokeWidth={ringWidth}
strokeLinecap="butt"
/>
))}
{/* center label */}
<text
x={cx}
y={cy - 6}
textAnchor="middle"
className="fill-slate-500 text-[11px]"
>
Total
</text>
<text
x={cx}
y={cy + 14}
textAnchor="middle"
className="fill-white text-[18px] font-semibold tabular-nums"
>
{formatCurrencyShort(data.totalValue)}
</text>
</svg>
</div>
)
}
function arcPath(cx: number, cy: number, r: number, startRad: number, endRad: number): string {
const x1 = cx + r * Math.cos(startRad)
const y1 = cy + r * Math.sin(startRad)
const x2 = cx + r * Math.cos(endRad)
const y2 = cy + r * Math.sin(endRad)
const largeArc = endRad - startRad > Math.PI ? 1 : 0
return `M ${x1} ${y1} A ${r} ${r} 0 ${largeArc} 1 ${x2} ${y2}`
}
function formatCurrencyShort(v: number): string {
if (v >= 1_000_000) return `$${(v / 1_000_000).toFixed(1)}M`
if (v >= 1_000) return `$${(v / 1_000).toFixed(1)}k`
return `$${v.toFixed(0)}`
}

View File

@ -0,0 +1,45 @@
"use client"
import Link from 'next/link'
import { UserPlus, Briefcase, Radio, Zap } from 'lucide-react'
import type { ComponentType } from 'react'
// Quick-action shortcuts. Each navigates to the page that owns the
// relevant "create" flow. We deliberately don't try to auto-open any
// modal on the target page — that'd require touching those pages,
// which is out of scope here.
interface Action {
label: string
href: string
icon: ComponentType<{ className?: string }>
tint: string
}
const ACTIONS: Action[] = [
{ label: 'New Contact', href: '/contacts', icon: UserPlus, tint: 'text-emerald-400' },
{ label: 'New Deal', href: '/pipelines', icon: Briefcase, tint: 'text-blue-400' },
{ label: 'New Broadcast', href: '/broadcasts/new', icon: Radio, tint: 'text-amber-400' },
{ label: 'New Automation', href: '/automations/new', icon: Zap, tint: 'text-violet-400' },
]
export function QuickActions() {
return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{ACTIONS.map((a) => {
const Icon = a.icon
return (
<Link
key={a.href}
href={a.href}
className="group flex items-center gap-3 rounded-xl border border-slate-800 bg-slate-900 px-4 py-3 transition-colors hover:border-slate-700 hover:bg-slate-800/60"
>
<div className={`flex h-9 w-9 items-center justify-center rounded-lg bg-slate-800 ${a.tint}`}>
<Icon className="h-4 w-4" />
</div>
<span className="text-sm font-medium text-white">{a.label}</span>
</Link>
)
})}
</div>
)
}

View File

@ -0,0 +1,202 @@
"use client"
import { Clock } from 'lucide-react'
import { DOW_SHORT_MON_FIRST } from '@/lib/dashboard/date-utils'
import type { ResponseTimeSummary } from '@/lib/dashboard/types'
import { EmptyState } from './empty-state'
import { Skeleton } from './skeleton'
interface ResponseTimeChartProps {
data: ResponseTimeSummary | null
loading: boolean
/** Minutes. Horizontal dashed line rendered at this height. */
thresholdMinutes?: number
}
const VB_W = 760
const VB_H = 220
const PADDING = { top: 24, right: 16, bottom: 32, left: 44 }
export function ResponseTimeChart({
data,
loading,
thresholdMinutes = 5,
}: ResponseTimeChartProps) {
const hasData = data?.buckets.some((b) => b.avgMinutes != null) ?? false
return (
<section className="rounded-xl border border-slate-800 bg-slate-900">
<header className="flex items-center justify-between border-b border-slate-800 px-5 py-4">
<div>
<h2 className="text-sm font-semibold text-white">
Average First Response Time
</h2>
<p className="mt-0.5 text-xs text-slate-500">
Minutes to reply to a customer&apos;s first unreplied message, by
weekday
</p>
</div>
{data && (data.thisWeekAvg != null || data.lastWeekAvg != null) && (
<div className="text-right text-xs">
<div className="text-slate-400">
This week:{' '}
<span className="font-medium text-white tabular-nums">
{fmt(data.thisWeekAvg)}
</span>
</div>
<div className="text-slate-500">
Last week:{' '}
<span className="tabular-nums">{fmt(data.lastWeekAvg)}</span>
</div>
</div>
)}
</header>
<div className="p-5">
{loading || !data ? (
<Skeleton className="h-[220px] w-full" />
) : !hasData ? (
<EmptyState
icon={Clock}
title="No replies recorded yet"
hint="This chart fills in as you reply to customer messages."
/>
) : (
<Bars data={data} thresholdMinutes={thresholdMinutes} />
)}
</div>
</section>
)
}
function Bars({
data,
thresholdMinutes,
}: {
data: ResponseTimeSummary
thresholdMinutes: number
}) {
const chartW = VB_W - PADDING.left - PADDING.right
const chartH = VB_H - PADDING.top - PADDING.bottom
const values = data.buckets.map((b) => b.avgMinutes ?? 0)
const rawMax = Math.max(thresholdMinutes * 1.2, ...values)
const maxY = niceCeil(rawMax)
const yFor = (v: number) =>
maxY === 0 ? PADDING.top + chartH : PADDING.top + chartH - (v / maxY) * chartH
const barSlot = chartW / 7
const barW = Math.min(44, barSlot * 0.55)
const ticks = [0, maxY / 2, maxY].map((t) => Math.round(t))
return (
<svg viewBox={`0 0 ${VB_W} ${VB_H}`} className="h-[220px] w-full" role="img">
{/* Y grid */}
{ticks.map((t) => {
const y = yFor(t)
return (
<g key={t}>
<line
x1={PADDING.left}
x2={VB_W - PADDING.right}
y1={y}
y2={y}
stroke="rgb(30 41 59)"
strokeDasharray="3 3"
/>
<text
x={PADDING.left - 8}
y={y}
textAnchor="end"
dominantBaseline="middle"
className="fill-slate-500 text-[10px]"
>
{t}m
</text>
</g>
)
})}
{/* Threshold line — rendered after grid so it sits on top, but
we keep it muted so bars remain the visual focus. */}
{thresholdMinutes > 0 && thresholdMinutes <= maxY && (
<g>
<line
x1={PADDING.left}
x2={VB_W - PADDING.right}
y1={yFor(thresholdMinutes)}
y2={yFor(thresholdMinutes)}
stroke="rgb(244 63 94)"
strokeDasharray="4 4"
strokeWidth={1.25}
opacity={0.8}
/>
<text
x={VB_W - PADDING.right - 4}
y={yFor(thresholdMinutes) - 4}
textAnchor="end"
className="fill-rose-300 text-[10px]"
>
target {thresholdMinutes}m
</text>
</g>
)}
{/* Bars */}
{data.buckets.map((b, i) => {
const v = b.avgMinutes ?? 0
const x = PADDING.left + barSlot * i + (barSlot - barW) / 2
const y = yFor(v)
const h = PADDING.top + chartH - y
const muted = b.avgMinutes == null
return (
<g key={i}>
<rect
x={x}
y={muted ? PADDING.top + chartH - 2 : y}
width={barW}
height={muted ? 2 : Math.max(1, h)}
rx={4}
fill={muted ? 'rgb(51 65 85)' : '#10b981'}
opacity={muted ? 0.6 : 1}
>
<title>
{DOW_SHORT_MON_FIRST[i]}:{' '}
{b.avgMinutes == null ? 'no samples' : `${b.avgMinutes.toFixed(1)} min avg`}
{b.samples > 0 ? ` (${b.samples} sample${b.samples === 1 ? '' : 's'})` : ''}
</title>
</rect>
<text
x={x + barW / 2}
y={VB_H - 10}
textAnchor="middle"
className="fill-slate-400 text-[11px]"
>
{DOW_SHORT_MON_FIRST[i]}
</text>
</g>
)
})}
</svg>
)
}
function fmt(mins: number | null): string {
if (mins == null) return '—'
if (mins < 1) return `${Math.max(1, Math.round(mins * 60))}s`
if (mins < 60) return `${mins.toFixed(1)}m`
return `${(mins / 60).toFixed(1)}h`
}
function niceCeil(max: number): number {
if (max <= 0) return 10
const pow = Math.pow(10, Math.floor(Math.log10(max)))
const n = max / pow
let nice: number
if (n <= 1) nice = 1
else if (n <= 2) nice = 2
else if (n <= 5) nice = 5
else nice = 10
return nice * pow
}

View File

@ -0,0 +1,25 @@
import { cn } from '@/lib/utils'
/**
* Shared skeleton primitive — a pulsing slate block sized to whatever
* container it's dropped into. Used by every dashboard widget while
* its data fetches.
*/
export function Skeleton({ className }: { className?: string }) {
return <div className={cn('animate-pulse rounded-md bg-slate-800', className)} />
}
export function SkeletonCard({ className }: { className?: string }) {
return (
<div
className={cn(
'rounded-xl border border-slate-800 bg-slate-900 p-5',
className,
)}
>
<Skeleton className="h-4 w-32" />
<Skeleton className="mt-4 h-8 w-20" />
<Skeleton className="mt-2 h-3 w-16" />
</div>
)
}

View File

@ -0,0 +1,52 @@
// Centralised date helpers for the dashboard so every chart / card
// agrees on what "today", "day boundary", and "day of week" mean.
// All boundaries are computed in the user's LOCAL timezone — which is
// what a business user intuitively expects when they say "today".
export function startOfLocalDay(d: Date = new Date()): Date {
const out = new Date(d)
out.setHours(0, 0, 0, 0)
return out
}
export function daysAgoStart(days: number): Date {
const out = startOfLocalDay()
out.setDate(out.getDate() - days)
return out
}
/** Date-only key (YYYY-MM-DD) for bucketing rows by local calendar day. */
export function localDayKey(d: Date | string): string {
const date = typeof d === 'string' ? new Date(d) : d
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
/**
* Inclusive list of local-day keys spanning the last `n` days, in
* chronological order. Useful for seeding chart buckets so days with
* zero activity still render a 0-point in the line.
*/
export function lastNDayKeys(n: number): string[] {
const keys: string[] = []
const start = daysAgoStart(n - 1)
for (let i = 0; i < n; i++) {
const d = new Date(start)
d.setDate(d.getDate() + i)
keys.push(localDayKey(d))
}
return keys
}
/**
* ISO day-of-week where 0 = Monday … 6 = Sunday. JavaScript's native
* getDay() uses 0 = Sunday which is awkward for most business charts.
*/
export function mondayIndex(d: Date): number {
const jsDow = d.getDay() // 0..6 with Sunday=0
return (jsDow + 6) % 7
}
export const DOW_SHORT_MON_FIRST = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] as const

View File

@ -0,0 +1,398 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import {
daysAgoStart,
DOW_SHORT_MON_FIRST,
lastNDayKeys,
localDayKey,
mondayIndex,
startOfLocalDay,
} from './date-utils'
import type {
ActivityItem,
ConversationsSeriesPoint,
MetricsBundle,
PipelineDonutData,
PipelineStageSlice,
ResponseTimeBucket,
ResponseTimeSummary,
} from './types'
// ------------------------------------------------------------
// All client-side aggregation. RLS scopes every query to the
// signed-in user automatically, so we never pass user_id explicitly
// here. Perf is acceptable for the current scale (low thousands of
// messages) — if a tenant's dataset outgrows this, we'd migrate the
// heavy aggregations to SQL RPCs. Noted in the PR.
// ------------------------------------------------------------
type DB = SupabaseClient
// --- 1. Metric cards ---------------------------------------------------
export async function loadMetrics(db: DB): Promise<MetricsBundle> {
const todayStart = startOfLocalDay().toISOString()
const yesterdayStart = daysAgoStart(1).toISOString()
const [
openConvCur,
newConvToday,
newConvYesterday,
newContactsToday,
newContactsYesterday,
openDeals,
messagesToday,
messagesYesterday,
] = await Promise.all([
db.from('conversations').select('id', { count: 'exact', head: true }).eq('status', 'open'),
db
.from('conversations')
.select('id', { count: 'exact', head: true })
.eq('status', 'open')
.gte('created_at', todayStart),
db
.from('conversations')
.select('id', { count: 'exact', head: true })
.eq('status', 'open')
.gte('created_at', yesterdayStart)
.lt('created_at', todayStart),
db.from('contacts').select('id', { count: 'exact', head: true }).gte('created_at', todayStart),
db
.from('contacts')
.select('id', { count: 'exact', head: true })
.gte('created_at', yesterdayStart)
.lt('created_at', todayStart),
db.from('deals').select('value, status').eq('status', 'open'),
db
.from('messages')
.select('id', { count: 'exact', head: true })
.eq('sender_type', 'agent')
.gte('created_at', todayStart),
db
.from('messages')
.select('id', { count: 'exact', head: true })
.eq('sender_type', 'agent')
.gte('created_at', yesterdayStart)
.lt('created_at', todayStart),
])
const openDealsRows = (openDeals.data ?? []) as { value: number | null }[]
const openDealsValue = openDealsRows.reduce((sum, d) => sum + (d.value ?? 0), 0)
return {
activeConversations: {
current: openConvCur.count ?? 0,
// "vs yesterday" on a current-state count has no clean answer
// without snapshots — we show the delta in NEW open conversations
// today vs yesterday. That's the business-meaningful daily signal.
previous: (newConvToday.count ?? 0) - (newConvYesterday.count ?? 0),
},
newContactsToday: {
current: newContactsToday.count ?? 0,
previous: newContactsYesterday.count ?? 0,
},
openDealsValue,
openDealsCount: openDealsRows.length,
messagesSentToday: {
current: messagesToday.count ?? 0,
previous: messagesYesterday.count ?? 0,
},
}
}
// --- 2. Conversations over time ---------------------------------------
export async function loadConversationsSeries(
db: DB,
rangeDays: number,
): Promise<ConversationsSeriesPoint[]> {
const start = daysAgoStart(rangeDays - 1).toISOString()
const { data, error } = await db
.from('messages')
.select('created_at, sender_type')
.gte('created_at', start)
.order('created_at', { ascending: true })
if (error) throw error
const keys = lastNDayKeys(rangeDays)
const buckets = new Map<string, { incoming: number; outgoing: number }>()
for (const k of keys) buckets.set(k, { incoming: 0, outgoing: 0 })
for (const row of (data ?? []) as { created_at: string; sender_type: string }[]) {
const key = localDayKey(row.created_at)
const bucket = buckets.get(key)
if (!bucket) continue
if (row.sender_type === 'customer') bucket.incoming += 1
else bucket.outgoing += 1 // agent + bot both count as outgoing
}
return keys.map((day) => ({ day, ...(buckets.get(day) ?? { incoming: 0, outgoing: 0 }) }))
}
// --- 3. Pipeline donut -------------------------------------------------
export async function loadPipelineDonut(db: DB): Promise<PipelineDonutData> {
const [stagesRes, dealsRes] = await Promise.all([
db.from('pipeline_stages').select('id, name, color, pipeline_id, position').order('position'),
db.from('deals').select('stage_id, value, status').eq('status', 'open'),
])
const stages =
(stagesRes.data ?? []) as { id: string; name: string; color: string }[]
const deals = (dealsRes.data ?? []) as { stage_id: string; value: number | null }[]
const byStage = new Map<string, { count: number; total: number }>()
for (const d of deals) {
const row = byStage.get(d.stage_id) ?? { count: 0, total: 0 }
row.count += 1
row.total += d.value ?? 0
byStage.set(d.stage_id, row)
}
const slices: PipelineStageSlice[] = stages
.map((s) => ({
id: s.id,
name: s.name,
color: s.color || '#64748b',
dealCount: byStage.get(s.id)?.count ?? 0,
totalValue: byStage.get(s.id)?.total ?? 0,
}))
// Hide empty stages from the ring (but we'd still show them in the
// legend if the user wanted a full breakdown — trimming keeps the
// visual clean for the common case).
.filter((s) => s.totalValue > 0 || s.dealCount > 0)
return {
stages: slices,
totalValue: slices.reduce((sum, s) => sum + s.totalValue, 0),
}
}
// --- 4. Response time by day of week ----------------------------------
export async function loadResponseTime(db: DB): Promise<ResponseTimeSummary> {
// Pull the last 14 days of messages in one shot, then walk per
// conversation to find each "first inbound" → "first subsequent
// outbound" pair. 14 days gives us both "this week" + "last week"
// with enough overlap if the user opens the dashboard late on a
// Monday.
const fourteenDaysAgo = daysAgoStart(13).toISOString()
const { data, error } = await db
.from('messages')
.select('conversation_id, sender_type, created_at')
.gte('created_at', fourteenDaysAgo)
.order('conversation_id', { ascending: true })
.order('created_at', { ascending: true })
if (error) throw error
const rows = (data ?? []) as {
conversation_id: string
sender_type: string
created_at: string
}[]
// Group per conversation, pair unreplied customer messages with the
// next outbound message from the agent/bot. A single customer message
// can only count once (avoids inflating averages if the customer
// double-messages while the agent takes time to reply).
interface Sample {
customerAt: Date
responseAt: Date
}
const samples: Sample[] = []
let currentConv = ''
let pendingCustomer: Date | null = null
for (const row of rows) {
if (row.conversation_id !== currentConv) {
currentConv = row.conversation_id
pendingCustomer = null
}
const ts = new Date(row.created_at)
if (row.sender_type === 'customer') {
if (!pendingCustomer) pendingCustomer = ts
} else if (pendingCustomer) {
samples.push({ customerAt: pendingCustomer, responseAt: ts })
pendingCustomer = null
}
}
const now = new Date()
const thisWeekStart = daysAgoStart(mondayIndex(now))
const lastWeekStart = daysAgoStart(mondayIndex(now) + 7)
// Per-day-of-week buckets, averaged over both weeks' worth of data
// so each bar has more samples to stand on. If a day has no samples
// its avgMinutes stays null and the chart renders the bar muted.
const byDow = new Map<number, number[]>()
for (let i = 0; i < 7; i++) byDow.set(i, [])
const thisWeekMins: number[] = []
const lastWeekMins: number[] = []
for (const s of samples) {
const diffMin = (s.responseAt.getTime() - s.customerAt.getTime()) / 60_000
if (diffMin < 0) continue
const dow = mondayIndex(s.customerAt)
byDow.get(dow)!.push(diffMin)
if (s.customerAt >= thisWeekStart) {
thisWeekMins.push(diffMin)
} else if (s.customerAt >= lastWeekStart && s.customerAt < thisWeekStart) {
lastWeekMins.push(diffMin)
}
}
const avg = (arr: number[]) =>
arr.length === 0 ? null : arr.reduce((a, b) => a + b, 0) / arr.length
const buckets: ResponseTimeBucket[] = Array.from({ length: 7 }, (_, dow) => {
const samples = byDow.get(dow) ?? []
return {
dow,
avgMinutes: avg(samples),
samples: samples.length,
}
})
// Silence unused-label warnings — keep the arrays explicitly named
// for readability above.
void DOW_SHORT_MON_FIRST
return {
buckets,
thisWeekAvg: avg(thisWeekMins),
lastWeekAvg: avg(lastWeekMins),
}
}
// --- 5. Activity feed --------------------------------------------------
export async function loadActivity(db: DB, limit = 20): Promise<ActivityItem[]> {
// Pull ~10 from each source (plenty of headroom after merge-sort),
// then interleave by timestamp. The individual per-table limits
// keep the payload small; the final limit is enforced after sort.
const [msgs, contacts, deals, broadcasts, autoLogs] = await Promise.all([
db
.from('messages')
.select('id, content_text, sender_type, created_at, conversation_id, conversations(contact_id, contacts(name, phone))')
.eq('sender_type', 'customer')
.order('created_at', { ascending: false })
.limit(10),
db
.from('contacts')
.select('id, name, phone, created_at')
.order('created_at', { ascending: false })
.limit(10),
db
.from('deals')
.select('id, title, updated_at, stage:pipeline_stages(name)')
.order('updated_at', { ascending: false })
.limit(10),
db
.from('broadcasts')
.select('id, name, status, total_recipients, created_at')
.order('created_at', { ascending: false })
.limit(5),
db
.from('automation_logs')
.select('id, trigger_event, status, created_at, automation:automations(name), contact:contacts(name, phone)')
.order('created_at', { ascending: false })
.limit(10),
])
const items: ActivityItem[] = []
// PostgREST returns nested selections as arrays by default, even when
// the foreign key is 1:1. We normalise by taking [0] on each level.
for (const m of (msgs.data ?? []) as unknown as Array<{
id: string
content_text: string | null
created_at: string
conversation_id: string
conversations:
| { contact_id: string | null; contacts: { name: string | null; phone: string }[] | { name: string | null; phone: string } | null }[]
| { contact_id: string | null; contacts: { name: string | null; phone: string }[] | { name: string | null; phone: string } | null }
| null
}>) {
const conv = Array.isArray(m.conversations) ? m.conversations[0] : m.conversations
const contact = Array.isArray(conv?.contacts) ? conv?.contacts[0] : conv?.contacts
const who = contact?.name || contact?.phone || 'Unknown'
items.push({
id: `msg-${m.id}`,
kind: 'message',
text: `New message from ${who}`,
at: m.created_at,
href: `/inbox?c=${m.conversation_id}`,
})
}
for (const c of (contacts.data ?? []) as Array<{ id: string; name: string | null; phone: string; created_at: string }>) {
items.push({
id: `contact-${c.id}`,
kind: 'contact',
text: `New contact: ${c.name || c.phone}`,
at: c.created_at,
href: '/contacts',
})
}
for (const d of (deals.data ?? []) as unknown as Array<{
id: string
title: string
updated_at: string
stage: { name: string }[] | { name: string } | null
}>) {
const stage = Array.isArray(d.stage) ? d.stage[0] : d.stage
items.push({
id: `deal-${d.id}`,
kind: 'deal',
text: stage?.name
? `Deal "${d.title}" in ${stage.name}`
: `Deal "${d.title}" updated`,
at: d.updated_at,
href: '/pipelines',
})
}
for (const b of (broadcasts.data ?? []) as Array<{
id: string
name: string
status: string
total_recipients: number
created_at: string
}>) {
const label =
b.status === 'sent'
? `sent to ${b.total_recipients} contacts`
: `${b.status} (${b.total_recipients} recipients)`
items.push({
id: `broadcast-${b.id}`,
kind: 'broadcast',
text: `Broadcast "${b.name}" ${label}`,
at: b.created_at,
href: '/broadcasts',
})
}
for (const l of (autoLogs.data ?? []) as unknown as Array<{
id: string
trigger_event: string
status: string
created_at: string
automation: { name: string }[] | { name: string } | null
contact: { name: string | null; phone: string }[] | { name: string | null; phone: string } | null
}>) {
const automation = Array.isArray(l.automation) ? l.automation[0] : l.automation
const contact = Array.isArray(l.contact) ? l.contact[0] : l.contact
const who = contact?.name || contact?.phone || 'a contact'
const autoName = automation?.name || 'Automation'
items.push({
id: `auto-${l.id}`,
kind: 'automation',
text: `Automation "${autoName}" ${l.status === 'failed' ? 'failed for' : 'triggered for'} ${who}`,
at: l.created_at,
})
}
return items
.sort((a, b) => (a.at > b.at ? -1 : a.at < b.at ? 1 : 0))
.slice(0, limit)
}

View File

@ -0,0 +1,67 @@
// Shared result shapes the dashboard components consume. Centralised
// here so each component stays thin and the page-level loader wires
// them up without type gymnastics.
export interface MetricDelta {
current: number
previous: number
}
export interface MetricsBundle {
activeConversations: MetricDelta
newContactsToday: MetricDelta
openDealsValue: number
openDealsCount: number
messagesSentToday: MetricDelta
}
export interface ConversationsSeriesPoint {
day: string // YYYY-MM-DD local
incoming: number
outgoing: number
}
export interface PipelineStageSlice {
id: string
name: string
color: string
dealCount: number
totalValue: number
}
export interface PipelineDonutData {
stages: PipelineStageSlice[]
totalValue: number
}
export interface ResponseTimeBucket {
/** 0 = Mon … 6 = Sun (Monday-first). */
dow: number
/** Average first-response time in minutes. Null means no samples. */
avgMinutes: number | null
samples: number
}
export interface ResponseTimeSummary {
buckets: ResponseTimeBucket[]
thisWeekAvg: number | null
lastWeekAvg: number | null
}
export type ActivityKind =
| 'message'
| 'deal'
| 'broadcast'
| 'automation'
| 'contact'
export interface ActivityItem {
id: string
kind: ActivityKind
/** Primary line of text rendered in the feed. Pre-formatted. */
text: string
/** ISO timestamp the item happened at, drives relative-time + sort. */
at: string
/** Optional deep-link for the whole row (not all items have a target). */
href?: string
}