"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 } import { useTranslations } from 'next-intl' export function ConversationsChart({ series, loading, range, onRangeChange }: ConversationsChartProps) { const t = useTranslations('Dashboard.conversationsChart') 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 (

{t('title')}

{t('description')}

{[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, t }: { data: ConversationsSeriesPoint[] maxY: number ticks: number[] t: ReturnType }) { // Hover state: both the snapped index AND the tooltip's pixel // offset inside the wrapper div. They're stored together so the // tooltip positions against the chart's actual rendered pixels, // not against a raw viewBox percentage. See the precision note on // the onMove handler below. const [hover, setHover] = useState<{ idx: number; tooltipLeftPx: number } | null>(null) const svgRef = useRef(null) const wrapRef = 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: use the SVG's current screen-CTM to map clientX // back to viewBox coordinates. The previous rect-based math // assumed the viewBox filled the SVG DOM box linearly, but // `preserveAspectRatio="xMidYMid meet"` (the SVG default) // letterboxes the content horizontally when the container is // wider than the viewBox aspect — so hover snapped hundreds of // pixels off on wide layouts. CTM-inverse correctly accounts for // letterboxing, scaling, and any future transform changes. useEffect(() => { const svg = svgRef.current const wrap = wrapRef.current if (!svg || !wrap) return const onMove = (e: MouseEvent) => { const ctm = svg.getScreenCTM() if (!ctm) return const pt = svg.createSVGPoint() pt.x = e.clientX pt.y = e.clientY const local = pt.matrixTransform(ctm.inverse()) const xVb = local.x if (xVb < PADDING.left - 8 || xVb > VB_W - PADDING.right + 8) { setHover(null) return } const relative = xVb - PADDING.left const idx = Math.max( 0, Math.min(data.length - 1, Math.round(stepX === 0 ? 0 : relative / stepX)), ) // Map the snapped data-point's viewBox x back to screen, then // subtract the wrapper's left edge — that pixel offset is what // the absolutely-positioned tooltip div consumes. `xFor` is // inlined here so the effect deps stay stable (it's a closure // that'd otherwise be a new reference every render). const dataPointVbX = PADDING.left + idx * stepX const dataPointPt = svg.createSVGPoint() dataPointPt.x = dataPointVbX dataPointPt.y = 0 const screen = dataPointPt.matrixTransform(ctm) const wrapRect = wrap.getBoundingClientRect() setHover({ idx, tooltipLeftPx: screen.x - wrapRect.left }) } const onLeave = () => setHover(null) svg.addEventListener('mousemove', onMove) svg.addEventListener('mouseleave', onLeave) return () => { svg.removeEventListener('mousemove', onMove) svg.removeEventListener('mouseleave', onLeave) } // xFor + yFor close over stepX, so stepX covers them. }, [data, stepX]) const hovered = hover !== null ? data[hover.idx] : null const hoverX = hover !== null ? xFor(hover.idx) : 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 (
{/* Y-axis gridlines + labels */} {ticks.map((t) => { const y = yFor(t) return ( {t} ) })} {/* X-axis labels */} {data.map((p, i) => i % labelStride === 0 ? ( {shortDayLabel(p.day)} ) : null, )} {/* Outgoing polyline (violet) */} {/* Incoming polyline (blue) */} {/* Hover crosshair */} {hover !== null && ( )} {/* Tooltip — absolute-positioned div so we get crisp text, not SVG-rendered text. The left offset comes from the CTM-based mapping so it lines up with the actual crosshair pixel, not a letterboxed viewBox percentage. */} {hovered && hover !== null && (
{longDayLabel(hovered.day)}
{t('tooltipIncoming', { count: hovered.incoming })} {t('tooltipOutgoing', { count: hovered.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 }