"use client"
/**
* ChartsOverview — Dashboard chart gallery
*
* ── ChartCard variants ───────────────────────────────────────────────────────
* normal — plain card with Ask Leo
* tabs — "Chart" | "Trend (Line)" tabs + Ask Leo
* selector — quick-filter Select + Ask Leo
* metrics-tabs — metric cells ARE the tab triggers (label + value + trend)
*
* ── ASK LEO ICON GUIDELINE ───────────────────────────────────────────────────
* Always use:
* Never use: fa-wand-magic-sparkles (retired, inconsistent)
* Size: text-xs (12px via --text-xs) with aria-hidden="true"
* Label: "Ask Leo" (never truncate or omit the text label)
* Applies to: ALL Ask Leo buttons across the entire app —
* ChartCard headers, KeyMetrics card, GreetingWidget, NavUser, etc.
*
* ── WCAG AA STANDARDS FOR GRAPHS ─────────────────────────────────────────────
* 1. Container landmark
* • Wrap each chart in a
(or div with role="figure") +
* aria-label="" + aria-describedby=""
* • Add a visually-hidden with a plain-text
* summary of the key trend (e.g. "Placements rose 12% in Q1 2026").
*
* 2. Keyboard navigation
* • The ChartContainer wrapper must have tabIndex={0} so it receives focus.
* • On focus, announce title + summary via aria-label / aria-describedby.
* • Arrow keys (←/→) cycle through data points; announce value via
* a live region (role="status" aria-live="polite").
* • Esc clears the selection and returns focus to the container.
*
* 3. Accessible data table (hidden fallback)
* • Immediately after the SVG/canvas, render a
wrapped in
* (visually hidden, in DOM).
* • Columns mirror the chart axes; each data point is a
.
* • Screen-reader users can navigate data with standard table shortcuts.
*
* 4. Colour & contrast
* • Chart series colours must achieve ≥ 3:1 contrast against the card bg.
* • Never use colour as the ONLY differentiator — pair with:
* - Dashed vs solid line strokes
* - Direct inline labels on lines/segments
* - Shape markers on data points (circle vs square vs triangle)
* • Text labels inside charts: ≥ 4.5:1 on their local background.
*
* 5. Focus ring on data points
* • Active/focused data point: 3px outline, ≥ 3:1 contrast, distinct
* from the hover state (use outline-offset to separate).
*
* 6. Tooltip accessibility
* • Tooltips must appear on keyboard focus, not only on mouse hover.
* • Tooltip content must be announced to the live region.
* • Tooltip must remain visible while it has focus (no auto-dismiss).
* ─────────────────────────────────────────────────────────────────────────────
*/
import * as React from "react"
import {
Area, AreaChart,
Bar, BarChart,
CartesianGrid,
Cell,
ComposedChart,
Funnel, FunnelChart, LabelList,
Line, LineChart,
Pie, PieChart,
PolarAngleAxis, PolarGrid, PolarRadiusAxis,
Radar, RadarChart,
RadialBar, RadialBarChart,
Scatter, ScatterChart,
Sector,
Trapezoid,
XAxis, YAxis, ZAxis,
type DotItemDotProps,
} from "recharts"
import type { PieSectorDataItem } from "recharts/types/polar/Pie"
import {
QuotaLinearProgressCardBody,
QuotaRadialChartInner,
} from "@/components/dashboard-quota-progress-card"
import {
DASHBOARD_STUDENT_SCORES,
formatBandScore,
type StudentScoreRadial,
} from "@/lib/mock/dashboard"
import {
Card, CardContent, CardDescription, CardHeader, CardTitle,
} from "@/components/ui/card"
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
chartTooltipKeyboardSyncProps,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart"
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select"
import { Button } from "@/components/ui/button"
import { AskLeoButton } from "@/components/ask-leo-button"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { isEditableTarget } from "@/lib/editable-target"
import { chartLineStrokeDash } from "@/lib/chart-line-dash"
import {
CHART_AXIS_TICK,
CHART_TICK_FONT_SIZE,
} from "@/lib/chart-typography"
import {
CHART_DASHBOARD_CELL_CLASS,
CHART_DASHBOARD_PLOT_MIN_CLASS,
CHART_DASHBOARD_ROW_GRID_CLASS,
CHART_DASHBOARD_ROW_GRID_THIRDS_CLASS,
} from "@/lib/chart-dashboard-layout"
import { chartLeoPeakAnchor } from "@/lib/chart-leo-spotting"
import {
CHART_KBD_ACTIVE_PIE_SHAPE,
} from "@/lib/chart-keyboard-selection"
import { cn } from "@/lib/utils"
import { metricTrendTone, type MetricTrendPolarity } from "@/components/key-metrics"
/** Recharts passes `index` into Line `dot` renderers; keep the callback typed against the v3 dot contract. */
type LineDotRenderProps = DotItemDotProps & { index?: number }
function donutLeoSector(props: PieSectorDataItem) {
const isPeak = props.name === "confirmed"
const isActive = (props as PieSectorDataItem & { isActive?: boolean }).isActive
return (
)
}
function radialLeoSector(
props: React.ComponentProps & { payload?: { name?: string }; isActive?: boolean },
) {
const isPeak = props.payload?.name === "nursing"
return (
)
}
function funnelLeoShape(
props: React.ComponentProps & { payload?: { name?: string } },
) {
const isPeak = props.payload?.name === "Applied"
return
}
const SCATTER_LEO_PEAK = { x: 90, y: 97 } as const
const activeIndexProps = (activeIndex: number | null) =>
activeIndex == null ? {} : ({ activeIndex } as Record)
type MiniMetric = {
label: string
value: string
trend?: "up" | "down" | "neutral"
/** Same semantics as `MetricItem.trendPolarity` on `KeyMetrics`. */
trendPolarity?: MetricTrendPolarity
}
/* ── Colour tokens ────────────────────────────────────────────────────────── */
const BRAND = "var(--brand-color)"
const CHART_1 = "var(--color-chart-1)"
const CHART_2 = "var(--color-chart-2)"
const CHART_3 = "var(--color-chart-3)"
const CHART_4 = "var(--color-chart-4)"
const CHART_5 = "var(--color-chart-5)"
const SUCCESS = "var(--chart-2)"
const WARNING = "var(--chart-4)"
const DESTRUCTIVE = "var(--destructive)"
/* ── Period filter options (reused across selector cards) ─────────────────── */
const PERIOD_OPTIONS = [
{ value: "7d", label: "Last 7 days" },
{ value: "30d", label: "Last 30 days" },
{ value: "90d", label: "Last quarter" },
{ value: "1y", label: "Last year" },
]
const PROGRAM_OPTIONS = [
{ value: "all", label: "All programs" },
{ value: "nursing", label: "Nursing" },
{ value: "pt", label: "PT" },
{ value: "ot", label: "OT" },
{ value: "pharmacy", label: "Pharmacy" },
]
/* ════════════════════════════════════════════════════════════════════════════
REUSABLE ChartCard — supports 3 variants
════════════════════════════════════════════════════════════════════════════ */
export type ChartCardVariant = "normal" | "tabs" | "selector" | "metrics-tabs" | "kpi-chart"
/** Shared line tab chrome — metrics-tabs is canonical; tabs variant uses the same shell. */
const chartCardLineTabsListClass =
"h-auto w-full gap-0 rounded-none justify-start !items-end"
const chartCardLineTabTriggerBaseClass =
"h-auto min-w-0 flex-none px-3 pt-2 pb-3 text-muted-foreground data-active:text-foreground"
const chartCardTabTriggerClass = cn(
chartCardLineTabTriggerBaseClass,
"flex-row items-center gap-2",
)
const chartCardMetricTabTriggerClass = cn(
chartCardLineTabTriggerBaseClass,
"flex-col items-start gap-1",
)
import {
type ChartLeoInsight,
type ChartLeoInsightAnchor,
type ChartLeoInsightKind,
} from "@/components/leo-insight-indicator"
import {
ChartLeoInsightOverlay,
ChartLeoPlotInsightOverlay,
} from "@/components/chart-leo-spotting"
export type { ChartLeoInsight, ChartLeoInsightAnchor, ChartLeoInsightKind }
export { ChartLeoPlotInsightOverlay, ChartLeoInsightOverlay, ChartLeoPixelPlotInsightOverlay } from "@/components/chart-leo-spotting"
/** Screen-reader data fallback for charts — shared with list-page dashboards. */
export function ChartDataTable({
caption,
headers,
rows,
}: {
caption: string
headers: string[]
rows: (string | number)[][]
}) {
return (
{caption}
{headers.map((h) =>
{h}
)}
{rows.map((row, i) => (
{row.map((cell, j) =>
{cell}
)}
))}
)
}
/**
* Keyboard-focusable chart region (arrow keys, Escape) + live announcement when a point is selected.
* Shared by the `/dashboard` gallery and **Data** view dashboards (Placements / Team / Compliance): same
* interaction model; visual differences come from `ChartCard` chrome and per-chart renderers (bar vs pie),
* not from a separate chart implementation.
*/
export function ChartFigure({
label,
summary,
dataLength,
leoInsight,
children,
}: {
label: string
summary: string
dataLength: number
/** Optional Ask-Leo insight context for chart bodies (same as `ChartCard`). */
leoInsight?: ChartLeoInsight | null
children: (activeIndex: number | null) => React.ReactNode
}) {
const [activeIndex, setActiveIndex] = React.useState(null)
const ref = React.useRef(null)
const prevActiveIndexRef = React.useRef(null)
React.useEffect(() => {
const prev = prevActiveIndexRef.current
prevActiveIndexRef.current = activeIndex
if (prev === null || activeIndex !== null) return
const wrapper = ref.current?.querySelector(".recharts-wrapper")
if (!wrapper) return
wrapper.dispatchEvent(
new MouseEvent("mouseleave", { bubbles: true, cancelable: true }),
)
}, [activeIndex])
const navigateKeys = React.useCallback(
(e: React.KeyboardEvent) => {
if (!dataLength) return
if (isEditableTarget(e.target)) return
switch (e.key) {
case "ArrowRight":
case "ArrowDown":
e.preventDefault()
e.stopPropagation()
setActiveIndex((i) => (i === null ? 0 : Math.min(i + 1, dataLength - 1)))
break
case "ArrowLeft":
case "ArrowUp":
e.preventDefault()
e.stopPropagation()
setActiveIndex((i) => (i === null ? dataLength - 1 : Math.max(i - 1, 0)))
break
case "Escape":
e.preventDefault()
e.stopPropagation()
setActiveIndex(null)
ref.current?.blur()
break
default:
break
}
},
[dataLength],
)
/** Clicks on Recharts SVG do not focus this node — focus so Arrow keys work without extra Tab stops. */
function handlePointerDownCapture(e: React.PointerEvent) {
if (!dataLength) return
const root = ref.current
if (!root?.contains(e.target as Node)) return
const el = e.target as HTMLElement | null
if (el?.closest?.("button, a, [role='tab'], [role='option'], input, select, textarea, [contenteditable='true']"))
return
queueMicrotask(() => root.focus())
}
return (
[d.name, d.value])} />
>
)}
)
}
const funnelLineTrendCfg: ChartConfig = {
applied: { label: "Applied", color: BRAND },
placed: { label: "Placed", color: CHART_4 },
completed: { label: "Completed", color: CHART_5 },
}
const funnelLineTrendData = [
{ month: "Oct", applied: 210, placed: 95, completed: 68 },
{ month: "Nov", applied: 245, placed: 108, completed: 82 },
{ month: "Dec", applied: 180, placed: 88, completed: 64 },
{ month: "Jan", applied: 280, placed: 120, completed: 91 },
{ month: "Feb", applied: 300, placed: 124, completed: 95 },
{ month: "Mar", applied: 320, placed: 128, completed: 98 },
]
function FunnelLineTrend() {
return (
} />
} />
)
}
export const CHART_GALLERY_LEO_DONUT: ChartLeoInsight = {
headline: "Confirmed placements dominate the current pipeline",
explanation:
"87% of placements are already confirmed, with only 9% pending and 4% in review. This is a healthy distribution suggesting strong conversion from applications to confirmed offers.",
kind: "spike",
delta: { value: "+12%", label: "vs. last month" },
bullets: [
"Confirmed count has grown steadily across nursing, PT, and OT programs.",
"Rejection rate remains low at 1% — applications are well-qualified.",
],
anchor: chartLeoPeakAnchor(),
}
export const CHART_GALLERY_LEO_APPLICATIONS: ChartLeoInsight = {
headline: "Nursing program leads application volume",
explanation:
"Nursing consistently attracts the most new applicants, with 34 this period. PT and OT follow closely. Returned applications suggest strong re-engagement.",
kind: "trend",
delta: { value: "+8%", label: "new vs. prior period" },
bullets: [
"Nursing: 34 new, 22 returned — highest volume and strong re-engagement.",
"PT and OT: steady demand — balanced load across clinical programs.",
],
anchor: { xValue: "Nursing", yDataKeys: ["new", "returned"], yCombine: "sum" },
}
export const CHART_GALLERY_LEO_LINE: ChartLeoInsight = {
headline: "Portal activity peaks mid-week",
explanation:
"Login, submission, and evaluation activity cluster around Tuesday–Thursday, with weekends showing predictable dips. This pattern is consistent and expected for an academic schedule.",
kind: "trend",
delta: { value: "—", label: "stable pattern" },
bullets: [
"Logins peak at ~450 on Wednesdays.",
"Submissions highest Monday–Friday, near-zero on weekends.",
],
anchor: { xValue: "W7", yDataKeys: ["logins"], yValue: 211 },
}
export const CHART_GALLERY_LEO_COMPLIANCE: ChartLeoInsight = {
headline: "PT/OT programs lead compliance scoring",
explanation:
"PT and OT average 88–89% compliance, outpacing Nursing (82%) and Pharmacy (76%). Radiology lags at 71% — may need targeted support.",
kind: "dip",
delta: { value: "-8%", label: "Radiology vs. PT/OT" },
bullets: [
"PT/OT: consistent excellence across all 6 dimensions.",
"Pharmacy: scoring gaps in documentation and timeliness.",
"Radiology: needs support in scheduling and follow-up processes.",
],
anchor: chartLeoPeakAnchor(),
}
export const CHART_GALLERY_LEO_HORIZONTAL: ChartLeoInsight = {
headline: "Large clinical sites carry most placements",
explanation:
"The three largest sites (University Hospital, Metro Clinic, Regional Center) account for 58% of all placements. Mid-size sites are under-utilized.",
kind: "anomaly",
delta: { value: "+22%", label: "top 3 sites total" },
bullets: [
"University Hospital: 156 placements (28% of total).",
"Capacity constraints may limit placement growth at smaller sites.",
],
anchor: { xValue: "City Med", yDataKeys: ["placements"] },
}
export const CHART_GALLERY_LEO_COMPOSED: ChartLeoInsight = {
headline: "Site capacity is healthy; fill rates peak Q2",
explanation:
"Most sites run 85–92% capacity utilization. Fill rate (placements / capacity) averages 78%, with spring months (Feb–Mar) consistently hitting 82%+.",
kind: "spike",
delta: { value: "+6%", label: "fill rate increase" },
bullets: [
"March shows the strongest fill rate at 84%.",
"Only 2 sites are below 70% utilization — opportunity to rebalance.",
],
anchor: { xValue: "Mar", yDataKeys: ["rate"], yValue: 94 },
}
export const CHART_GALLERY_LEO_RADAR: ChartLeoInsight = {
headline: "Nursing and PT/OT competencies are well-balanced",
explanation:
"Both programs score 80+ on all six dimensions. Nursing edges slightly on patient care; PT/OT lead in mobility and assessment. Ready for expanded placements.",
kind: "trend",
delta: { value: "—", label: "strong across programs" },
bullets: [
"6-dimension average: Nursing 84%, PT/OT 86%.",
"Lowest dimension: patient care (Nursing 79%) — room to develop.",
],
anchor: chartLeoPeakAnchor(),
}
export const CHART_GALLERY_LEO_SCATTER: ChartLeoInsight = {
headline: "Application-to-placement funnel is healthy",
explanation:
"Applications feed steadily into offers; offer-to-confirmation conversion hovers around 72%. A small number of dropouts from offer-to-start, typical for clinical placements.",
kind: "trend",
delta: { value: "+4%", label: "confirmation rate" },
bullets: [
"Applications → Offers: 63% convert (typical for competitive placements).",
"Offers → Confirmed: 72% accept (strong acceptance rate).",
],
anchor: chartLeoPeakAnchor(),
}
export const CHART_GALLERY_LEO_FUNNEL: ChartLeoInsight = {
headline: "Funnel shape is expected; strong at top of pipe",
explanation:
"4,200 applications narrow to 842 offers (20% funnel rate) and 604 confirmed placements (72% offer acceptance). Losses are proportional—no anomalous drops.",
kind: "trend",
delta: { value: "+8%", label: "application volume" },
bullets: [
"Application → Offer: drop-off is typical for screening.",
"Offer → Confirmed: acceptance rate of 72% is healthy.",
],
anchor: chartLeoPeakAnchor(),
}
export const CHART_GALLERY_LEO_QUOTA: ChartLeoInsight = {
headline: "Student performance tracking and cohort comparison",
explanation:
"Track individual student progress against class averages and scale benchmarks. Identify outliers above or below cohort norms.",
kind: "anomaly",
bullets: [
"Performance visualized on a consistent scale across cohorts.",
"Class average provides immediate context for comparison.",
],
anchor: chartLeoPeakAnchor(),
}
export const CHART_GALLERY_LEO_TRENDS: ChartLeoInsight = {
headline: "December dips across placements, applications, and reviews",
explanation:
"All three series pull back in December—often seasonal (holidays, academic breaks) or a real pipeline stall. Worth confirming whether approvals or site capacity paused.",
kind: "dip",
delta: { value: "-24%", label: "vs. November" },
bullets: [
"Placements are 18% below the 6-month trailing average.",
"Reviews dropped sharply in the last 2 weeks of the month.",
"Same pattern appeared in Dec '24 — seasonal signal is plausible.",
],
anchor: {
xValue: "Dec",
yDataKeys: ["placements", "applications", "reviews"],
yCombine: "max",
},
}
export const CHART_GALLERY_LEO_REVIEWS: ChartLeoInsight = {
headline: "December is the low point in review throughput",
explanation:
"Totals drop before recovering — worth confirming whether fewer submissions arrived or reviewers were out. Pending and rejected slices still matter once volume returns.",
kind: "dip",
delta: { value: "-31%", label: "vs. November total" },
bullets: [
"Approved reviews fell from 68 to 47 month-over-month.",
"Pending queue grew by 9 items — backlog forming.",
"Two reviewers were OOO for most of the last two weeks.",
],
anchor: {
xValue: "Dec",
yDataKeys: ["approved", "pending", "rejected"],
yCombine: "sum",
},
}
export const CHART_GALLERY_LEO_WATERFALL: ChartLeoInsight = {
headline: "New placements drove the largest positive step this cycle",
explanation:
"After opening at 120 placements, the New step adds 44 — the biggest single increase before renewals and closures adjust the total to 158.",
kind: "spike",
delta: { value: "+37%", label: "vs. opening total" },
bullets: [
"New (+44) is the largest upward move in the sequence.",
"Expired (−22) and Closed (−15) are the main reductions after growth.",
],
anchor: { xValue: "New", yDataKeys: ["total"] },
}
export const CHART_GALLERY_LEO_TREEMAP: ChartLeoInsight = {
headline: "Nursing occupies the largest share of program volume",
explanation:
"The treemap shows Nursing at 34 — roughly one-third of the combined program share — with PT and OT as the next largest tiles.",
kind: "trend",
delta: { value: "34", label: "Nursing share" },
bullets: [
"Tile area tracks proportional share — Nursing leads by a wide margin.",
"Smaller programs still appear as readable tiles with labels.",
],
anchor: chartLeoPeakAnchor(),
}
export const CHART_GALLERY_LEO_HEATMAP: ChartLeoInsight = {
headline: "Wednesday afternoon shows the busiest activity window",
explanation:
"Mid-week cells around 12p–2p run hotter than early-morning slots — useful when scheduling coordinator coverage.",
kind: "spike",
delta: { value: "53", label: "Wed 2p peak" },
bullets: [
"Peak intensity clusters on Wed between 12p and 2p.",
"Monday mornings stay lighter — good for async review work.",
],
anchor: chartLeoPeakAnchor(),
}
export const CHART_GALLERY_LEO_SANKEY: ChartLeoInsight = {
headline: "Screening retains most of the application volume",
explanation:
"240 applications flow into screening; the widest link is Applied → Screened, with expected tapering through placement stages.",
kind: "trend",
delta: { value: "240", label: "Applied → Screened" },
bullets: [
"Applied → Screened is the highest-volume transition.",
"Placed → Completed shows healthy completion conversion.",
],
anchor: chartLeoPeakAnchor(),
}
export const CHART_GALLERY_LEO_TIMELINE: ChartLeoInsight = {
headline: "Placement completes around day 56 on average",
explanation:
"Milestones cluster on a single lane with the longest gap between Placed and Complete — worth monitoring for cycle-time SLAs.",
kind: "trend",
delta: { value: "56d", label: "to complete" },
bullets: [
"Matched → Placed spans ~13 days in this sample.",
"Complete lands near day 56 — use for cohort planning.",
],
anchor: { xValue: "56", yDataKeys: ["lane"], yValue: 1 },
}
export const CHART_GALLERY_LEO_BUBBLE: ChartLeoInsight = {
headline: "West region combines high fill rate with strong student volume",
explanation:
"The West bubble sits high on fill rate (82%) with 52 students — an efficient site cluster worth replicating.",
kind: "spike",
bullets: [
"West: 82% fill rate at mid-range capacity.",
"South shows low fill despite available capacity — investigate demand.",
],
anchor: { xValue: "West", yDataKeys: ["fillRate"], yValue: 82 },
}
export const CHART_GALLERY_LEO_BOXPLOT: ChartLeoInsight = {
headline: "Nursing scores show the tightest interquartile spread",
explanation:
"Nursing’s Q1–Q3 band is narrow relative to Pharm — consistent performance across the cohort.",
kind: "trend",
delta: { value: "Q1–Q3", label: "Nursing band" },
bullets: [
"Nursing median sits highest in the set.",
"Pharm shows the widest whisker span — more variance to investigate.",
],
anchor: { xValue: "Nursing", yDataKeys: ["median"] },
}
export const CHART_GALLERY_LEO_RANGE: ChartLeoInsight = {
headline: "May shows the widest capacity band in the series",
explanation:
"The range between low and high capacity peaks in May — plan staffing for the upper band while monitoring June tightening.",
kind: "trend",
delta: { value: "91", label: "May high" },
bullets: [
"May high band reaches 91 on this scale.",
"June compresses slightly — confirm whether demand eased.",
],
anchor: { xValue: "May", yDataKeys: ["high"] },
}
export const CHART_GALLERY_LEO_BULLET: ChartLeoInsight = {
headline: "Compliance is closest to its target of the three KPIs",
explanation:
"Compliance at 86% is 6 points under the 92% target — narrower gap than Placements or Reviews.",
kind: "dip",
delta: { value: "−6 pts", label: "vs. compliance target" },
bullets: [
"Compliance: 86 actual vs. 92 target.",
"Reviews trail furthest — prioritize review throughput.",
],
anchor: { xValue: "Compliance", yDataKeys: ["value"], yValue: 86 },
}
/** Design-system Chart / ChartCard previews — plot-anchored Leo on monthly bar data. */
export const CATALOG_PREVIEW_CHART_LEO: ChartLeoInsight = {
headline: "March is the strongest placement month in this window",
explanation:
"Placements rose from 42 in January to 74 in March before easing in April. The spike suggests strong spring cohort activity worth sustaining.",
kind: "spike",
delta: { value: "+76%", label: "vs. January" },
bullets: [
"March leads the five-month series at 74 placements.",
"April dips slightly; monitor whether offers converted on schedule.",
],
anchor: { xValue: "Mar", yDataKeys: ["placements"] },
}
function ChartRows({ v }: { v: ChartCardVariant }) {
const isTabs = v === "tabs"
const isSel = v === "selector"
const isMT = v === "metrics-tabs"
const isKpi = v === "kpi-chart"
return (
<>
{/* Row 1 · Area (2/3) + Donut (1/3) */}