diff --git a/src/app/(dashboard)/dashboard/page.tsx b/src/app/(dashboard)/dashboard/page.tsx
index 24fa91d..bf68834 100644
--- a/src/app/(dashboard)/dashboard/page.tsx
+++ b/src/app/(dashboard)/dashboard/page.tsx
@@ -1,428 +1,218 @@
-"use client";
+"use client"
-import { useState, useEffect } from "react";
-import { useRouter } from "next/navigation";
-import { createClient } from "@/lib/supabase/client";
-import { Conversation, Pipeline, PipelineStage, Broadcast } from "@/types";
+import { useCallback, useEffect, useState } from 'react'
+import { createClient } from '@/lib/supabase/client'
import {
- Users,
MessageSquare,
- Mail,
- TrendingUp,
- ArrowRight,
- Radio,
- GitBranch,
+ UserPlus,
DollarSign,
- Clock,
-} from "lucide-react";
+ Send,
+} from 'lucide-react'
-function formatCurrency(value: number) {
- return new Intl.NumberFormat("en-US", {
- style: "currency",
- currency: "USD",
- minimumFractionDigits: 0,
- maximumFractionDigits: 0,
- }).format(value);
-}
+import {
+ loadActivity,
+ loadConversationsSeries,
+ loadMetrics,
+ loadPipelineDonut,
+ loadResponseTime,
+} from '@/lib/dashboard/queries'
+import type {
+ ActivityItem,
+ ConversationsSeriesPoint,
+ MetricsBundle,
+ PipelineDonutData,
+ ResponseTimeSummary,
+} from '@/lib/dashboard/types'
-function formatRelativeTime(dateStr: string) {
- const date = new Date(dateStr);
- const now = new Date();
- const diffMs = now.getTime() - date.getTime();
- const diffMin = Math.floor(diffMs / 60000);
- const diffHr = Math.floor(diffMin / 60);
- const diffDay = Math.floor(diffHr / 24);
+import { MetricCard } from '@/components/dashboard/metric-card'
+import { SkeletonCard } from '@/components/dashboard/skeleton'
+import { QuickActions } from '@/components/dashboard/quick-actions'
+import { ConversationsChart } from '@/components/dashboard/conversations-chart'
+import { PipelineDonut } from '@/components/dashboard/pipeline-donut'
+import { ResponseTimeChart } from '@/components/dashboard/response-time-chart'
+import { ActivityFeed } from '@/components/dashboard/activity-feed'
- if (diffMin < 1) return "Just now";
- 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 (
-
-
- {loading ? (
-
- ) : (
-
{value}
- )}
-
- );
-}
-
-interface PipelineOverviewData {
- pipeline: Pipeline;
- stages: (PipelineStage & { dealCount: number; totalValue: number })[];
-}
+type RangeDays = 7 | 30 | 90
export default function DashboardPage() {
- const router = useRouter();
- const supabase = createClient();
+ const [metrics, setMetrics] = useState(null)
+ const [metricsLoading, setMetricsLoading] = useState(true)
- const [loading, setLoading] = useState(true);
- const [totalContacts, setTotalContacts] = useState(0);
- const [openConversations, setOpenConversations] = useState(0);
- const [messagesToday, setMessagesToday] = useState(0);
- const [activeDeals, setActiveDeals] = useState(0);
- const [recentConversations, setRecentConversations] = useState([]);
- const [pipelineOverviews, setPipelineOverviews] = useState([]);
- const [recentBroadcasts, setRecentBroadcasts] = useState([]);
+ const [range, setRange] = useState(30)
+ // Keep a cache per range so switching tabs doesn't re-fetch what we
+ // already have. Ranges the user hasn't opened yet stay null and
+ // trigger a fetch on first view.
+ const [series, setSeries] = useState>({
+ 7: null,
+ 30: null,
+ 90: null,
+ })
+ const [seriesLoading, setSeriesLoading] = useState(true)
+
+ const [pipeline, setPipeline] = useState(null)
+ const [pipelineLoading, setPipelineLoading] = useState(true)
+
+ const [responseTime, setResponseTime] = useState(null)
+ const [responseTimeLoading, setResponseTimeLoading] = useState(true)
+
+ const [activity, setActivity] = useState(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(() => {
- loadDashboardData();
- }, []);
+ loadAll()
+ }, [loadAll])
- async function loadDashboardData() {
- setLoading(true);
-
- const todayStart = new Date();
- todayStart.setHours(0, 0, 0, 0);
-
- const [
- contactsRes,
- openConvRes,
- messagesTodayRes,
- dealsRes,
- recentConvRes,
- pipelinesRes,
- broadcastsRes,
- ] = await Promise.all([
- supabase.from("contacts").select("id", { count: "exact", head: true }),
- 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";
- }
- }
+ // Range switch handler — kept in an event callback (not an effect)
+ // 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
+ // previously-viewed range is instant and doesn't re-fetch.
+ const handleRangeChange = useCallback(
+ (r: RangeDays) => {
+ setRange(r)
+ if (series[r] !== null) return
+ setSeriesLoading(true)
+ const db = createClient()
+ loadConversationsSeries(db, r)
+ .then((s) => setSeries((prev) => ({ ...prev, [r]: s })))
+ .catch((err) => console.error('[dashboard] series failed:', err))
+ .finally(() => setSeriesLoading(false))
+ },
+ [series],
+ )
return (
-
+
{/* Header */}
Dashboard
- Overview of your WhatsApp CRM activity
+ Live analytics across conversations, contacts, deals, broadcasts, and automations.
- {/* Row 1: Stat Cards */}
+ {/* Metric cards */}
- }
- loading={loading}
- />
- }
- loading={loading}
- />
- }
- loading={loading}
- />
- }
- loading={loading}
- />
+ {metricsLoading || !metrics ? (
+ Array.from({ length: 4 }).map((_, i) => )
+ ) : (
+ <>
+
+
+
+
+ >
+ )}
- {/* Row 2: Recent Conversations */}
-
-
-
-
-
- Recent Conversations
-
-
-
+ {/* Quick actions */}
+
+
+ {/* Charts row */}
+
+
+
-
- {loading ? (
- Array.from({ length: 3 }).map((_, i) => (
-
- ))
- ) : recentConversations.length === 0 ? (
-
- No conversations yet
-
- ) : (
- recentConversations.map((conv) => (
-
- ))
- )}
+
- {/* Row 3: Pipeline Overview */}
-
-
-
-
- Pipeline Overview
-
-
-
- {loading ? (
-
- {[1, 2].map((i) => (
-
-
-
- {[1, 2, 3, 4].map((j) => (
-
- ))}
-
-
- ))}
-
- ) : pipelineOverviews.length === 0 ? (
-
- No pipelines created yet
-
- ) : (
-
- {pipelineOverviews.map(({ pipeline, stages }) => (
-
-
- {pipeline.name}
-
-
- {stages.map((stage) => (
-
-
-
- {stage.dealCount}
-
- {stage.totalValue > 0 && (
-
-
- {formatCurrency(stage.totalValue)}
-
- )}
-
- ))}
-
-
- ))}
-
- )}
-
-
+ {/* Response time */}
+
- {/* Row 4: Recent Broadcasts */}
-
-
-
-
- Recent Broadcasts
-
-
-
- {loading ? (
- Array.from({ length: 2 }).map((_, i) => (
-
- ))
- ) : recentBroadcasts.length === 0 ? (
-
- No broadcasts yet
-
- ) : (
- recentBroadcasts.map((broadcast) => (
-
-
-
-
-
-
- {broadcast.name}
-
-
-
- {broadcast.sent_count}/{broadcast.total_recipients} sent
-
-
-
- {formatRelativeTime(broadcast.created_at)}
-
-
-
-
- {broadcast.status}
-
-
- ))
- )}
-
-
+ {/* Activity feed */}
+
- );
+ )
+}
+
+// ------------------------------------------------------------
+
+function formatCurrency(v: number): string {
+ return new Intl.NumberFormat(undefined, {
+ style: 'currency',
+ currency: 'USD',
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 0,
+ }).format(v)
+}
+
+function deltaLabel(delta: number, suffix: string): string {
+ if (delta === 0) return `No change ${suffix}`
+ const sign = delta > 0 ? '+' : ''
+ return `${sign}${delta.toLocaleString()} ${suffix}`
}
diff --git a/src/components/dashboard/activity-feed.tsx b/src/components/dashboard/activity-feed.tsx
new file mode 100644
index 0000000..36d3da3
--- /dev/null
+++ b/src/components/dashboard/activity-feed.tsx
@@ -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
= {
+ 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 (
+
+
+ Recent Activity
+
+ View all →
+
+
+
+ {loading || !items ? (
+
+ {Array.from({ length: 5 }).map((_, i) => (
+
+ ))}
+
+ ) : items.length === 0 ? (
+
+
+
+ ) : (
+
+ )}
+
+ )
+}
+
+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()
+}
diff --git a/src/components/dashboard/conversations-chart.tsx b/src/components/dashboard/conversations-chart.tsx
new file mode 100644
index 0000000..458db7a
--- /dev/null
+++ b/src/components/dashboard/conversations-chart.tsx
@@ -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
+ 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 (
+
+
+
+
Conversations Over Time
+
Daily message volume by direction
+
+
+ {[7, 30, 90].map((r) => (
+
+ ))}
+
+
+
+
+ {loading || !data ? (
+
+ ) : data.every((p) => p.incoming === 0 && p.outgoing === 0) ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ )
+}
+
+// ------------------------------------------------------------
+// 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(null)
+ const svgRef = useRef(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 (
+
+
+
+ {/* Tooltip — absolute-positioned div so we get crisp text, not SVG */}
+ {hovered && hoverIdx !== null && (
+
+
{longDayLabel(hovered.day)}
+
+
+
+ {hovered.incoming} incoming
+
+
+
+ {hovered.outgoing} outgoing
+
+
+
+ )}
+
+ )
+}
+
+function LegendDot({ color, label }: { color: string; label: string }) {
+ return (
+
+
+ {label}
+
+ )
+}
+
+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
+}
diff --git a/src/components/dashboard/empty-state.tsx b/src/components/dashboard/empty-state.tsx
new file mode 100644
index 0000000..bcdcce5
--- /dev/null
+++ b/src/components/dashboard/empty-state.tsx
@@ -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 (
+
+
+
+
+
{title}
+ {hint &&
{hint}
}
+
+ )
+}
diff --git a/src/components/dashboard/metric-card.tsx b/src/components/dashboard/metric-card.tsx
new file mode 100644
index 0000000..348b2b9
--- /dev/null
+++ b/src/components/dashboard/metric-card.tsx
@@ -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 (
+
+
+
+ {value}
+
+ {delta ?
: subtitle ? (
+
{subtitle}
+ ) : null}
+
+ )
+}
+
+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 (
+
+ )
+}
diff --git a/src/components/dashboard/pipeline-donut.tsx b/src/components/dashboard/pipeline-donut.tsx
new file mode 100644
index 0000000..4a9854a
--- /dev/null
+++ b/src/components/dashboard/pipeline-donut.tsx
@@ -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 (
+
+
+ Pipeline Value
+
+ Open deals by stage
+
+
+
+
+ {loading || !data ? (
+
+ ) : data.stages.length === 0 ? (
+
+ ) : (
+ <>
+
+
+ {data.stages.map((s) => (
+ -
+
+ {s.name}
+
+ {s.dealCount} deal{s.dealCount === 1 ? '' : 's'}
+
+
+ {formatCurrencyShort(s.totalValue)}
+
+
+ ))}
+
+ >
+ )}
+
+
+ )
+}
+
+// ------------------------------------------------------------
+// SVG ring. 200×200 viewBox, 12px ring width. We draw one
+// 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 (
+
+
+
+ )
+}
+
+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)}`
+}
diff --git a/src/components/dashboard/quick-actions.tsx b/src/components/dashboard/quick-actions.tsx
new file mode 100644
index 0000000..c37711e
--- /dev/null
+++ b/src/components/dashboard/quick-actions.tsx
@@ -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 (
+
+ {ACTIONS.map((a) => {
+ const Icon = a.icon
+ return (
+
+
+
+
+
{a.label}
+
+ )
+ })}
+
+ )
+}
diff --git a/src/components/dashboard/response-time-chart.tsx b/src/components/dashboard/response-time-chart.tsx
new file mode 100644
index 0000000..d368199
--- /dev/null
+++ b/src/components/dashboard/response-time-chart.tsx
@@ -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 (
+
+
+
+
+ {loading || !data ? (
+
+ ) : !hasData ? (
+
+ ) : (
+
+ )}
+
+
+ )
+}
+
+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 (
+
+ )
+}
+
+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
+}
diff --git a/src/components/dashboard/skeleton.tsx b/src/components/dashboard/skeleton.tsx
new file mode 100644
index 0000000..cd1b469
--- /dev/null
+++ b/src/components/dashboard/skeleton.tsx
@@ -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
+}
+
+export function SkeletonCard({ className }: { className?: string }) {
+ return (
+
+
+
+
+
+ )
+}
diff --git a/src/lib/dashboard/date-utils.ts b/src/lib/dashboard/date-utils.ts
new file mode 100644
index 0000000..10dce41
--- /dev/null
+++ b/src/lib/dashboard/date-utils.ts
@@ -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
diff --git a/src/lib/dashboard/queries.ts b/src/lib/dashboard/queries.ts
new file mode 100644
index 0000000..ac03a2d
--- /dev/null
+++ b/src/lib/dashboard/queries.ts
@@ -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 {
+ 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 {
+ 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()
+ 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 {
+ 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()
+ 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 {
+ // 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()
+ 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 {
+ // 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)
+}
diff --git a/src/lib/dashboard/types.ts b/src/lib/dashboard/types.ts
new file mode 100644
index 0000000..790d6e8
--- /dev/null
+++ b/src/lib/dashboard/types.ts
@@ -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
+}