wacrm/src/lib/dashboard/queries.ts
Arnas Donauskas ed669424b6
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>
2026-04-17 22:16:00 +03:00

399 lines
13 KiB
TypeScript

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)
}