- Ocurrió un error en el dashboard
+ Ocurrió un error en la aplicación
Intenta nuevamente o vuelve al inicio. Si el problema persiste,
diff --git a/src/app/dashboard/menu-items/[action]/[id]/variant-form.tsx b/src/app/dashboard/menu-items/[action]/[id]/variant-form.tsx
index 10d20c45..b0bcc7f6 100644
--- a/src/app/dashboard/menu-items/[action]/[id]/variant-form.tsx
+++ b/src/app/dashboard/menu-items/[action]/[id]/variant-form.tsx
@@ -8,7 +8,7 @@ import {
type UseFormReturn
} from "react-hook-form"
import { Trash } from "lucide-react"
-import { z } from "zod/v4"
+import { type z } from "zod/v4"
import { Button } from "@/components/ui/button"
import { Field, FieldError, FieldLabel } from "@/components/ui/field"
@@ -22,7 +22,7 @@ import {
TableRow
} from "@/components/ui/table"
import VariantDelete from "@/app/dashboard/menu-items/[action]/[id]/variant-delete"
-import { menuItemFormSchema } from "@/lib/types/menu-item"
+import { type menuItemFormSchema } from "@/lib/types/menu-item"
type VariantFormValues = z.infer
diff --git a/src/app/dashboard/menu-items/import-options.tsx b/src/app/dashboard/menu-items/import-options.tsx
index 99b3ee5d..22cad5bf 100644
--- a/src/app/dashboard/menu-items/import-options.tsx
+++ b/src/app/dashboard/menu-items/import-options.tsx
@@ -15,10 +15,10 @@ import { bulkCreateItems } from "@/server/actions/item/mutations"
import { MenuItemStatus, type BulkMenuItem } from "@/lib/types/menu-item"
type CSVRow = {
- nombre: string
+ nombre?: string
variante?: string
descripcion?: string
- precio: string
+ precio?: string
categoria?: string
moneda?: string
}
@@ -28,6 +28,130 @@ type ImportError = {
errors: string[]
}
+type CanonicalHeader = keyof CSVRow
+
+const HEADER_ALIASES: Record = {
+ nombre: ["nombre", "name", "producto", "product", "productname", "item"],
+ variante: ["variante", "variant", "variantname", "size"],
+ descripcion: ["descripcion", "description", "desc", "detalle", "detalles"],
+ precio: ["precio", "price", "unitprice", "pricevalue", "unitpricevalue"],
+ categoria: ["categoria", "category", "group", "section", "family"],
+ moneda: ["moneda", "currency", "currencycode", "curr"]
+}
+
+function normalizeHeaderValue(header: string) {
+ return header
+ .trim()
+ .replace(/^\uFEFF/, "")
+ .toLowerCase()
+ .normalize("NFKD")
+ .replace(/[\u0300-\u036f]/g, "")
+ .replace(/[^a-z0-9]+/g, "")
+}
+
+function normalizeHeader(header: string) {
+ const normalized = normalizeHeaderValue(header)
+
+ for (const [canonical, aliases] of Object.entries(HEADER_ALIASES)) {
+ if (aliases.some(alias => normalizeHeaderValue(alias) === normalized)) {
+ return canonical
+ }
+ }
+
+ return normalized
+}
+
+function parsePriceValue(value: string | undefined) {
+ if (!value) {
+ return undefined
+ }
+
+ const normalized = value.trim().replace(/\s/g, "")
+ if (!normalized) {
+ return undefined
+ }
+
+ const numericValue = normalized.replace(/[^\d,.-]/g, "")
+ if (!numericValue) {
+ return undefined
+ }
+
+ const hasComma = numericValue.includes(",")
+ const hasDot = numericValue.includes(".")
+
+ if (hasComma && hasDot) {
+ const lastComma = numericValue.lastIndexOf(",")
+ const lastDot = numericValue.lastIndexOf(".")
+ const decimalSeparator = lastComma > lastDot ? "," : "."
+ const sanitized = numericValue.replace(/[,.]/g, char =>
+ char === decimalSeparator ? "." : ""
+ )
+ return Number.parseFloat(sanitized)
+ }
+
+ if (hasComma) {
+ return Number.parseFloat(numericValue.replace(",", "."))
+ }
+
+ return Number.parseFloat(numericValue)
+}
+
+function detectDelimiter(text: string) {
+ const sample = text.slice(0, 5000)
+ const counts = [",", ";", "\t"].map(delimiter => ({
+ delimiter,
+ count: (sample.match(new RegExp(`\\${delimiter}`, "g")) ?? []).length
+ }))
+
+ return (
+ counts.sort((left, right) => right.count - left.count)[0]?.delimiter ?? ","
+ )
+}
+
+function parseCsvRows(text: string) {
+ const delimiter = detectDelimiter(text)
+ const results = Papa.parse>(text, {
+ header: true,
+ skipEmptyLines: true,
+ delimiter,
+ transformHeader: header => normalizeHeader(header),
+ dynamicTyping: false
+ })
+
+ if (results.errors.length > 0) {
+ const firstError = results.errors[0]
+ return {
+ error: firstError?.message
+ ? `No pudimos procesar el archivo CSV: ${firstError.message}`
+ : "No pudimos procesar el archivo CSV"
+ }
+ }
+
+ const fields = results.meta.fields ?? []
+ const hasRequiredColumns =
+ fields.includes("nombre") && fields.includes("precio")
+
+ if (!hasRequiredColumns) {
+ return {
+ error:
+ "El archivo debe incluir columnas de nombre y precio. Usa la plantilla descargable para garantizar el formato correcto."
+ }
+ }
+
+ const rows = (results.data as Array>).map(
+ row => ({
+ nombre: row.nombre?.trim(),
+ variante: row.variante?.trim(),
+ descripcion: row.descripcion?.trim(),
+ precio: row.precio?.trim(),
+ categoria: row.categoria?.trim(),
+ moneda: row.moneda?.trim()
+ })
+ )
+
+ return { rows }
+}
+
function downloadCsvFile(rows: CSVRow[], fileName: string) {
const csv = Papa.unparse(rows)
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" })
@@ -74,8 +198,8 @@ function validateRow(row: CSVRow): string[] {
if (!row.precio) {
errors.push("El precio es requerido")
} else {
- const price = parseFloat(row.precio)
- if (isNaN(price) || price < 0) {
+ const price = parsePriceValue(row.precio)
+ if (typeof price !== "number" || Number.isNaN(price) || price < 0) {
errors.push("El precio debe ser un número positivo")
}
}
@@ -123,7 +247,9 @@ export default function MenuImportOptions({
}
})
- const handleFileUpload = (event: React.ChangeEvent) => {
+ const handleFileUpload = async (
+ event: React.ChangeEvent
+ ) => {
const file = event.target.files?.[0]
if (!file) {
@@ -132,69 +258,82 @@ export default function MenuImportOptions({
setErrors([])
- Papa.parse(file, {
- header: true,
- skipEmptyLines: true,
- encoding: "utf-8",
- complete: results => {
- if (results.data.length === 0) {
- setErrors([{ row: 0, errors: ["El archivo está vacío"] }])
- return
- }
+ try {
+ const text = await file.text()
+ const parsed = parseCsvRows(text)
- if (results.data.length > 200) {
- setErrors([
- {
- row: 0,
- errors: ["No puedes importar más de 200 productos a la vez"]
- }
- ])
- return
- }
+ if ("error" in parsed && parsed.error) {
+ setErrors([{ row: 0, errors: [parsed.error] }])
+ event.target.value = ""
+ return
+ }
- const foundErrors: ImportError[] = []
- const validItems: BulkMenuItem[] = []
+ const rows = parsed.rows ?? []
- results.data.forEach((row, index) => {
- const rowErrors = validateRow(row)
+ if (rows.length === 0) {
+ setErrors([{ row: 0, errors: ["El archivo está vacío"] }])
+ event.target.value = ""
+ return
+ }
- if (rowErrors.length > 0) {
- foundErrors.push({
- row: index + 1,
- errors: rowErrors
- })
- return
+ if (rows.length > 200) {
+ setErrors([
+ {
+ row: 0,
+ errors: ["No puedes importar más de 200 productos a la vez"]
}
+ ])
+ event.target.value = ""
+ return
+ }
- const currency = (row.moneda ?? "MXN").trim().toUpperCase()
+ const foundErrors: ImportError[] = []
+ const validItems: BulkMenuItem[] = []
- validItems.push({
- name: row.nombre,
- variantName: row.variante?.trim() || undefined,
- description: row.descripcion,
- price: parseFloat(row.precio),
- status: MenuItemStatus.ACTIVE,
- category: row.categoria,
- currency: currency === "USD" ? "USD" : "MXN"
- })
- })
+ rows.forEach((row, index) => {
+ const rowErrors = validateRow(row)
- if (foundErrors.length > 0) {
- setErrors(foundErrors)
+ if (rowErrors.length > 0) {
+ foundErrors.push({
+ row: index + 1,
+ errors: rowErrors
+ })
return
}
- execute(validItems)
- },
- error: error => {
- setErrors([
- {
- row: 0,
- errors: [`No pudimos procesar el archivo CSV: ${error.message}`]
- }
- ])
+ const price = parsePriceValue(row.precio)
+ const currency = (row.moneda ?? "MXN").trim().toUpperCase()
+
+ validItems.push({
+ name: row.nombre ?? "",
+ variantName: row.variante?.trim() || undefined,
+ description: row.descripcion,
+ price: typeof price === "number" ? price : 0,
+ status: MenuItemStatus.ACTIVE,
+ category: row.categoria,
+ currency: currency === "USD" ? "USD" : "MXN"
+ })
+ })
+
+ if (foundErrors.length > 0) {
+ setErrors(foundErrors)
+ event.target.value = ""
+ return
}
- })
+
+ execute(validItems)
+ } catch (error) {
+ setErrors([
+ {
+ row: 0,
+ errors: [
+ `No pudimos procesar el archivo CSV: ${error instanceof Error ? error.message : "Error desconocido"}`
+ ]
+ }
+ ])
+ } finally {
+ event.target.value = ""
+ }
}
return (
@@ -215,7 +354,7 @@ export default function MenuImportOptions({
dark:ring-white/15"
>
-
+
Importar menú desde PDF o imagen con IA
@@ -239,7 +378,7 @@ export default function MenuImportOptions({
shadow-sm"
>
-
+
Importar desde CSV
@@ -270,8 +409,8 @@ export default function MenuImportOptions({
onChange={handleFileUpload}
disabled={isPending}
aria-busy={isPending}
- className="border-border bg-background file:bg-primary
- file:text-primary-foreground hover:file:bg-primary/90
+ className="border-border bg-background file:bg-secondary
+ file:text-secondary-foreground hover:file:bg-secondary/90
focus-visible:ring-ring block w-full cursor-pointer rounded-lg
border text-sm shadow-sm file:mr-4 file:cursor-pointer
file:rounded-md file:border-0 file:px-4 file:py-2 file:text-sm
diff --git a/src/app/dashboard/menu-items/translations/translations-manager.tsx b/src/app/dashboard/menu-items/translations/translations-manager.tsx
index 5714507d..7f11aac8 100644
--- a/src/app/dashboard/menu-items/translations/translations-manager.tsx
+++ b/src/app/dashboard/menu-items/translations/translations-manager.tsx
@@ -4,7 +4,7 @@ import { useState } from "react"
import toast from "react-hot-toast"
import * as Sentry from "@sentry/nextjs"
import {
- CircleFadingArrowUp,
+ Crown,
Languages,
Loader,
PlusCircle,
@@ -327,11 +327,10 @@ export default function TranslationsManager({
{!isPro && availableToAdd.length > 0 && (
diff --git a/src/app/dashboard/sales/closing/page.tsx b/src/app/dashboard/sales/closing/page.tsx
new file mode 100644
index 00000000..d0cd1062
--- /dev/null
+++ b/src/app/dashboard/sales/closing/page.tsx
@@ -0,0 +1,72 @@
+import { ReceiptText } from "lucide-react"
+import type { Metadata } from "next"
+import { notFound } from "next/navigation"
+import { createLoader, parseAsString } from "nuqs/server"
+
+import PageSubtitle from "@/components/dashboard/page-subtitle"
+import { SalesClosingReport } from "@/components/sales/sales-closing"
+import { SalesClosingDateFilter } from "@/components/sales/sales-closing-date-filter"
+import { SalesClosingExportButton } from "@/components/sales/sales-closing-export"
+import { SalesProBanner } from "@/components/sales/sales-pro-banner"
+import { getSalesClosingData } from "@/server/actions/sales/queries"
+import {
+ getCurrentOrganization,
+ isProMember
+} from "@/server/actions/user/queries"
+import {
+ getSalesClosingDateValue,
+ resolveSalesClosingDateValue
+} from "@/lib/sales-closing-date"
+
+export const metadata: Metadata = {
+ title: "Cierre diario"
+}
+
+const loadSalesClosingSearchParams = createLoader({
+ date: parseAsString.withDefault(getSalesClosingDateValue())
+})
+
+export default async function SalesClosingPage(props: {
+ searchParams: Promise<{ [key: string]: string | string[] | undefined }>
+}) {
+ const [{ date }, currentOrg, isPro] = await Promise.all([
+ loadSalesClosingSearchParams(props.searchParams),
+ getCurrentOrganization(),
+ isProMember()
+ ])
+
+ if (!currentOrg) {
+ notFound()
+ }
+
+ const selectedDateValue = resolveSalesClosingDateValue(date)
+ const data = await getSalesClosingData(currentOrg.id, selectedDateValue)
+
+ return (
+
+
+
+ Cierre diario
+
+ Reporte de fin de jornada para restaurante
+
+
+
+
+
+
+
+
+
+ {!isPro &&
}
+
+
+
+ )
+}
diff --git a/src/app/dashboard/sales/loading.tsx b/src/app/dashboard/sales/loading.tsx
new file mode 100644
index 00000000..783d74c4
--- /dev/null
+++ b/src/app/dashboard/sales/loading.tsx
@@ -0,0 +1,143 @@
+import { Skeleton } from "@/components/ui/skeleton"
+
+export default function Loading() {
+ return (
+
+
+
+
+
+
+
+
+ {Array.from({ length: 4 }).map((_, index) => (
+
+ ))}
+
+
+
+
+
+
+
+ {Array.from({ length: 4 }).map((_, index) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+ {Array.from({ length: 6 }).map((_, index) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {Array.from({ length: 4 }).map((_, index) => (
+
+ ))}
+
+ {Array.from({ length: 5 }).map((_, rowIndex) => (
+
+
+
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {Array.from({ length: 5 }).map((_, index) => (
+
+ ))}
+
+
+
+
+ )
+}
diff --git a/src/app/dashboard/sales/new/page.tsx b/src/app/dashboard/sales/new/page.tsx
new file mode 100644
index 00000000..808b7731
--- /dev/null
+++ b/src/app/dashboard/sales/new/page.tsx
@@ -0,0 +1,36 @@
+import type { Metadata } from "next"
+import { notFound } from "next/navigation"
+
+import { QuickSaleScreen } from "@/components/sales/quick-sale-screen"
+import { SalesProBanner } from "@/components/sales/sales-pro-banner"
+import { getSalesCatalog } from "@/server/actions/sales/queries"
+import {
+ getCurrentOrganization,
+ isProMember
+} from "@/server/actions/user/queries"
+
+export const metadata: Metadata = {
+ title: "Nueva venta"
+}
+
+export default async function NewSalePage() {
+ const [currentOrg, isPro] = await Promise.all([
+ getCurrentOrganization(),
+ isProMember()
+ ])
+
+ if (!currentOrg) {
+ notFound()
+ }
+
+ const catalog = await getSalesCatalog(currentOrg.id)
+
+ return (
+
+ {!isPro && }
+
+
+ )
+}
diff --git a/src/app/dashboard/sales/page.tsx b/src/app/dashboard/sales/page.tsx
new file mode 100644
index 00000000..14867fd6
--- /dev/null
+++ b/src/app/dashboard/sales/page.tsx
@@ -0,0 +1,70 @@
+import { Banknote } from "lucide-react"
+import type { Metadata } from "next"
+import { notFound } from "next/navigation"
+import { createLoader, parseAsStringEnum } from "nuqs/server"
+
+import PageSubtitle from "@/components/dashboard/page-subtitle"
+import { SalesDashboard } from "@/components/sales/sales-dashboard"
+import { SalesDashboardPeriodFilter } from "@/components/sales/sales-dashboard-period-filter"
+import { SalesProBanner } from "@/components/sales/sales-pro-banner"
+import { getSalesDashboardData } from "@/server/actions/sales/queries"
+import {
+ getCurrentOrganization,
+ isProMember
+} from "@/server/actions/user/queries"
+import {
+ defaultSalesDashboardPeriod,
+ salesDashboardPeriodValues
+} from "@/lib/sales-dashboard-period"
+
+export const metadata: Metadata = {
+ title: "Ventas"
+}
+
+const loadSalesDashboardSearchParams = createLoader({
+ period: parseAsStringEnum([...salesDashboardPeriodValues]).withDefault(
+ defaultSalesDashboardPeriod
+ )
+})
+
+export default async function SalesPage(props: {
+ searchParams: Promise<{ [key: string]: string | string[] | undefined }>
+}) {
+ const [{ period }, currentOrg, isPro] = await Promise.all([
+ loadSalesDashboardSearchParams(props.searchParams),
+ getCurrentOrganization(),
+ isProMember()
+ ])
+
+ if (!currentOrg) {
+ notFound()
+ }
+
+ const data = await getSalesDashboardData(currentOrg.id, period)
+
+ return (
+
+
+
+ Ventas
+
+ Resumen de ventas y actividad reciente
+
+
+
+
+
+
+ {!isPro &&
}
+
+
+
+ )
+}
diff --git a/src/app/dashboard/settings/billing/basic-plan-view.tsx b/src/app/dashboard/settings/billing/basic-plan-view.tsx
index ad7e8168..be327ae9 100644
--- a/src/app/dashboard/settings/billing/basic-plan-view.tsx
+++ b/src/app/dashboard/settings/billing/basic-plan-view.tsx
@@ -66,18 +66,16 @@ export function BasicPlanView({ itemCount }: { itemCount: number }) {
value={itemCount * 10}
showAnimation
size="sm"
- primary="oklch(51.1% 0.262 276.966)"
+ primary="oklch(68.5% 0.169 237.323)"
secondary={theme.resolvedTheme === "dark" ? "#27272a" : "#cfcfcf"}
/>
-
+
Has consumido {itemCount} de los {appConfig.itemLimit} productos
disponibles en tu plan
-
+
Actualiza tu plan para agregar más productos a tu menú y disfrutar
de más beneficios.
@@ -91,19 +89,19 @@ export function BasicPlanView({ itemCount }: { itemCount: number }) {
onValueChange={value =>
value && setBillingInterval(value as "monthly" | "yearly")
}
- className="w-full justify-center gap-0.5 rounded-lg border
- border-gray-200 p-1 dark:border-gray-800"
+ className="border-border w-full justify-center gap-0.5 rounded-lg
+ border p-1"
>
Mensual
Anual
@@ -122,7 +120,7 @@ export function BasicPlanView({ itemCount }: { itemCount: number }) {
key={tier.id}
className={
tier.id === Plan.PRO
- ? "border-indigo-500 dark:border-indigo-500"
+ ? "border-primary dark:border-primary"
: "hidden border-dashed sm:block"
}
>
@@ -138,7 +136,9 @@ export function BasicPlanView({ itemCount }: { itemCount: number }) {
: `${new Intl.NumberFormat("es-MX", { style: "currency", currency: "MXN" }).format(tier.priceYearly)} MXN/año`}
) : (
-
Gratis
+
+ Gratis
+
)}
diff --git a/src/app/dashboard/settings/billing/page.tsx b/src/app/dashboard/settings/billing/page.tsx
index f198a86f..2ad01d6f 100644
--- a/src/app/dashboard/settings/billing/page.tsx
+++ b/src/app/dashboard/settings/billing/page.tsx
@@ -16,6 +16,7 @@ import {
import { BasicPlanView } from "@/app/dashboard/settings/billing/basic-plan-view"
import { ProPlanView } from "@/app/dashboard/settings/billing/pro-plan-view"
import RevalidateStatus from "@/app/dashboard/settings/billing/revalidate-status"
+import { isStripeBillingConfigured } from "@/lib/auth"
import { MembershipRole } from "@/lib/types/organization"
export const metadata: Metadata = {
@@ -30,6 +31,7 @@ export default async function BillingPage() {
getItemCount(),
getCurrentOrganization()
])
+ const billingEnabled = subsEnabled && isStripeBillingConfigured
if (!currentOrg) {
return notFound()
@@ -44,7 +46,7 @@ export default async function BillingPage() {
Maneja tu plan de suscripción e historial de pagos
- {role === MembershipRole.OWNER && subsEnabled ? (
+ {role === MembershipRole.OWNER && billingEnabled ? (
{isPro ? (
}>
@@ -64,8 +66,9 @@ export default async function BillingPage() {
Aviso
- Solo los miembros propietarios de la organización pueden acceder a
- esta página
+ {role === MembershipRole.OWNER && subsEnabled
+ ? "La facturación con Stripe no está configurada en este entorno, así que no se puede iniciar el checkout."
+ : "Solo los miembros propietarios de la organización pueden acceder a esta página"}
)}
diff --git a/src/app/dashboard/settings/billing/pro-plan-view.tsx b/src/app/dashboard/settings/billing/pro-plan-view.tsx
index 50fcd862..75511de4 100644
--- a/src/app/dashboard/settings/billing/pro-plan-view.tsx
+++ b/src/app/dashboard/settings/billing/pro-plan-view.tsx
@@ -41,14 +41,11 @@ export async function ProPlanView() {
}
return (
-
+
Plan
-
+
PRO
{(() => {
switch (subscription.status) {
@@ -75,7 +72,7 @@ export async function ProPlanView() {
}
})()}
{subscription.status === "trialing" && (
-
+
- Termina el{" "}
{subscription?.trialEnd
? new Date(subscription.trialEnd).toLocaleDateString(
@@ -95,7 +92,7 @@ export async function ProPlanView() {
{/* */}
-
Precio
+
Precio
{(() => {
const tier = Tiers.find(
@@ -114,7 +111,9 @@ export async function ProPlanView() {
-
Periodo activo
+
+ Periodo activo
+
{subscription?.periodStart
? new Date(subscription.periodStart).toLocaleDateString(
@@ -130,7 +129,7 @@ export async function ProPlanView() {
{subscription?.cancelAtPeriodEnd ? (
-
Cancela el
+
Cancela el
{subscription?.periodEnd
? new Date(subscription.periodEnd).toLocaleDateString(
@@ -146,7 +145,9 @@ export async function ProPlanView() {
) : (
-
Próxima renovación
+
+ Próxima renovación
+
{subscription?.periodEnd
? new Date(subscription.periodEnd).toLocaleDateString(
@@ -186,17 +187,17 @@ export async function ProPlanView() {
-
+
Maneja tu suscripción en Stripe
-
+
¿Tienes algún problema con tu suscripción? Envía un correo a{" "}
contacto@biztro.co
diff --git a/src/components/charts/animation.ts b/src/components/charts/animation.ts
new file mode 100644
index 00000000..7286c7c4
--- /dev/null
+++ b/src/components/charts/animation.ts
@@ -0,0 +1,36 @@
+import type { Transition } from "motion/react"
+
+/** Default clip-reveal easing for cartesian charts. */
+export const DEFAULT_ANIMATION_EASING = "cubic-bezier(0.85, 0, 0.15, 1)"
+
+export const DEFAULT_ANIMATION_DURATION_MS = 1100
+
+/** Default enter transition — matches the original line chart reveal. */
+export const DEFAULT_CHART_ENTER_TRANSITION: Transition = {
+ type: "tween",
+ duration: DEFAULT_ANIMATION_DURATION_MS / 1000,
+ ease: [0.85, 0, 0.15, 1]
+}
+
+/**
+ * Clip-path width reveal must use tween — spring does not reliably animate SVG width.
+ */
+export function clipRevealTransition(enterTransition?: Transition): Transition {
+ if (enterTransition?.type === "tween") {
+ return {
+ ...enterTransition,
+ ease: enterTransition.ease ?? DEFAULT_CHART_ENTER_TRANSITION.ease
+ }
+ }
+
+ const duration =
+ typeof enterTransition?.duration === "number"
+ ? enterTransition.duration
+ : DEFAULT_ANIMATION_DURATION_MS / 1000
+
+ return {
+ type: "tween",
+ duration,
+ ease: DEFAULT_CHART_ENTER_TRANSITION.ease
+ }
+}
diff --git a/src/components/charts/bar-chart-loading.tsx b/src/components/charts/bar-chart-loading.tsx
new file mode 100644
index 00000000..e521a084
--- /dev/null
+++ b/src/components/charts/bar-chart-loading.tsx
@@ -0,0 +1,38 @@
+"use client"
+
+import { BarChart } from "./bar-chart"
+import type { Margin } from "./chart-context"
+
+const EMPTY_DATA: Record
[] = []
+
+export interface BarChartLoadingProps {
+ /** Chart margins. */
+ margin?: Partial
+ /** Aspect ratio as "width / height". Default: "2 / 1" */
+ aspectRatio?: string
+ /** Additional class name for the container. */
+ className?: string
+}
+
+/**
+ * Turnkey loading skeleton for bar charts, a thin shortcut for
+ * ` `. Renders shimmer-swept placeholder bars while
+ * data is fetching; swap in a real `` once it resolves.
+ */
+export function BarChartLoading({
+ margin,
+ aspectRatio = "2 / 1",
+ className = ""
+}: BarChartLoadingProps) {
+ return (
+
+ )
+}
+
+export default BarChartLoading
diff --git a/src/components/charts/bar-chart.tsx b/src/components/charts/bar-chart.tsx
new file mode 100644
index 00000000..fa6bdb9a
--- /dev/null
+++ b/src/components/charts/bar-chart.tsx
@@ -0,0 +1,705 @@
+"use client"
+
+import {
+ memo,
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type ReactElement,
+ type ReactNode
+} from "react"
+import { localPoint } from "@visx/event"
+import { ParentSize } from "@visx/responsive"
+import { scaleBand, scaleLinear } from "@visx/scale"
+import type { Transition } from "motion/react"
+
+import { cn } from "@/lib/utils"
+import { DEFAULT_ANIMATION_EASING } from "./animation"
+import type { BarProps } from "./bar"
+import {
+ forEachChartChild,
+ isChartClipPassthrough,
+ isClipExcludedComponent,
+ isPostOverlayComponent,
+ isUnderlayComponent,
+ renderKeyedChartLayers,
+ resolveChartChildElement
+} from "./chart-child-passthrough"
+import {
+ ChartProvider,
+ type ChartContextValue,
+ type LineConfig,
+ type Margin,
+ type TooltipData
+} from "./chart-context"
+import { isGradientDefComponent, isPatternDefComponent } from "./chart-defs"
+import { shortDateFmt } from "./chart-formatters"
+import {
+ DEFAULT_CHART_LIFECYCLE,
+ resolveRestingChartPhase,
+ type ChartPhase,
+ type ChartStatus
+} from "./chart-phase"
+import { BarLoadingSkeleton } from "./loading-sweep"
+import { extractReferenceAreaConfigs } from "./reference-area-config"
+import { useScheduledTooltip } from "./use-scheduled-tooltip"
+import {
+ buildYScalesForLines,
+ getPrimaryYScale,
+ normalizeYAxisId,
+ wrapSingleYScale
+} from "./y-axis-scales"
+
+/** Skeleton bars to show when `status="loading"` and `data` is empty. */
+const FALLBACK_LOADING_BARS = 12
+
+export type BarOrientation = "vertical" | "horizontal"
+
+export interface BarChartProps {
+ /** Data array - each item should have an x-axis key and numeric values */
+ data: Record[]
+ /** Key in data for the categorical axis. Default: "name" */
+ xDataKey?: string
+ /** Chart margins */
+ margin?: Partial
+ /** Animation duration in milliseconds. Default: 1100 */
+ animationDuration?: number
+ /** CSS easing for bar grow transitions. */
+ animationEasing?: string
+ /** Motion enter transition (spring or cubic-bezier tween). */
+ enterTransition?: Transition
+ /** Signature of motion URL state — triggers enter replay when it changes. */
+ revealSignature?: string
+ /** Aspect ratio as "width / height". Default: "2 / 1" */
+ aspectRatio?: string
+ /** Additional class name for the container */
+ className?: string
+ /** Gap between bar groups as a fraction of band width (0-1). Default: 0.2 */
+ barGap?: number
+ /** Fixed bar width in pixels. If not set, bars auto-size to fill the band. */
+ barWidth?: number
+ /** Bar chart orientation. Default: "vertical" */
+ orientation?: BarOrientation
+ /** Whether to stack bars instead of grouping them. Default: false */
+ stacked?: boolean
+ /** Gap between stacked bar segments in pixels. Default: 0 */
+ stackGap?: number
+ /** Child components (Bar, Grid, ChartTooltip, etc.). Optional — omit for a
+ * pure `status="loading"` skeleton. */
+ children?: ReactNode
+ /** Reports reveal lifecycle for OG screenshots and loading orchestration. */
+ onPhaseChange?: (phase: ChartPhase) => void
+ /** Fetch / display status. When `"loading"`, a shimmer skeleton replaces the
+ * bars (no chart data required). Default: `"ready"`. */
+ status?: ChartStatus
+}
+
+const DEFAULT_MARGIN: Margin = { top: 40, right: 40, bottom: 40, left: 40 }
+
+// Extract bar configs from children synchronously
+function extractBarConfigs(children: ReactNode): LineConfig[] {
+ const configs: LineConfig[] = []
+
+ forEachChartChild(children, child => {
+ const childType = child.type as {
+ displayName?: string
+ name?: string
+ __isBarDepthLayer?: boolean
+ }
+ // Bar-depth surface layers (BarDepthBack/Front, BarPulse) carry a
+ // `dataKey` to pair with a Bar but are not series themselves — skip them
+ // so they don't inflate the series count and shrink the real bars.
+ if (childType.__isBarDepthLayer) {
+ return
+ }
+ const componentName =
+ typeof child.type === "function"
+ ? childType.displayName || childType.name || ""
+ : ""
+
+ const props = child.props as BarProps | undefined
+ const isBarComponent =
+ componentName === "Bar" ||
+ (props && typeof props.dataKey === "string" && props.dataKey.length > 0)
+
+ if (isBarComponent && props?.dataKey) {
+ // Use stroke for tooltip dot color if provided, otherwise fall back to fill
+ // This allows gradient/pattern fills to have a solid dot color
+ const dotColor = props.stroke || props.fill || "var(--chart-line-primary)"
+ configs.push({
+ dataKey: props.dataKey,
+ stroke: dotColor,
+ strokeWidth: 0,
+ yAxisId: props.yAxisId
+ })
+ }
+ })
+
+ return configs
+}
+
+interface ChartInnerProps {
+ width: number
+ height: number
+ data: Record[]
+ xDataKey: string
+ margin: Margin
+ animationDuration: number
+ animationEasing: string
+ enterTransition?: Transition
+ revealSignature?: string
+ barGap: number
+ barWidthProp?: number
+ orientation: BarOrientation
+ stacked: boolean
+ stackGap: number
+ children: ReactNode
+ containerRef: React.RefObject
+ onPhaseChange?: (phase: ChartPhase) => void
+ status: ChartStatus
+}
+
+function ChartInner(props: ChartInnerProps) {
+ const { width, height } = props
+ if (width < 10 || height < 10) {
+ return null
+ }
+ return
+}
+
+const ChartCore = memo(function ChartCore({
+ width,
+ height,
+ data,
+ xDataKey,
+ margin,
+ animationDuration,
+ animationEasing,
+ enterTransition,
+ revealSignature = "",
+ barGap,
+ barWidthProp,
+ orientation,
+ stacked,
+ stackGap,
+ children,
+ containerRef,
+ onPhaseChange,
+ status
+}: ChartInnerProps) {
+ const { tooltipData, setTooltipData, scheduleTooltip, clearTooltip } =
+ useScheduledTooltip()
+ const [isLoaded, setIsLoaded] = useState(false)
+ const [revealEpoch, setRevealEpoch] = useState(0)
+ const hoveredBarIndex = tooltipData?.index ?? null
+
+ const isHorizontal = orientation === "horizontal"
+
+ // Extract bar configs synchronously from children
+ const lines = useMemo(() => extractBarConfigs(children), [children])
+
+ const innerWidth = width - margin.left - margin.right
+ const innerHeight = height - margin.top - margin.bottom
+
+ // Category accessor function - returns string for categorical scale
+ const categoryAccessor = useCallback(
+ (d: Record): string => {
+ const value = d[xDataKey]
+ if (value instanceof Date) {
+ return shortDateFmt.format(value)
+ }
+ return String(value ?? "")
+ },
+ [xDataKey]
+ )
+
+ // For compatibility with ChartContext, provide a Date-based xAccessor
+ const xAccessorDate = useCallback(
+ (d: Record): Date => {
+ const value = d[xDataKey]
+ if (value instanceof Date) {
+ return value
+ }
+ return new Date()
+ },
+ [xDataKey]
+ )
+
+ // Category scale (band) - for the categorical axis
+ const categoryScale = useMemo(() => {
+ const domain = data.map(d => categoryAccessor(d))
+ const range: [number, number] = isHorizontal
+ ? [0, innerHeight]
+ : [0, innerWidth]
+ return scaleBand({
+ range,
+ domain,
+ padding: barGap
+ })
+ }, [innerWidth, innerHeight, data, categoryAccessor, barGap, isHorizontal])
+
+ // Band width for bars - use prop if provided, otherwise use scale's bandwidth
+ const bandWidth = barWidthProp ?? categoryScale.bandwidth()
+
+ // Compute max value considering stacking
+ const maxValue = useMemo(() => {
+ if (stacked) {
+ // For stacked bars, sum all values at each data point
+ let max = 0
+ for (const d of data) {
+ let sum = 0
+ for (const line of lines) {
+ const value = d[line.dataKey]
+ if (typeof value === "number") {
+ sum += value
+ }
+ }
+ if (sum > max) {
+ max = sum
+ }
+ }
+ return max || 100
+ }
+ // For grouped bars, find max single value
+ let max = 0
+ for (const line of lines) {
+ for (const d of data) {
+ const value = d[line.dataKey]
+ if (typeof value === "number" && value > max) {
+ max = value
+ }
+ }
+ }
+ return max || 100
+ }, [data, lines, stacked])
+
+ // Value scale (linear) - for the value axis
+ const valueScale = useMemo(() => {
+ const range = isHorizontal ? [0, innerWidth] : [innerHeight, 0]
+ return scaleLinear({
+ range,
+ domain: [0, maxValue * 1.1],
+ nice: true
+ })
+ }, [innerWidth, innerHeight, maxValue, isHorizontal])
+
+ const yScales = useMemo(() => {
+ if (isHorizontal) {
+ return wrapSingleYScale(valueScale)
+ }
+ return buildYScalesForLines({
+ lines,
+ data,
+ innerHeight,
+ resolveDomain: dataKeys => {
+ let max = 0
+ for (const d of data) {
+ for (const key of dataKeys) {
+ const value = d[key]
+ if (typeof value === "number" && value > max) {
+ max = value
+ }
+ }
+ }
+ return [0, (max || 100) * 1.1]
+ }
+ })
+ }, [data, innerHeight, isHorizontal, lines, valueScale])
+
+ const primaryYScale = getPrimaryYScale(yScales, valueScale)
+
+ // Compute stack offsets for stacked bars
+ const stackOffsets = useMemo(() => {
+ if (!stacked) {
+ return undefined
+ }
+ const offsets = new Map>()
+ for (let i = 0; i < data.length; i++) {
+ const d = data[i]
+ if (!d) {
+ continue
+ }
+ const pointOffsets = new Map()
+ let cumulative = 0
+ for (const line of lines) {
+ pointOffsets.set(line.dataKey, cumulative)
+ const value = d[line.dataKey]
+ if (typeof value === "number") {
+ cumulative += value
+ }
+ }
+ offsets.set(i, pointOffsets)
+ }
+ return offsets
+ }, [data, lines, stacked])
+
+ // Column width for tooltip indicator
+ const columnWidth = useMemo(() => {
+ if (data.length < 1) {
+ return 0
+ }
+ return isHorizontal ? innerHeight / data.length : innerWidth / data.length
+ }, [innerWidth, innerHeight, data.length, isHorizontal])
+
+ // Pre-compute labels for ticker animation
+ const dateLabels = useMemo(
+ () => data.map(d => categoryAccessor(d)),
+ [data, categoryAccessor]
+ )
+
+ // Create a fake time scale for compatibility with ChartContext
+ const fakeTimeScale = useMemo(() => {
+ const now = Date.now()
+ const start = now - data.length * 24 * 60 * 60 * 1000
+ const scale = {
+ ...categoryScale,
+ domain: () => [new Date(start), new Date(now)],
+ range: () => [0, innerWidth] as [number, number],
+ invert: (x: number) => new Date(start + (x / innerWidth) * (now - start)),
+ copy: () => scale
+ }
+ return scale
+ }, [categoryScale, innerWidth, data.length])
+
+ // Animation timing — replay when motion settings change
+ // biome-ignore lint/correctness/useExhaustiveDependencies: revealSignature
+ useEffect(() => {
+ setRevealEpoch(n => n + 1)
+ setIsLoaded(false)
+ // While loading, hold the skeleton (no reveal, no interaction). When
+ // status flips to "ready" this effect re-runs and plays the grow reveal.
+ if (status === "loading") {
+ return
+ }
+ const timer = setTimeout(() => {
+ setIsLoaded(true)
+ }, animationDuration)
+ return () => clearTimeout(timer)
+ }, [animationDuration, revealSignature, status])
+
+ useEffect(() => {
+ onPhaseChange?.(isLoaded ? "ready" : "revealing")
+ }, [isLoaded, onPhaseChange])
+
+ // Mouse move handler
+ const handleMouseMove = useCallback(
+ (event: React.MouseEvent) => {
+ const point = localPoint(event)
+ if (!point) {
+ return
+ }
+
+ const pos = isHorizontal ? point.y - margin.top : point.x - margin.left
+
+ // Find which band the mouse is over
+ const bandIndex = Math.floor(pos / columnWidth)
+ const clampedIndex = Math.max(0, Math.min(data.length - 1, bandIndex))
+ const d = data[clampedIndex]
+
+ if (!d) {
+ return
+ }
+
+ // Calculate positions for each bar
+ const yPositions: Record = {}
+ const xPositions: Record = {}
+ const barPos = categoryScale(categoryAccessor(d)) ?? 0
+
+ if (isHorizontal) {
+ // Horizontal bars: dots at end of bar (x = value), centered vertically in band
+ const seriesCount = lines.length
+ const groupGap = seriesCount > 1 ? 4 : 0
+ const individualBarHeight =
+ seriesCount > 0
+ ? (bandWidth - groupGap * (seriesCount - 1)) / seriesCount
+ : bandWidth
+
+ if (stacked) {
+ // Stacked horizontal: all bars same y, x at cumulative end
+ let cumulative = 0
+ for (const line of lines) {
+ const value = d[line.dataKey]
+ if (typeof value === "number") {
+ cumulative += value
+ const axisScale =
+ yScales[normalizeYAxisId(line.yAxisId)] ?? valueScale
+ xPositions[line.dataKey] = axisScale(cumulative) ?? 0
+ yPositions[line.dataKey] = barPos + bandWidth / 2
+ }
+ }
+ } else {
+ // Grouped horizontal: each bar at its own y position
+ lines.forEach((line, idx) => {
+ const value = d[line.dataKey]
+ if (typeof value === "number") {
+ const axisScale =
+ yScales[normalizeYAxisId(line.yAxisId)] ?? valueScale
+ xPositions[line.dataKey] = axisScale(value) ?? 0
+ yPositions[line.dataKey] =
+ barPos +
+ idx * (individualBarHeight + groupGap) +
+ individualBarHeight / 2
+ }
+ })
+ }
+ } else if (stacked) {
+ // Vertical stacked bars
+ let cumulative = 0
+ let seriesIdx = 0
+ for (const line of lines) {
+ const value = d[line.dataKey]
+ if (typeof value === "number") {
+ cumulative += value
+ const axisScale =
+ yScales[normalizeYAxisId(line.yAxisId)] ?? primaryYScale
+ const gapOffset = seriesIdx * stackGap
+ yPositions[line.dataKey] = (axisScale(cumulative) ?? 0) - gapOffset
+ seriesIdx++
+ }
+ }
+ } else {
+ // Vertical grouped bars
+ const seriesCount = lines.length
+ const groupGap = seriesCount > 1 ? 4 : 0
+ const individualBarWidth =
+ seriesCount > 0
+ ? (bandWidth - groupGap * (seriesCount - 1)) / seriesCount
+ : bandWidth
+
+ lines.forEach((line, idx) => {
+ const value = d[line.dataKey]
+ if (typeof value === "number") {
+ const axisScale =
+ yScales[normalizeYAxisId(line.yAxisId)] ?? primaryYScale
+ yPositions[line.dataKey] = axisScale(value) ?? 0
+ xPositions[line.dataKey] =
+ barPos +
+ idx * (individualBarWidth + groupGap) +
+ individualBarWidth / 2
+ }
+ })
+ }
+
+ // Tooltip position: for horizontal, position at max bar end; for vertical, center of band
+ let tooltipX: number
+ if (isHorizontal) {
+ // Position tooltip at the end of the longest bar
+ const maxX = Math.max(...Object.values(xPositions), 0)
+ tooltipX = maxX
+ } else {
+ tooltipX = barPos + bandWidth / 2
+ }
+
+ scheduleTooltip({
+ point: d,
+ index: clampedIndex,
+ x: tooltipX,
+ yPositions,
+ xPositions: Object.keys(xPositions).length > 0 ? xPositions : undefined
+ })
+ },
+ [
+ categoryScale,
+ valueScale,
+ data,
+ lines,
+ margin.left,
+ margin.top,
+ categoryAccessor,
+ columnWidth,
+ bandWidth,
+ isHorizontal,
+ stacked,
+ stackGap,
+ scheduleTooltip,
+ yScales,
+ primaryYScale
+ ]
+ )
+
+ const handleMouseLeave = useCallback(() => {
+ clearTooltip()
+ }, [clearTooltip])
+
+ const canInteract = isLoaded
+
+ // Separate children into defs, pre-overlay, and post-overlay
+ const defsChildren: ReactElement[] = []
+ const clipExcludedChildren: ReactElement[] = []
+ const underlayChildren: ReactElement[] = []
+ const preOverlayChildren: ReactElement[] = []
+ const postOverlayChildren: ReactElement[] = []
+
+ forEachChartChild(children, child => {
+ const resolvedChild = resolveChartChildElement(child)
+
+ if (isGradientDefComponent(child)) {
+ defsChildren.push(child)
+ } else if (isPatternDefComponent(child)) {
+ preOverlayChildren.push(child)
+ } else if (isPostOverlayComponent(resolvedChild)) {
+ postOverlayChildren.push(resolvedChild)
+ } else if (isClipExcludedComponent(resolvedChild)) {
+ clipExcludedChildren.push(
+ isChartClipPassthrough(child.type) ? resolvedChild : child
+ )
+ } else if (isUnderlayComponent(resolvedChild)) {
+ underlayChildren.push(resolvedChild)
+ } else {
+ preOverlayChildren.push(child)
+ }
+ })
+
+ const referenceAreas = useMemo(
+ () => extractReferenceAreaConfigs(children),
+ [children]
+ )
+
+ const contextValue = {
+ ...DEFAULT_CHART_LIFECYCLE,
+ chartPhase: resolveRestingChartPhase(status),
+ chartStatus: status,
+ data,
+ renderData: data,
+ xScale: fakeTimeScale as unknown as ChartContextValue["xScale"],
+ yScale: isHorizontal ? valueScale : primaryYScale,
+ yScales,
+ width,
+ height,
+ innerWidth,
+ innerHeight,
+ margin,
+ columnWidth,
+ tooltipData,
+ setTooltipData,
+ containerRef,
+ lines,
+ referenceAreas,
+ isLoaded,
+ animationDuration,
+ animationEasing,
+ enterTransition,
+ revealEpoch,
+ xAccessor: xAccessorDate,
+ dateLabels,
+ // Bar-specific properties
+ barScale: categoryScale,
+ bandWidth,
+ hoveredBarIndex,
+ barXAccessor: categoryAccessor,
+ orientation,
+ stacked,
+ stackOffsets
+ }
+
+ return (
+
+
+ {/* Gradient and pattern definitions */}
+ {defsChildren.length > 0 && {defsChildren} }
+
+
+
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: Chart interaction area */}
+
+ {/* Background rect for mouse event detection */}
+
+
+ {renderKeyedChartLayers(clipExcludedChildren)}
+ {renderKeyedChartLayers(underlayChildren)}
+ {status === "loading" ? (
+
+ ) : (
+ renderKeyedChartLayers(preOverlayChildren)
+ )}
+
+ {/* Markers rendered last so they're on top for interaction */}
+ {renderKeyedChartLayers(postOverlayChildren)}
+
+
+
+ )
+})
+
+export function BarChart({
+ data,
+ xDataKey = "name",
+ margin: marginProp,
+ animationDuration = 1100,
+ animationEasing = DEFAULT_ANIMATION_EASING,
+ enterTransition,
+ revealSignature,
+ aspectRatio = "2 / 1",
+ className = "",
+ barGap = 0.2,
+ barWidth,
+ orientation = "vertical",
+ stacked = false,
+ stackGap = 0,
+ children,
+ onPhaseChange,
+ status = "ready"
+}: BarChartProps) {
+ const containerRef = useRef(null)
+ const margin = { ...DEFAULT_MARGIN, ...marginProp }
+
+ return (
+
+
+ {({ width, height }) => (
+
+ {children}
+
+ )}
+
+
+ )
+}
+
+BarChart.displayName = "BarChart"
+
+export default BarChart
diff --git a/src/components/charts/bar-depth-geometry.ts b/src/components/charts/bar-depth-geometry.ts
new file mode 100644
index 00000000..f139eb1c
--- /dev/null
+++ b/src/components/charts/bar-depth-geometry.ts
@@ -0,0 +1,34 @@
+export const BAR_DEPTH_MAX_PX = 7
+/** The side parallelogram's back edge lifts by `depth * this ratio`, giving a
+ * subtle head-on perspective slope. */
+export const BAR_DEPTH_PERSPECTIVE_RATIO = 0.45
+
+/**
+ * Maximum side-face depth in px for a chart, clamped so depth never spills past
+ * the gap between bars. `stepWidth` is d3-scaleBand's `step()` (bandwidth +
+ * gap); `bandWidth` is a single bar's width. Returns 0 for gapless/dense charts.
+ */
+export function barDepthMaxDepth(stepWidth: number, bandWidth: number): number {
+ const gap = Math.max(0, stepWidth - bandWidth)
+ return Math.min(bandWidth * 0.22, Math.max(0, gap - 1), BAR_DEPTH_MAX_PX)
+}
+
+/**
+ * Per-bar side-face depth + perspective rise.
+ *
+ * - `absOffset` ∈ [0, 1]: the bar's normalized distance from the chart's
+ * horizontal center. 0 = dead center (no depth); 1 = chart edge (full depth).
+ * - `naturalHeight`: the bar's pixel height. Depth is capped by it so a short
+ * bar's side never reads wider than the bar is tall.
+ * - `maxDepth`: from `barDepthMaxDepth`.
+ */
+export function barDepthAndRise(
+ absOffset: number,
+ naturalHeight: number,
+ maxDepth: number
+): { depth: number; perspectiveRise: number } {
+ const offset = Math.min(1, Math.max(0, absOffset))
+ const cappedMaxDepth = Math.min(maxDepth, Math.max(0, naturalHeight))
+ const depth = offset * cappedMaxDepth
+ return { depth, perspectiveRise: depth * BAR_DEPTH_PERSPECTIVE_RATIO }
+}
diff --git a/src/components/charts/bar-x-axis.tsx b/src/components/charts/bar-x-axis.tsx
new file mode 100644
index 00000000..b13c590c
--- /dev/null
+++ b/src/components/charts/bar-x-axis.tsx
@@ -0,0 +1,155 @@
+"use client"
+
+import { memo, useEffect, useMemo, useState } from "react"
+import { createPortal } from "react-dom"
+import { motion } from "motion/react"
+
+import { cn } from "@/lib/utils"
+import { useChart, useChartStable } from "./chart-context"
+
+export interface BarXAxisProps {
+ /** Width of the date ticker box for fade calculation. Default: 50 */
+ tickerHalfWidth?: number
+ /** Whether to show all labels or skip some for dense data. Default: false */
+ showAllLabels?: boolean
+ /** Maximum number of labels to show. Default: 12 */
+ maxLabels?: number
+}
+
+interface BarXAxisLabelProps {
+ label: string
+ x: number
+ crosshairX: number | null
+ isHovering: boolean
+ tickerHalfWidth: number
+}
+
+function BarXAxisLabel({
+ label,
+ x,
+ crosshairX,
+ isHovering,
+ tickerHalfWidth
+}: BarXAxisLabelProps) {
+ const fadeBuffer = 20
+ const fadeRadius = tickerHalfWidth + fadeBuffer
+
+ let opacity = 1
+ if (isHovering && crosshairX !== null) {
+ const distance = Math.abs(x - crosshairX)
+ if (distance < tickerHalfWidth) {
+ opacity = 0
+ } else if (distance < fadeRadius) {
+ opacity = (distance - tickerHalfWidth) / fadeBuffer
+ }
+ }
+
+ // Zero-width container approach for perfect centering
+ return (
+
+
+ {label}
+
+
+ )
+}
+
+export function BarXAxis(props: BarXAxisProps) {
+ const { containerRef, barScale } = useChartStable()
+ const [container, setContainer] = useState(null)
+ const [mounted, setMounted] = useState(false)
+
+ useEffect(() => {
+ setMounted(true)
+ setContainer(containerRef.current)
+ }, [containerRef])
+
+ if (!(mounted && container)) {
+ return null
+ }
+
+ if (!barScale) {
+ return null
+ }
+
+ return
+}
+
+const BarXAxisInner = memo(function BarXAxisInner({
+ tickerHalfWidth = 50,
+ showAllLabels = false,
+ maxLabels = 12,
+ container
+}: BarXAxisProps & { container: HTMLDivElement }) {
+ const { margin, tooltipData, barScale, bandWidth, barXAccessor, data } =
+ useChart()
+
+ // Generate labels for each bar
+ const labelsToShow = useMemo(() => {
+ if (!(barScale && bandWidth && barXAccessor)) {
+ return []
+ }
+
+ const allLabels = data.map(d => {
+ const label = barXAccessor(d)
+ const bandX = barScale(label) ?? 0
+ // Center the label under the bar group
+ const x = bandX + bandWidth / 2 + margin.left
+ return { label, x }
+ })
+
+ // If showAllLabels is true or we have fewer than maxLabels, show all
+ if (showAllLabels || allLabels.length <= maxLabels) {
+ return allLabels
+ }
+
+ // Otherwise, skip some labels to avoid crowding
+ const step = Math.ceil(allLabels.length / maxLabels)
+ return allLabels.filter((_, i) => i % step === 0)
+ }, [
+ barScale,
+ bandWidth,
+ barXAccessor,
+ data,
+ margin.left,
+ showAllLabels,
+ maxLabels
+ ])
+
+ const isHovering = tooltipData !== null
+ const crosshairX = tooltipData ? tooltipData.x + margin.left : null
+
+ return createPortal(
+
+ {labelsToShow.map(item => (
+
+ ))}
+
,
+ container
+ )
+})
+
+BarXAxis.displayName = "BarXAxis"
+
+export default BarXAxis
diff --git a/src/components/charts/bar-y-axis.tsx b/src/components/charts/bar-y-axis.tsx
new file mode 100644
index 00000000..1ba9fd52
--- /dev/null
+++ b/src/components/charts/bar-y-axis.tsx
@@ -0,0 +1,144 @@
+"use client"
+
+import { memo, useEffect, useMemo, useState } from "react"
+import { createPortal } from "react-dom"
+import { motion } from "motion/react"
+
+import { cn } from "@/lib/utils"
+import { useChart, useChartStable } from "./chart-context"
+
+export interface BarYAxisProps {
+ /** Whether to show all labels or skip some for dense data. Default: true */
+ showAllLabels?: boolean
+ /** Maximum number of labels to show. Default: 20 */
+ maxLabels?: number
+}
+
+interface BarYAxisLabelProps {
+ label: string
+ y: number
+ bandHeight: number
+ isHovered: boolean
+}
+
+function BarYAxisLabel({
+ label,
+ y,
+ bandHeight,
+ isHovered
+}: BarYAxisLabelProps) {
+ return (
+
+
+ {label}
+
+
+ )
+}
+
+export function BarYAxis(props: BarYAxisProps) {
+ const { containerRef, barScale } = useChartStable()
+ const [container, setContainer] = useState(null)
+ const [mounted, setMounted] = useState(false)
+
+ useEffect(() => {
+ setMounted(true)
+ setContainer(containerRef.current)
+ }, [containerRef])
+
+ if (!(mounted && container)) {
+ return null
+ }
+
+ if (!barScale) {
+ return null
+ }
+
+ return
+}
+
+const BarYAxisInner = memo(function BarYAxisInner({
+ showAllLabels = true,
+ maxLabels = 20,
+ container
+}: BarYAxisProps & { container: HTMLDivElement }) {
+ const { margin, barScale, bandWidth, barXAccessor, data, hoveredBarIndex } =
+ useChart()
+
+ // Generate labels for each bar
+ const labelsToShow = useMemo(() => {
+ if (!(barScale && bandWidth && barXAccessor)) {
+ return []
+ }
+
+ const allLabels = data.map((d, i) => {
+ const label = barXAccessor(d)
+ const bandY = barScale(label) ?? 0
+ // Center the label vertically within the band
+ const y = bandY + margin.top
+ return { label, y, bandHeight: bandWidth, index: i }
+ })
+
+ // If showAllLabels is true or we have fewer than maxLabels, show all
+ if (showAllLabels || allLabels.length <= maxLabels) {
+ return allLabels
+ }
+
+ // Otherwise, skip some labels to avoid crowding
+ const step = Math.ceil(allLabels.length / maxLabels)
+ return allLabels.filter((_, i) => i % step === 0)
+ }, [
+ barScale,
+ bandWidth,
+ barXAccessor,
+ data,
+ margin.top,
+ showAllLabels,
+ maxLabels
+ ])
+
+ return createPortal(
+
+ {labelsToShow.map(item => (
+
+ ))}
+
,
+ container
+ )
+})
+
+BarYAxis.displayName = "BarYAxis"
+
+export default BarYAxis
diff --git a/src/components/charts/bar.tsx b/src/components/charts/bar.tsx
new file mode 100644
index 00000000..a083f63b
--- /dev/null
+++ b/src/components/charts/bar.tsx
@@ -0,0 +1,467 @@
+"use client"
+
+import { memo, useId, useMemo } from "react"
+import type { scaleBand } from "@visx/scale"
+import type { Transition } from "motion/react"
+import { motion } from "motion/react"
+
+import { barDepthAndRise, barDepthMaxDepth } from "./bar-depth-geometry"
+import {
+ chartCssVars,
+ useChart,
+ useChartStable,
+ useYScale
+} from "./chart-context"
+import { useChartLegendHover } from "./chart-legend-hover"
+import { transitionWithDelay } from "./motion-utils"
+
+type ScaleBand = ReturnType<
+ typeof scaleBand
+>
+
+export type BarLineCap = "round" | "butt" | number
+export type BarAnimationType = "grow" | "fade"
+
+// ── Bar-depth perspective trim ───────────────────────────────────────────
+// Uses the SHARED geometry (`bar-depth-geometry.ts`) so a
+// `` front face lines up exactly with
+// ``'s lid — the formula lives in one place for both.
+
+/** perspectiveRise for a positive bar whose visual top sits at `topY`.
+ * Returns 0 for a dead-center bar or a dense chart (degenerate depth). */
+function barDepthPerspectiveRise(
+ barScale: ScaleBand,
+ bandWidth: number,
+ barXAccessor: (d: Record) => string,
+ innerWidth: number,
+ datum: Record,
+ topY: number,
+ baselineY: number
+): number {
+ const centerX = innerWidth / 2
+ if (centerX <= 0) {
+ return 0
+ }
+ const step =
+ (barScale as unknown as { step?: () => number }).step?.() ?? bandWidth
+ const maxDepth = barDepthMaxDepth(step, bandWidth)
+ const bandX = barScale(barXAccessor(datum)) ?? 0
+ const cx = bandX + bandWidth / 2
+ const absOffset = Math.min(1, Math.abs((cx - centerX) / centerX))
+ const naturalHeight = Math.abs(baselineY - topY)
+ return barDepthAndRise(absOffset, naturalHeight, maxDepth).perspectiveRise
+}
+
+export interface BarProps {
+ /** Key in data to use for y values */
+ dataKey: string
+ /** Y-scale group id for vertical bars (Recharts `yAxisId`). Default: `"left"`. */
+ yAxisId?: string | number
+ /** Fill color for the bar. Can be a color, gradient url, or pattern url. Default: var(--chart-line-primary) */
+ fill?: string
+ /** Color for tooltip dot. Use when fill is a gradient/pattern. Default: uses fill value */
+ stroke?: string
+ /** Line cap style for bar ends: "round", "butt", or a number for custom radius. Default: "round" */
+ lineCap?: BarLineCap
+ /** Whether to animate the bars. Default: true */
+ animate?: boolean
+ /** Animation type: "grow" (height) or "fade" (opacity + blur). Default: "grow" */
+ animationType?: BarAnimationType
+ /** Opacity when not hovered (when another bar is hovered). Default: 0.3 */
+ fadedOpacity?: number
+ /** Stagger delay between bars in seconds. Auto-calculated if not provided. */
+ staggerDelay?: number
+ /** Gap between stacked bars in pixels. Default: 0 */
+ stackGap?: number
+ /** Gap between grouped bars in pixels. Default: 4 */
+ groupGap?: number
+ /** Shrink each positive bar's top by its perspective rise so the front face
+ * lines up with ``'s lid (instead of the lid sitting above the
+ * front face). Pass `true` whenever the chart also renders the bar-depth 3D
+ * surfaces. Default: false */
+ perspective?: boolean
+ /** Minimum rendered bar height in px (non-stacked, vertical). Floors short or
+ * zero-value bars so they stay visible. Pair with the same value on
+ * `` when using the 3D surfaces. Default: 0 */
+ minBarHeight?: number
+}
+
+interface BarInnerProps extends BarProps {
+ barScale: ScaleBand
+ bandWidth: number
+ barXAccessor: (d: Record) => string
+}
+
+interface AnimatedBarProps {
+ x: number
+ y: number
+ width: number
+ height: number
+ fill: string
+ rx: number
+ ry: number
+ index: number
+ isFaded: boolean
+ animationType: BarAnimationType
+ innerHeight: number
+ fadedOpacity: number
+ staggerDelay: number
+ enterTransition?: Transition
+ revealEpoch: number
+ isHorizontal: boolean
+}
+
+function AnimatedBar({
+ x,
+ y,
+ width,
+ height,
+ fill,
+ rx,
+ ry,
+ index,
+ isFaded,
+ animationType,
+ innerHeight,
+ fadedOpacity,
+ staggerDelay,
+ enterTransition,
+ revealEpoch,
+ isHorizontal
+}: AnimatedBarProps) {
+ const enterAnim = transitionWithDelay(enterTransition, index * staggerDelay)
+
+ if (animationType === "fade") {
+ return (
+
+ )
+ }
+
+ const initial = isHorizontal
+ ? { width: 0, height, x: 0, y }
+ : { width, height: 0, x, y: innerHeight }
+ const target = isHorizontal
+ ? { width, height, x: 0, y }
+ : { width, height, x, y }
+
+ return (
+
+
+
+ )
+}
+
+const BarInner = memo(function BarInner({
+ dataKey,
+ yAxisId,
+ fill = chartCssVars.linePrimary,
+ lineCap = "round",
+ animate = true,
+ animationType = "grow",
+ fadedOpacity = 0.3,
+ staggerDelay,
+ stackGap = 0,
+ groupGap = 4,
+ perspective = false,
+ minBarHeight = 0,
+ barScale,
+ bandWidth,
+ barXAccessor
+}: BarInnerProps) {
+ const {
+ data,
+ yScale: chartYScale,
+ innerHeight,
+ innerWidth,
+ isLoaded,
+ hoveredBarIndex,
+ lines,
+ orientation,
+ stacked,
+ stackOffsets,
+ animationDuration,
+ enterTransition,
+ revealEpoch = 0
+ } = useChart()
+
+ // Calculate stagger delay automatically if not provided
+ // Total animation duration is ~1200ms, with 40% for stagger spread and 60% for bar animation
+ const totalAnimDuration = animationDuration || 1100
+ const staggerSpread = totalAnimDuration * 0.4 // 40% of time for stagger spread
+ const calculatedStaggerDelay =
+ staggerDelay ?? (data.length > 1 ? staggerSpread / 1000 / data.length : 0)
+ const uniqueId = useId()
+
+ const isHorizontal = orientation === "horizontal"
+
+ // Find the index of this bar series among all bar series
+ const { hoveredIndex: legendHoveredIndex } = useChartLegendHover()
+
+ const seriesIndex = useMemo(() => {
+ const idx = lines.findIndex(l => l.dataKey === dataKey)
+ return idx >= 0 ? idx : 0
+ }, [lines, dataKey])
+
+ const seriesConfig = lines[seriesIndex]
+ const valueScale = useYScale(yAxisId ?? seriesConfig?.yAxisId)
+
+ const isLegendDimmed =
+ legendHoveredIndex !== null && legendHoveredIndex !== seriesIndex
+
+ const seriesCount = lines.length
+ const isLastSeries = seriesIndex === seriesCount - 1
+
+ // Calculate the width for each bar within a group (for non-stacked)
+ const barWidth = useMemo(() => {
+ if (!bandWidth || seriesCount === 0) {
+ return 0
+ }
+ if (stacked) {
+ // Stacked bars use full band width
+ return bandWidth
+ }
+ // Leave a gap between grouped bars (controlled by groupGap prop)
+ const effectiveGroupGap = seriesCount > 1 ? groupGap : 0
+ return (bandWidth - effectiveGroupGap * (seriesCount - 1)) / seriesCount
+ }, [bandWidth, seriesCount, stacked, groupGap])
+
+ // Calculate corner radius based on lineCap. Perspective bars force a flat
+ // top (radius 0) so the 3D lid from `` meets the bar with no
+ // gap — rounded corners would leave a wedge, so `perspective` overrides it.
+ const cornerRadius = useMemo(() => {
+ if (perspective) {
+ return 0
+ }
+ if (typeof lineCap === "number") {
+ return lineCap
+ }
+ if (lineCap === "round" && barWidth) {
+ return Math.min(barWidth / 2, 8)
+ }
+ return 0
+ }, [lineCap, barWidth, perspective])
+
+ return (
+
+ {data.map((d, i) => {
+ const value = d[dataKey]
+ if (typeof value !== "number") {
+ return null
+ }
+
+ const categoryValue = barXAccessor(d)
+ const bandPos = barScale(categoryValue) ?? 0
+
+ let x: number
+ let y: number
+ let barHeight: number
+ let barW: number
+
+ const scale = isHorizontal ? chartYScale : valueScale
+
+ if (isHorizontal) {
+ // Horizontal bars: category on y-axis, value on x-axis
+ const valuePos = scale(value) ?? 0
+ barW = valuePos // Width is the value position (grows from left)
+ barHeight = barWidth
+
+ if (stacked && stackOffsets) {
+ const offset = stackOffsets.get(i)?.get(dataKey) ?? 0
+ x = scale(offset) ?? 0
+ barW = valuePos - x
+ // Apply stack gap for horizontal: shift right and reduce width
+ const gapOffset = seriesIndex * stackGap
+ x += gapOffset
+ if (!isLastSeries && stackGap > 0) {
+ barW = Math.max(0, barW - stackGap)
+ }
+ } else {
+ x = 0
+ // For grouped bars, offset y position
+ const effectiveGroupGap = seriesCount > 1 ? groupGap : 0
+ y = bandPos + seriesIndex * (barWidth + effectiveGroupGap)
+ }
+ y = stacked
+ ? bandPos
+ : bandPos +
+ seriesIndex * (barWidth + (seriesCount > 1 ? groupGap : 0))
+ } else {
+ // Vertical bars: category on x-axis, value on y-axis
+ const valuePos = scale(value) ?? 0
+ barHeight = innerHeight - valuePos
+ barW = barWidth
+
+ if (stacked && stackOffsets) {
+ const offset = stackOffsets.get(i)?.get(dataKey) ?? 0
+ const offsetY = scale(offset) ?? innerHeight
+ // Apply stack gap: shift up and reduce height
+ const gapOffset = seriesIndex * stackGap
+ y = offsetY - barHeight - gapOffset
+ // Reduce height slightly for non-last bars to create visual gap
+ if (!isLastSeries && stackGap > 0) {
+ barHeight = Math.max(0, barHeight - stackGap)
+ }
+ } else {
+ y = valuePos
+ // For grouped bars, offset x position
+ const effectiveGroupGap = seriesCount > 1 ? groupGap : 0
+ x = bandPos + seriesIndex * (barWidth + effectiveGroupGap)
+ }
+ x = stacked
+ ? bandPos
+ : bandPos +
+ seriesIndex * (barWidth + (seriesCount > 1 ? groupGap : 0))
+
+ // Minimum visible height — floor short/zero non-stacked bars so a
+ // zero-value data point still reads as a tiny bar instead of
+ // vanishing. Grows up from the baseline. Floored bars skip the
+ // perspective trim (sub-pixel on a 3px bar; keeps the front aligned
+ // with bar-depth, which also skips trim for floored bars).
+ let isFloored = false
+ if (
+ !stacked &&
+ minBarHeight > 0 &&
+ value >= 0 &&
+ barHeight < minBarHeight
+ ) {
+ const baselineY = scale(0) ?? innerHeight
+ barHeight = minBarHeight
+ y = baselineY - minBarHeight
+ isFloored = true
+ }
+
+ // Perspective trim — shrink the topmost positive bar's front-face
+ // top down by its perspective rise so it meets ``'s
+ // lid back edge. Stacked: only the last (topmost) series; grouped or
+ // single: every positive bar. Clamped to `barHeight - 1` so very
+ // short bars keep a positive height (matches bar-depth's clamp).
+ if (
+ perspective &&
+ value > 0 &&
+ !isFloored &&
+ (!stacked || isLastSeries)
+ ) {
+ const baselineY = scale(0) ?? innerHeight
+ const rise = barDepthPerspectiveRise(
+ barScale,
+ bandWidth,
+ barXAccessor,
+ innerWidth,
+ d,
+ y,
+ baselineY
+ )
+ const trim = Math.min(rise, Math.max(0, barHeight - 1))
+ y += trim
+ barHeight -= trim
+ }
+ }
+
+ const isFaded =
+ (hoveredBarIndex !== null && hoveredBarIndex !== i) || isLegendDimmed
+
+ // Use categoryValue as key since it's the unique identifier from data
+ const barKey = `bar-${dataKey}-${categoryValue}`
+
+ // Apply rounded corners:
+ // - For non-stacked: always apply
+ // - For stacked with gap: apply to all bars
+ // - For stacked without gap: only apply to the last series
+ const applyRounding = !stacked || stackGap > 0 || isLastSeries
+ const effectiveRx = applyRounding ? cornerRadius : 0
+ const effectiveRy = applyRounding ? cornerRadius : 0
+
+ if (animate && !isLoaded) {
+ return (
+
+ )
+ }
+
+ // Static bar after animation completes
+ return (
+
+ )
+ })}
+
+ )
+})
+
+export function Bar(props: BarProps) {
+ const { barScale, bandWidth, barXAccessor } = useChartStable()
+
+ if (!(barScale && bandWidth && barXAccessor)) {
+ console.warn("Bar component must be used within a BarChart")
+ return null
+ }
+
+ return (
+
+ )
+}
+
+Bar.displayName = "Bar"
+
+export default Bar
diff --git a/src/components/charts/chart-child-passthrough.ts b/src/components/charts/chart-child-passthrough.ts
new file mode 100644
index 00000000..3fb1d3bc
--- /dev/null
+++ b/src/components/charts/chart-child-passthrough.ts
@@ -0,0 +1,117 @@
+import {
+ Children,
+ cloneElement,
+ Fragment,
+ isValidElement,
+ type ReactElement,
+ type ReactNode
+} from "react"
+
+/** Marker on wrapper components whose single child should inherit clip classification. */
+export const CHART_CLIP_PASSTHROUGH = "__chartClipPassthrough" as const
+
+export function isChartClipPassthrough(type: unknown): boolean {
+ return (
+ typeof type === "function" &&
+ (type as { [CHART_CLIP_PASSTHROUGH]?: boolean })[CHART_CLIP_PASSTHROUGH] ===
+ true
+ )
+}
+
+/** Unwrap visibility wrappers so `Grid` / axes stay outside the series clip. */
+export function resolveChartChildElement(child: ReactElement): ReactElement {
+ if (isChartClipPassthrough(child.type)) {
+ const inner = (child.props as { children?: unknown }).children
+ if (isValidElement(inner)) {
+ return resolveChartChildElement(inner)
+ }
+ }
+ return child
+}
+
+/** Walk chart children, flattening React fragments (studio often groups layers in `<>...>`). */
+export function forEachChartChild(
+ children: ReactNode,
+ callback: (child: ReactElement, index: number) => void
+) {
+ let index = 0
+ const visit = (nodes: ReactNode) => {
+ Children.forEach(nodes, child => {
+ if (!isValidElement(child)) {
+ return
+ }
+ if (child.type === Fragment) {
+ visit((child.props as { children?: ReactNode }).children)
+ return
+ }
+ callback(child, index)
+ index += 1
+ })
+ }
+ visit(children)
+}
+
+const CLIP_EXCLUDED_COMPONENT_NAMES = new Set([
+ "Background",
+ "Grid",
+ "XAxis",
+ "YAxis",
+ "BarXAxis",
+ "BarYAxis",
+ "LiveXAxis",
+ "LiveYAxis"
+])
+
+const UNDERLAY_COMPONENT_NAMES = new Set(["ReferenceArea"])
+
+/** Markers render after the interaction overlay so they stay clickable. */
+export function isPostOverlayComponent(child: ReactElement): boolean {
+ const childType = child.type as {
+ displayName?: string
+ name?: string
+ __isChartMarkers?: boolean
+ __isPostOverlay?: boolean
+ }
+
+ if (childType.__isChartMarkers || childType.__isPostOverlay) {
+ return true
+ }
+
+ const componentName =
+ typeof child.type === "function"
+ ? childType.displayName || childType.name || ""
+ : ""
+
+ return (
+ componentName === "ChartMarkers" ||
+ componentName === "MarkerGroup" ||
+ componentName === "ChartBrush"
+ )
+}
+
+/** Renders above grid/axes but below series; excluded from grow-clip reveal. */
+export function isUnderlayComponent(child: ReactElement): boolean {
+ const childType = child.type as { displayName?: string; name?: string }
+ const componentName =
+ typeof child.type === "function"
+ ? childType.displayName || childType.name || ""
+ : ""
+ return UNDERLAY_COMPONENT_NAMES.has(componentName)
+}
+
+/** Grid and axes stay visible during series clip reveal (e.g. loading → ready). */
+export function isClipExcludedComponent(child: ReactElement): boolean {
+ const childType = child.type as { displayName?: string; name?: string }
+ const componentName =
+ typeof child.type === "function"
+ ? childType.displayName || childType.name || ""
+ : ""
+ return CLIP_EXCLUDED_COMPONENT_NAMES.has(componentName)
+}
+
+/** SVG layer lists from chart shells need stable keys when rendered as arrays. */
+export function renderKeyedChartLayers(children: ReactElement[]) {
+ return children.map((child, index) =>
+ cloneElement(child, { key: child.key ?? `chart-layer-${index}` })
+ )
+}
diff --git a/src/components/charts/chart-config-context.tsx b/src/components/charts/chart-config-context.tsx
new file mode 100644
index 00000000..aae5857a
--- /dev/null
+++ b/src/components/charts/chart-config-context.tsx
@@ -0,0 +1,92 @@
+"use client"
+
+import { createContext, useContext, useMemo, type ReactNode } from "react"
+
+export interface SpringConfig {
+ stiffness: number
+ damping: number
+}
+
+export interface ChartConfigValue {
+ /** Crosshair indicator, tooltip dot, date pill. */
+ tooltipSpring: SpringConfig
+ /** Floating tooltip panel. */
+ tooltipBoxSpring: SpringConfig
+ /** Line/area hover-highlight band (x + width). */
+ highlightSpring: SpringConfig
+}
+
+export const DEFAULT_CHART_CONFIG: ChartConfigValue = {
+ tooltipSpring: { stiffness: 300, damping: 30 },
+ tooltipBoxSpring: { stiffness: 100, damping: 20 },
+ highlightSpring: { stiffness: 180, damping: 28 }
+}
+
+const ChartConfigContext = createContext(null)
+
+export interface ChartConfigProviderProps {
+ value?: Partial
+ children: ReactNode
+}
+
+export function ChartConfigProvider({
+ value,
+ children
+}: ChartConfigProviderProps) {
+ const merged = useMemo(
+ () => ({
+ ...DEFAULT_CHART_CONFIG,
+ ...value
+ }),
+ [value]
+ )
+
+ return (
+
+ {children}
+
+ )
+}
+
+export function useChartConfig(): ChartConfigValue {
+ return useContext(ChartConfigContext) ?? DEFAULT_CHART_CONFIG
+}
+
+const DEFAULT_TOOLTIP_BOX_DAMPING =
+ DEFAULT_CHART_CONFIG.tooltipBoxSpring.damping
+
+/** Maps a damping slider to the floating tooltip panel follow spring. `0` = instant. */
+export function resolveTooltipBoxMotion(damping?: number): {
+ animate: boolean
+ springConfig: SpringConfig
+} {
+ if (damping === 0) {
+ return {
+ animate: false,
+ springConfig: DEFAULT_CHART_CONFIG.tooltipBoxSpring
+ }
+ }
+
+ const effectiveDamping = damping ?? DEFAULT_TOOLTIP_BOX_DAMPING
+ let stiffness = DEFAULT_CHART_CONFIG.tooltipBoxSpring.stiffness
+
+ if (effectiveDamping < DEFAULT_TOOLTIP_BOX_DAMPING) {
+ const t =
+ (DEFAULT_TOOLTIP_BOX_DAMPING - effectiveDamping) /
+ DEFAULT_TOOLTIP_BOX_DAMPING
+ stiffness += t * 400
+ } else if (effectiveDamping > DEFAULT_TOOLTIP_BOX_DAMPING) {
+ const t =
+ (effectiveDamping - DEFAULT_TOOLTIP_BOX_DAMPING) /
+ (100 - DEFAULT_TOOLTIP_BOX_DAMPING)
+ stiffness -= t * 85
+ }
+
+ return {
+ animate: true,
+ springConfig: {
+ stiffness: Math.max(12, Math.round(stiffness)),
+ damping: effectiveDamping
+ }
+ }
+}
diff --git a/src/components/charts/chart-context.tsx b/src/components/charts/chart-context.tsx
new file mode 100644
index 00000000..8792bfa8
--- /dev/null
+++ b/src/components/charts/chart-context.tsx
@@ -0,0 +1,426 @@
+"use client"
+
+import {
+ createContext,
+ useContext,
+ useMemo,
+ type Dispatch,
+ type ReactNode,
+ type RefObject,
+ type SetStateAction
+} from "react"
+import type { scaleBand, scaleLinear, scaleTime } from "@visx/scale"
+import type { Transition } from "motion/react"
+
+import type { ChartPhase, ChartStatus } from "./chart-phase"
+import type { ReferenceAreaConfig } from "./reference-area-config"
+import type { ChartSelection } from "./use-chart-interaction"
+import { DEFAULT_Y_AXIS_ID } from "./y-axis-scales"
+import type { YDomain } from "./y-domain-utils"
+
+type ScaleLinear = ReturnType<
+ typeof scaleLinear
+>
+type ScaleTime = ReturnType<
+ typeof scaleTime
+>
+type ScaleBand = ReturnType<
+ typeof scaleBand
+>
+
+// CSS variable references for theming
+export const chartCssVars = {
+ background: "var(--chart-background)",
+ foreground: "var(--chart-foreground)",
+ foregroundMuted: "var(--chart-foreground-muted)",
+ label: "var(--chart-label)",
+ linePrimary: "var(--chart-line-primary)",
+ lineSecondary: "var(--chart-line-secondary)",
+ crosshair: "var(--chart-crosshair)",
+ grid: "var(--chart-grid)",
+ indicatorColor: "var(--chart-indicator-color)",
+ indicatorSecondaryColor: "var(--chart-indicator-secondary-color)",
+ markerBackground: "var(--chart-marker-background)",
+ markerBorder: "var(--chart-marker-border)",
+ markerForeground: "var(--chart-marker-foreground)",
+ badgeBackground: "var(--chart-marker-badge-background)",
+ badgeForeground: "var(--chart-marker-badge-foreground)",
+ segmentBackground: "var(--chart-segment-background)",
+ segmentLine: "var(--chart-segment-line)",
+ brushBorder: "var(--chart-brush-border)",
+ tooltipBackground: "var(--chart-tooltip-background)"
+}
+
+/** Default scatter series colors from the chart palette (`--chart-1` … `--chart-5`). */
+export const defaultScatterColors = [
+ "var(--chart-1)",
+ "var(--chart-2)",
+ "var(--chart-3)",
+ "var(--chart-4)",
+ "var(--chart-5)"
+] as const
+
+export interface Margin {
+ top: number
+ right: number
+ bottom: number
+ left: number
+}
+
+export interface TooltipData {
+ /** The data point being hovered */
+ point: Record
+ /** Index in the data array */
+ index: number
+ /** X position in pixels (relative to chart area) */
+ x: number
+ /** Y positions for each line, keyed by dataKey */
+ yPositions: Record
+ /** X positions for each series (for grouped bars), keyed by dataKey */
+ xPositions?: Record
+}
+
+export interface LineConfig {
+ dataKey: string
+ stroke: string
+ strokeWidth: number
+ /** Scale group id (Recharts `yAxisId`). Default: `"left"`. */
+ yAxisId?: string | number
+}
+
+/**
+ * Hover/selection state — every field here changes on mouse movement.
+ * Lives in its own context so cold consumers (Grid, YAxis, PatternArea, …)
+ * can subscribe to the stable slice and skip re-rendering on every hover.
+ */
+export interface ChartHoverContextValue {
+ // Tooltip state
+ tooltipData: TooltipData | null
+ setTooltipData: Dispatch>
+
+ // Selection state (optional - only present when useChartInteraction is used)
+ /** Current drag/pinch selection range */
+ selection?: ChartSelection | null
+ /** Clear the current selection */
+ clearSelection?: () => void
+
+ // Bar chart hover (optional - only present in BarChart)
+ /** Index of currently hovered bar */
+ hoveredBarIndex?: number | null
+ /** Setter for hovered bar index */
+ setHoveredBarIndex?: (index: number | null) => void
+
+ // Candlestick hover (optional - only present in CandlestickChart)
+ /** Index of currently hovered candle */
+ hoveredCandleIndex?: number | null
+ /** Setter for hovered candle index */
+ setHoveredCandleIndex?: (index: number | null) => void
+}
+
+export interface ChartContextValue extends ChartHoverContextValue {
+ // Data
+ data: Record[]
+ /** Decimated subset for SVG path rendering; equals `data` when no decimation is needed. */
+ renderData: Record[]
+
+ // Scales
+ xScale: ScaleTime
+ /** Primary (left) y-scale — alias for `yScales[DEFAULT_Y_AXIS_ID]`. */
+ yScale: ScaleLinear
+ /** Per-axis y-scales keyed by `yAxisId`. */
+ yScales: Record>
+
+ // Dimensions
+ width: number
+ height: number
+ innerWidth: number
+ innerHeight: number
+ margin: Margin
+
+ // Column width for spacing calculations
+ columnWidth: number
+
+ // Container ref for portals
+ containerRef: RefObject
+
+ // Line configurations (extracted from children)
+ lines: LineConfig[]
+
+ /** {@link ReferenceArea} bands — drives y-axis label colors in range. */
+ referenceAreas: ReferenceAreaConfig[]
+
+ // Loading / lifecycle (LineChart status transitions)
+ chartPhase: ChartPhase
+ chartStatus: ChartStatus
+ /** Centered label while `chartPhase` shows loading chrome. */
+ loadingLabel?: string
+ /** Y-domain tween duration when transitioning loading ↔ ready (ms). */
+ yDomainTweenDuration: number
+ /** Nice’d y-domains per axis from skeleton data (placeholder). */
+ yDomainSkeletonByAxis: Record
+ /** Nice’d y-domains per axis from the current target data. */
+ yDomainTargetByAxis: Record
+
+ // Animation state
+ isLoaded: boolean
+ animationDuration: number
+ /** CSS easing for clip-reveal / line draw (cartesian charts). */
+ animationEasing?: string
+ /** Motion enter transition (spring or tween) — drives clip reveal when spring. */
+ enterTransition?: Transition
+ /** Increments when enter animation should replay. */
+ revealEpoch?: number
+ /** Fired when a one-shot loading pulse (exit / enter) completes. */
+ notifyLoadingPulseComplete?: () => void
+
+ // X accessor - how to get the x value from data points
+ xAccessor: (d: Record) => Date
+
+ // Pre-computed date labels for ticker animation
+ dateLabels: string[]
+
+ /** Active brush zoom range — when set, axis ticks align to visible data rows. */
+ xDomain?: [Date, Date]
+ /** Full dataset length when brush zoom is enabled (for zoom vs full-range detection). */
+ xDomainSlotCount?: number
+
+ // Bar chart specific (optional - only present in BarChart)
+ /** Band scale for categorical x-axis (bar charts) */
+ barScale?: ScaleBand
+ /** Width of each bar band */
+ bandWidth?: number
+ /** X accessor for bar charts (returns string instead of Date) */
+ barXAccessor?: (d: Record) => string
+ /** Bar chart orientation */
+ orientation?: "vertical" | "horizontal"
+ /** Whether bars are stacked */
+ stacked?: boolean
+ /** Stack offsets: Map of data index -> Map of dataKey -> cumulative offset */
+ stackOffsets?: Map>
+
+ // ComposedChart + SeriesBar (optional)
+ /** `SeriesBar` dataKeys in tree order, for grouped columns at each x */
+ composedBarDataKeys?: string[]
+ /** Target bar width in px (Recharts `barSize` style). */
+ composedBarSize?: number
+ /** Max bar width in px (Recharts `maxBarSize`). */
+ composedMaxBarSize?: number
+ /** Gap between grouped `SeriesBar` columns in px. */
+ composedBarGap?: number
+ /** When true, `SeriesBar` segments stack in child order at each x. */
+ composedStacked?: boolean
+ /** Per-row cumulative offsets for stacked `SeriesBar` (data index → dataKey → offset). */
+ composedStackOffsets?: Map>
+ /** Vertical gap in px between stacked `SeriesBar` segments. Default: 0 */
+ composedStackGap?: number
+}
+
+/**
+ * Stable slice of the chart context — everything that doesn't change on hover
+ * (data, scales, dimensions, animation state, layout config). Consumers that
+ * subscribe via `useChartStable()` skip re-renders on every mouse move.
+ */
+export type ChartStableContextValue = Omit<
+ ChartContextValue,
+ keyof ChartHoverContextValue
+>
+
+const ChartStableContext = createContext(null)
+const ChartHoverContext = createContext(null)
+
+/**
+ * Splits the merged `value` into a stable slice and a volatile hover slice,
+ * publishing each to its own context. Each slice is memoized on its own
+ * field identities, so changing `tooltipData` does not bust the stable
+ * slice — consumers of `useChartStable()` skip re-renders on hover.
+ */
+export function ChartProvider({
+ children,
+ value
+}: {
+ children: ReactNode
+ value: ChartContextValue
+}) {
+ const stable = useMemo(
+ () => ({
+ data: value.data,
+ renderData: value.renderData,
+ xScale: value.xScale,
+ yScale: value.yScale,
+ yScales: value.yScales,
+ width: value.width,
+ height: value.height,
+ innerWidth: value.innerWidth,
+ innerHeight: value.innerHeight,
+ margin: value.margin,
+ columnWidth: value.columnWidth,
+ containerRef: value.containerRef,
+ lines: value.lines,
+ referenceAreas: value.referenceAreas,
+ chartPhase: value.chartPhase,
+ chartStatus: value.chartStatus,
+ loadingLabel: value.loadingLabel,
+ yDomainTweenDuration: value.yDomainTweenDuration,
+ yDomainSkeletonByAxis: value.yDomainSkeletonByAxis,
+ yDomainTargetByAxis: value.yDomainTargetByAxis,
+ isLoaded: value.isLoaded,
+ animationDuration: value.animationDuration,
+ animationEasing: value.animationEasing,
+ enterTransition: value.enterTransition,
+ revealEpoch: value.revealEpoch,
+ notifyLoadingPulseComplete: value.notifyLoadingPulseComplete,
+ xAccessor: value.xAccessor,
+ dateLabels: value.dateLabels,
+ xDomain: value.xDomain,
+ xDomainSlotCount: value.xDomainSlotCount,
+ barScale: value.barScale,
+ bandWidth: value.bandWidth,
+ barXAccessor: value.barXAccessor,
+ orientation: value.orientation,
+ stacked: value.stacked,
+ stackOffsets: value.stackOffsets,
+ composedBarDataKeys: value.composedBarDataKeys,
+ composedBarSize: value.composedBarSize,
+ composedMaxBarSize: value.composedMaxBarSize,
+ composedBarGap: value.composedBarGap,
+ composedStacked: value.composedStacked,
+ composedStackOffsets: value.composedStackOffsets,
+ composedStackGap: value.composedStackGap
+ }),
+ [
+ value.data,
+ value.renderData,
+ value.xScale,
+ value.yScale,
+ value.yScales,
+ value.width,
+ value.height,
+ value.innerWidth,
+ value.innerHeight,
+ value.margin,
+ value.columnWidth,
+ value.containerRef,
+ value.lines,
+ value.referenceAreas,
+ value.chartPhase,
+ value.chartStatus,
+ value.loadingLabel,
+ value.yDomainTweenDuration,
+ value.yDomainSkeletonByAxis,
+ value.yDomainTargetByAxis,
+ value.isLoaded,
+ value.animationDuration,
+ value.animationEasing,
+ value.enterTransition,
+ value.revealEpoch,
+ value.notifyLoadingPulseComplete,
+ value.xAccessor,
+ value.dateLabels,
+ value.xDomain,
+ value.xDomainSlotCount,
+ value.barScale,
+ value.bandWidth,
+ value.barXAccessor,
+ value.orientation,
+ value.stacked,
+ value.stackOffsets,
+ value.composedBarDataKeys,
+ value.composedBarSize,
+ value.composedMaxBarSize,
+ value.composedBarGap,
+ value.composedStacked,
+ value.composedStackOffsets,
+ value.composedStackGap
+ ]
+ )
+
+ const hover = useMemo(
+ () => ({
+ tooltipData: value.tooltipData,
+ setTooltipData: value.setTooltipData,
+ selection: value.selection,
+ clearSelection: value.clearSelection,
+ hoveredBarIndex: value.hoveredBarIndex,
+ setHoveredBarIndex: value.setHoveredBarIndex,
+ hoveredCandleIndex: value.hoveredCandleIndex,
+ setHoveredCandleIndex: value.setHoveredCandleIndex
+ }),
+ [
+ value.tooltipData,
+ value.setTooltipData,
+ value.selection,
+ value.clearSelection,
+ value.hoveredBarIndex,
+ value.setHoveredBarIndex,
+ value.hoveredCandleIndex,
+ value.setHoveredCandleIndex
+ ]
+ )
+
+ return (
+
+
+ {children}
+
+
+ )
+}
+
+/**
+ * Stable slice — data, scales, dimensions, animation state, layout config.
+ * Subscribers skip re-renders on hover (the hover slice lives in a separate
+ * context). Prefer this in cold consumers like axes, grid, pattern fills.
+ */
+export function useChartStable(): ChartStableContextValue {
+ const context = useContext(ChartStableContext)
+ if (!context) {
+ throw new Error(
+ "useChartStable must be used within a ChartProvider. " +
+ "Make sure your component is wrapped in , , , or ."
+ )
+ }
+ return context
+}
+
+/** Y-scale for a series axis (`yAxisId` on Line / Area / YAxis). */
+export function useYScale(
+ yAxisId?: string | number
+): ScaleLinear {
+ const { yScales, yScale } = useChartStable()
+ const id =
+ yAxisId == null || yAxisId === "" ? DEFAULT_Y_AXIS_ID : String(yAxisId)
+ return yScales[id] ?? yScale
+}
+
+/**
+ * Hover slice — tooltipData, selection, hovered bar / candle indices.
+ * Subscribers re-render on every mouse move. Use only when the component
+ * actually reads hover state.
+ */
+export function useChartHover(): ChartHoverContextValue {
+ const context = useContext(ChartHoverContext)
+ if (!context) {
+ throw new Error(
+ "useChartHover must be used within a ChartProvider. " +
+ "Make sure your component is wrapped in , , , or ."
+ )
+ }
+ return context
+}
+
+/**
+ * Merged stable + hover context. Convenient for components that need both,
+ * but re-renders on every hover (because hover changes). Prefer
+ * `useChartStable()` or `useChartHover()` for hot consumers that only need
+ * one slice.
+ */
+export function useChart(): ChartContextValue {
+ const stable = useChartStable()
+ const hover = useChartHover()
+ // Identity changes on every hover (hover is the volatile slice) — that's
+ // fine for consumers using this merged hook; they explicitly opted in to
+ // re-rendering on hover.
+ return { ...stable, ...hover }
+}
+
+export default ChartStableContext
diff --git a/src/components/charts/chart-defs.ts b/src/components/charts/chart-defs.ts
new file mode 100644
index 00000000..27221af9
--- /dev/null
+++ b/src/components/charts/chart-defs.ts
@@ -0,0 +1,72 @@
+import {
+ Children,
+ isValidElement,
+ type ReactElement,
+ type ReactNode
+} from "react"
+
+export function getChartChildComponentName(child: ReactElement): string {
+ const childType = child.type as { displayName?: string; name?: string }
+ return typeof child.type === "function"
+ ? childType.displayName || childType.name || ""
+ : ""
+}
+
+const VISX_PATTERN_COMPONENT_NAMES = new Set([
+ "Lines",
+ "Circles",
+ "Waves",
+ "Hexagons",
+ "Path",
+ "Pattern"
+])
+
+/** @visx/pattern default exports use short names (e.g. `Lines`); also match *Pattern* displayNames. */
+export function isPatternDefComponent(child: ReactElement): boolean {
+ const name = getChartChildComponentName(child)
+ return name.includes("Pattern") || VISX_PATTERN_COMPONENT_NAMES.has(name)
+}
+
+export function isGradientDefComponent(child: ReactElement): boolean {
+ const name = getChartChildComponentName(child)
+ return (
+ name.includes("Gradient") ||
+ name === "LinearGradient" ||
+ name === "RadialGradient"
+ )
+}
+
+export function isChartDefsComponent(child: ReactElement): boolean {
+ return isPatternDefComponent(child) || isGradientDefComponent(child)
+}
+
+/** Split hoisted defs: @visx/pattern nodes already wrap `` and render at the svg root. */
+export function partitionChartDefNodes(defNodes: ReactElement[]): {
+ patternDefNodes: ReactElement[]
+ gradientDefNodes: ReactElement[]
+} {
+ const patternDefNodes: ReactElement[] = []
+ const gradientDefNodes: ReactElement[] = []
+
+ for (const node of defNodes) {
+ if (isPatternDefComponent(node)) {
+ patternDefNodes.push(node)
+ } else {
+ gradientDefNodes.push(node)
+ }
+ }
+
+ return { patternDefNodes, gradientDefNodes }
+}
+
+export function collectChartDefsChildren(children: ReactNode): ReactElement[] {
+ const defNodes: ReactElement[] = []
+
+ Children.forEach(children, child => {
+ if (isValidElement(child) && isChartDefsComponent(child)) {
+ defNodes.push(child)
+ }
+ })
+
+ return defNodes
+}
diff --git a/src/components/charts/chart-formatters.ts b/src/components/charts/chart-formatters.ts
new file mode 100644
index 00000000..b0ed580d
--- /dev/null
+++ b/src/components/charts/chart-formatters.ts
@@ -0,0 +1,20 @@
+export const shortDateFmt = new Intl.DateTimeFormat("en-US", {
+ month: "short",
+ day: "numeric"
+})
+
+export const weekdayDateFmt = new Intl.DateTimeFormat("en-US", {
+ weekday: "short",
+ month: "short",
+ day: "numeric"
+})
+
+export const hmsTimeFmt = new Intl.DateTimeFormat("en-US", {
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ hour12: false
+})
+
+// `Intl.NumberFormat.prototype.format` is a bound getter — safe to extract.
+export const intFmt = new Intl.NumberFormat("en-US").format
diff --git a/src/components/charts/chart-legend-hover.tsx b/src/components/charts/chart-legend-hover.tsx
new file mode 100644
index 00000000..def2d301
--- /dev/null
+++ b/src/components/charts/chart-legend-hover.tsx
@@ -0,0 +1,44 @@
+"use client"
+
+import { createContext, useContext, useMemo, type ReactNode } from "react"
+
+interface ChartLegendHoverContextValue {
+ hoveredIndex: number | null
+ setHoveredIndex: (index: number | null) => void
+}
+
+const ChartLegendHoverContext =
+ createContext(null)
+
+export function ChartLegendHoverProvider({
+ hoveredIndex,
+ onHoverChange,
+ children
+}: {
+ hoveredIndex: number | null
+ onHoverChange: (index: number | null) => void
+ children: ReactNode
+}) {
+ const value = useMemo(
+ () => ({ hoveredIndex, setHoveredIndex: onHoverChange }),
+ [hoveredIndex, onHoverChange]
+ )
+
+ return (
+
+ {children}
+
+ )
+}
+
+export function useChartLegendHover(): ChartLegendHoverContextValue {
+ const context = useContext(ChartLegendHoverContext)
+ return (
+ context ?? {
+ hoveredIndex: null,
+ setHoveredIndex: () => {
+ /* noop outside ChartLegendHoverProvider */
+ }
+ }
+ )
+}
diff --git a/src/components/charts/chart-phase.ts b/src/components/charts/chart-phase.ts
new file mode 100644
index 00000000..709f8d9e
--- /dev/null
+++ b/src/components/charts/chart-phase.ts
@@ -0,0 +1,52 @@
+export type ChartStatus = "loading" | "ready"
+
+/** Loading animation style: the default traveling pulse, or a diagonal
+ * shimmer that sweeps across the skeleton. */
+export type LoadingStyle = "pulse" | "sweep"
+
+/**
+ * Internal visual lifecycle phase. Forward and reverse transitions add
+ * intermediate phases in later stack branches.
+ */
+export type ChartPhase =
+ | "loading"
+ | "exiting"
+ | "gridTweenReady"
+ | "revealing"
+ | "ready"
+ | "exitingReady"
+ | "gridTweenLoading"
+ | "revealingLoading"
+
+export const DEFAULT_CHART_STATUS: ChartStatus = "ready"
+
+/** Default Y-domain tween when transitioning loading ↔ ready (ms). */
+export const DEFAULT_Y_DOMAIN_TWEEN_MS = 500
+
+/** Relative domain delta below which Y tween may be skipped (see plan). */
+export const Y_DOMAIN_TWEEN_SKIP_THRESHOLD = 0.02
+
+/** Resting phase for a given status before transition orchestration runs. */
+export function resolveRestingChartPhase(status: ChartStatus): ChartPhase {
+ return status === "loading" ? "loading" : "ready"
+}
+
+export function isChartInteractionPhase(phase: ChartPhase): boolean {
+ return phase === "ready"
+}
+
+export const DEFAULT_CHART_LIFECYCLE = {
+ chartPhase: "ready",
+ chartStatus: "ready",
+ loadingLabel: undefined,
+ yDomainTweenDuration: DEFAULT_Y_DOMAIN_TWEEN_MS,
+ yDomainSkeletonByAxis: { left: [0, 100] as [number, number] },
+ yDomainTargetByAxis: { left: [0, 100] as [number, number] }
+} as const satisfies {
+ chartPhase: ChartPhase
+ chartStatus: ChartStatus
+ loadingLabel: undefined
+ yDomainTweenDuration: number
+ yDomainSkeletonByAxis: Record
+ yDomainTargetByAxis: Record
+}
diff --git a/src/components/charts/chart-reveal-clip.tsx b/src/components/charts/chart-reveal-clip.tsx
new file mode 100644
index 00000000..cafb641e
--- /dev/null
+++ b/src/components/charts/chart-reveal-clip.tsx
@@ -0,0 +1,93 @@
+"use client"
+
+import type { Transition } from "motion/react"
+import { motion } from "motion/react"
+
+import { clipRevealTransition } from "./animation"
+
+export type ChartRevealClipMode = "reveal" | "conceal"
+
+export interface ChartRevealClipProps {
+ clipPathId: string
+ height: number
+ targetWidth: number
+ enterTransition?: Transition
+ /** Bumps when motion settings change to replay the reveal. */
+ revealEpoch: number
+ /** Extra inset around the clip rect so edge glyphs are not cut off. */
+ padding?: number
+ /** When false, clip stays at full width (no grow animation). */
+ animating?: boolean
+ /** Reveal grows 0 → full; conceal shrinks full → 0 (ready → loading). */
+ mode?: ChartRevealClipMode
+ /** Called when a conceal animation finishes. */
+ onComplete?: () => void
+}
+
+/**
+ * Left-to-right clip reveal for cartesian series.
+ * Grows clip rect width from 0 → full (true LTR; scaleX is avoided — it reveals from center).
+ */
+export function ChartRevealClip({
+ clipPathId,
+ height,
+ targetWidth,
+ enterTransition,
+ revealEpoch,
+ padding = 0,
+ animating = true,
+ mode = "reveal",
+ onComplete
+}: ChartRevealClipProps) {
+ const transition = clipRevealTransition(enterTransition)
+ const paddedWidth = Math.max(0, targetWidth + padding * 2)
+ const paddedHeight = height + padding * 2
+
+ if (!animating) {
+ return (
+
+
+
+ )
+ }
+
+ if (mode === "conceal") {
+ // Mirror the LTR reveal: advance the clip's left edge rightward while width
+ // shrinks (same geometry as `LineLoadingPulseStroke` exit half-cycle).
+ const rightEdge = -padding + paddedWidth
+
+ return (
+
+ onComplete?.()}
+ transition={transition}
+ y={-padding}
+ />
+
+ )
+ }
+
+ return (
+
+
+
+ )
+}
diff --git a/src/components/charts/decimate-time-series.ts b/src/components/charts/decimate-time-series.ts
new file mode 100644
index 00000000..78405891
--- /dev/null
+++ b/src/components/charts/decimate-time-series.ts
@@ -0,0 +1,136 @@
+export function decimateTimeSeries>(
+ data: T[],
+ maxPoints: number,
+ valueKeys: string[] = []
+): T[] {
+ const len = data.length
+ if (maxPoints >= len || maxPoints < 3) {
+ return data
+ }
+
+ const getY = (point: T, index: number): number => {
+ if (valueKeys.length === 0) {
+ for (const val of Object.values(point)) {
+ if (typeof val === "number") {
+ return val
+ }
+ }
+ return index
+ }
+
+ let sum = 0
+ let count = 0
+ for (const key of valueKeys) {
+ const val = point[key]
+ if (typeof val === "number") {
+ sum += val
+ count++
+ }
+ }
+ return count > 0 ? sum / count : index
+ }
+
+ const sampled: T[] = [data[0] as T]
+ const bucketSize = (len - 2) / (maxPoints - 2)
+ let previousIndex = 0
+
+ for (let i = 0; i < maxPoints - 2; i++) {
+ const rangeStart = Math.floor((i + 1) * bucketSize) + 1
+ const rangeEnd = Math.min(Math.floor((i + 2) * bucketSize) + 1, len - 1)
+
+ const nextRangeStart = Math.floor((i + 2) * bucketSize) + 1
+ const nextRangeEnd = Math.min(Math.floor((i + 3) * bucketSize) + 1, len)
+ const nextCount = Math.max(0, nextRangeEnd - nextRangeStart)
+
+ let avgX = len - 1
+ let avgY = getY(data[len - 1] as T, len - 1)
+ if (nextCount > 0) {
+ avgX = 0
+ avgY = 0
+ for (let j = nextRangeStart; j < nextRangeEnd; j++) {
+ avgX += j
+ avgY += getY(data[j] as T, j)
+ }
+ avgX /= nextCount
+ avgY /= nextCount
+ }
+
+ const pointA = data[previousIndex] as T
+ const ax = previousIndex
+ const ay = getY(pointA, previousIndex)
+
+ let maxArea = -1
+ let maxIndex = rangeStart
+
+ for (let j = rangeStart; j < rangeEnd; j++) {
+ const area =
+ Math.abs(
+ (ax - avgX) * (getY(data[j] as T, j) - ay) - (ax - j) * (avgY - ay)
+ ) * 0.5
+ if (area > maxArea) {
+ maxArea = area
+ maxIndex = j
+ }
+ }
+
+ sampled.push(data[maxIndex] as T)
+ previousIndex = maxIndex
+ }
+
+ sampled.push(data[len - 1] as T)
+ return sampled
+}
+
+/** ~1.5 points per pixel — enough for crisp curves without over-drawing. */
+export function maxRenderPointsForWidth(innerWidth: number): number {
+ return Math.max(64, Math.ceil(innerWidth * 1.5))
+}
+
+/** Bucket OHLC rows into fewer candles while preserving high/low extremes. */
+export function decimateOhlcData>(
+ data: T[],
+ maxPoints: number
+): T[] {
+ const len = data.length
+ if (maxPoints >= len || maxPoints < 2) {
+ return data
+ }
+
+ const bucketSize = len / maxPoints
+ const sampled: T[] = []
+
+ for (let i = 0; i < maxPoints; i++) {
+ const start = Math.floor(i * bucketSize)
+ const end = Math.min(len, Math.floor((i + 1) * bucketSize))
+ if (start >= end) {
+ continue
+ }
+
+ const bucket = data.slice(start, end)
+ const first = bucket[0] as T
+ const last = bucket.at(-1) as T
+
+ let high = Number.NEGATIVE_INFINITY
+ let low = Number.POSITIVE_INFINITY
+ for (const row of bucket) {
+ const rowHigh = row.high
+ const rowLow = row.low
+ if (typeof rowHigh === "number" && rowHigh > high) {
+ high = rowHigh
+ }
+ if (typeof rowLow === "number" && rowLow < low) {
+ low = rowLow
+ }
+ }
+
+ sampled.push({
+ ...last,
+ open: first.open,
+ high: Number.isFinite(high) ? high : last.high,
+ low: Number.isFinite(low) ? low : last.low,
+ close: last.close
+ } as T)
+ }
+
+ return sampled
+}
diff --git a/src/components/charts/filter-data-by-x-domain.ts b/src/components/charts/filter-data-by-x-domain.ts
new file mode 100644
index 00000000..08ba70a8
--- /dev/null
+++ b/src/components/charts/filter-data-by-x-domain.ts
@@ -0,0 +1,59 @@
+export function filterDataByXDomain(
+ data: Record[],
+ xDomain: [Date, Date],
+ xAccessor: (d: Record) => Date
+): Record[] {
+ const start = xDomain[0].getTime()
+ const end = xDomain[1].getTime()
+ const minTime = Math.min(start, end)
+ const maxTime = Math.max(start, end)
+
+ return data.filter(d => {
+ const time = xAccessor(d).getTime()
+ return time >= minTime && time <= maxTime
+ })
+}
+
+export function resolveDataXExtent(
+ data: Record[],
+ xAccessor: (d: Record) => Date
+): [Date, Date] | null {
+ if (data.length === 0) {
+ return null
+ }
+
+ let minTime = Number.POSITIVE_INFINITY
+ let maxTime = Number.NEGATIVE_INFINITY
+
+ for (const point of data) {
+ const time = xAccessor(point).getTime()
+ if (time < minTime) {
+ minTime = time
+ }
+ if (time > maxTime) {
+ maxTime = time
+ }
+ }
+
+ if (minTime === Number.POSITIVE_INFINITY) {
+ return null
+ }
+
+ return [new Date(minTime), new Date(maxTime)]
+}
+
+/** Brush track extent — optionally extends past the last data row (e.g. projections). */
+export function resolveBrushTrackXExtent(
+ data: Record[],
+ xAccessor: (d: Record) => Date,
+ xExtentMax?: Date
+): [Date, Date] | null {
+ const extent = resolveDataXExtent(data, xAccessor)
+ if (!extent) {
+ return null
+ }
+ if (!xExtentMax || xExtentMax.getTime() <= extent[1].getTime()) {
+ return extent
+ }
+ return [extent[0], xExtentMax]
+}
diff --git a/src/components/charts/generate-chart-skeleton-data.ts b/src/components/charts/generate-chart-skeleton-data.ts
new file mode 100644
index 00000000..78257f39
--- /dev/null
+++ b/src/components/charts/generate-chart-skeleton-data.ts
@@ -0,0 +1,42 @@
+const DEFAULT_SKELETON_DATA_KEY = "value"
+const DEFAULT_SKELETON_POINT_COUNT = 7
+
+export interface GenerateChartSkeletonDataOptions {
+ /** Key used for y values in each row. Default: `"value"`. */
+ dataKey?: string
+ /** Number of points. Default: 7. */
+ pointCount?: number
+ /** Start date for the x axis. Default: 2025-01-01. */
+ baseDate?: Date
+}
+
+/** Placeholder series used while `status="loading"` and data is empty. */
+export function generateChartSkeletonData(
+ options: GenerateChartSkeletonDataOptions = {}
+): Record[] {
+ const dataKey = options.dataKey ?? DEFAULT_SKELETON_DATA_KEY
+ const pointCount = options.pointCount ?? DEFAULT_SKELETON_POINT_COUNT
+ const baseDate = options.baseDate ?? new Date("2025-01-01")
+
+ return Array.from({ length: pointCount }, (_, index) => {
+ const date = new Date(baseDate)
+ date.setDate(baseDate.getDate() + index)
+ return {
+ date,
+ [dataKey]: Math.round(110 + Math.sin(index * 1.15) * 36 + index * 9)
+ }
+ })
+}
+
+/** Skeleton rows that mirror target dates/count with lower magnitudes for Y tween. */
+export function generateChartSkeletonFromTarget(
+ targetData: Record[],
+ dataKey: string
+): Record[] {
+ return targetData.map((row, index) => ({
+ ...row,
+ [dataKey]: Math.round(95 + Math.sin(index * 1.05) * 28 + index * 7)
+ }))
+}
+
+export { DEFAULT_SKELETON_DATA_KEY, DEFAULT_SKELETON_POINT_COUNT }
diff --git a/src/components/charts/grid.tsx b/src/components/charts/grid.tsx
new file mode 100644
index 00000000..8bd84e20
--- /dev/null
+++ b/src/components/charts/grid.tsx
@@ -0,0 +1,318 @@
+"use client"
+
+import { useId } from "react"
+import { GridColumns, GridRows } from "@visx/grid"
+import { motion } from "motion/react"
+
+import { chartCssVars, useChartStable, useYScale } from "./chart-context"
+import { useGridShimmer } from "./use-grid-shimmer"
+import {
+ isLoadingChromePhase,
+ isLoadingGridChromePhase
+} from "./y-domain-utils"
+
+const DEFAULT_SHIMMER_LENGTH_PX = 140
+const DEFAULT_SHIMMER_SPEED = 1
+const DEFAULT_SHIMMER_STROKE =
+ "color-mix(in oklch, var(--foreground) 68%, transparent)"
+
+export interface GridProps {
+ /** Show horizontal grid lines. Default: true */
+ horizontal?: boolean
+ /** Show vertical grid lines. Default: false */
+ vertical?: boolean
+ /** Number of horizontal grid lines. Default: 5 */
+ numTicksRows?: number
+ /** Number of vertical grid lines. Default: 10 */
+ numTicksColumns?: number
+ /** Explicit tick values for horizontal grid lines. Overrides numTicksRows. */
+ rowTickValues?: number[]
+ /** Grid line stroke color. Default: var(--chart-grid) */
+ stroke?: string
+ /** Grid stroke while loading chrome is active. Falls back to `stroke`. */
+ loadingStroke?: string
+ /** Grid line stroke opacity. Default: 1 */
+ strokeOpacity?: number
+ /** Grid line stroke width. Default: 1 */
+ strokeWidth?: number
+ /** Grid line dash array. Default: "4,4" for dashed lines */
+ strokeDasharray?: string
+ /** Horizontal row values rendered with alternate styling (e.g. zero baseline). */
+ highlightRowValues?: number[]
+ /** Stroke for highlighted rows. Default: var(--chart-foreground-muted) */
+ highlightRowStroke?: string
+ /** Stroke opacity for highlighted rows. Default: 1 */
+ highlightRowStrokeOpacity?: number
+ /** Stroke width for highlighted rows. Default: 1 */
+ highlightRowStrokeWidth?: number
+ /** Dash array for highlighted rows. Default: solid line */
+ highlightRowStrokeDasharray?: string
+ /** Enable horizontal fade effect on grid rows (fades at left/right). Default: true */
+ fadeHorizontal?: boolean
+ /** Enable vertical fade effect on grid columns (fades at top/bottom). Default: false */
+ fadeVertical?: boolean
+ /** Omit the first and last horizontal grid lines. Default: false */
+ hideHorizontalEdgeLines?: boolean
+ /** Omit the first and last vertical grid lines. Default: false */
+ hideVerticalEdgeLines?: boolean
+ /** Y-scale for horizontal grid lines. Default: primary (`"left"`) axis. */
+ yAxisId?: string | number
+ /** Animate a shimmer band across horizontal grid lines. Default: false */
+ shimmer?: boolean
+ /** Shimmer band stroke (color and opacity via color-mix or oklch alpha). */
+ shimmerStroke?: string
+ /** Shimmer band width in pixels. Default: 140 */
+ shimmerLength?: number
+ /** Shimmer speed multiplier (higher = faster). Default: 1 */
+ shimmerSpeed?: number
+ /** Match loop timing to the loading line pulse (cycle + inter-loop pause). */
+ shimmerSync?: boolean
+}
+
+function hideEdgeTicks(ticks: T[], hideEdgeLines: boolean): T[] {
+ if (!hideEdgeLines || ticks.length <= 2) {
+ return ticks
+ }
+ return ticks.slice(1, -1)
+}
+
+function resolveRowTickValues(options: {
+ hideHorizontalEdgeLines: boolean
+ numTicksRows: number
+ rowTickValues?: number[]
+ yScale: { ticks?: (count: number) => number[] }
+}): number[] | undefined {
+ const { hideHorizontalEdgeLines, numTicksRows, rowTickValues, yScale } =
+ options
+ const ticks =
+ rowTickValues ?? (yScale.ticks ? yScale.ticks(numTicksRows) : [])
+ const filtered = hideEdgeTicks(ticks, hideHorizontalEdgeLines)
+ if (filtered === ticks && !rowTickValues && !hideHorizontalEdgeLines) {
+ return undefined
+ }
+ return filtered.length > 0 ? filtered : undefined
+}
+
+// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: grid fade masks and shimmer share one layer tree
+export function Grid({
+ horizontal = true,
+ vertical = false,
+ numTicksRows = 5,
+ numTicksColumns = 10,
+ rowTickValues,
+ stroke = chartCssVars.grid,
+ loadingStroke,
+ strokeOpacity = 1,
+ strokeWidth = 1,
+ strokeDasharray = "4,4",
+ highlightRowValues,
+ highlightRowStroke = chartCssVars.foregroundMuted,
+ highlightRowStrokeOpacity = 1,
+ highlightRowStrokeWidth = 1,
+ highlightRowStrokeDasharray = "0",
+ fadeHorizontal = true,
+ fadeVertical = false,
+ hideHorizontalEdgeLines = false,
+ hideVerticalEdgeLines = false,
+ yAxisId,
+ shimmer = false,
+ shimmerStroke = DEFAULT_SHIMMER_STROKE,
+ shimmerLength = DEFAULT_SHIMMER_LENGTH_PX,
+ shimmerSpeed = DEFAULT_SHIMMER_SPEED,
+ shimmerSync = false
+}: GridProps) {
+ const { xScale, innerWidth, innerHeight, orientation, barScale, chartPhase } =
+ useChartStable()
+ const yScale = useYScale(yAxisId)
+ const shimmerActive = shimmer && isLoadingChromePhase(chartPhase)
+ const gridStroke =
+ isLoadingGridChromePhase(chartPhase) && loadingStroke != null
+ ? loadingStroke
+ : stroke
+ const { shimmerEnabled, shimmerTransform } = useGridShimmer({
+ innerWidth,
+ shimmer,
+ shimmerLength,
+ shimmerSpeed,
+ shimmerSync,
+ active: shimmerActive
+ })
+
+ // For bar charts, determine which scale to use for grid lines
+ // Horizontal bar charts: vertical grid should use yScale (value scale)
+ // Vertical bar charts: horizontal grid uses yScale (value scale)
+ const isHorizontalBarChart = orientation === "horizontal" && barScale
+
+ // For vertical grid lines in horizontal bar charts, use yScale (the value scale)
+ // For time-based charts, use xScale
+ const columnScale = isHorizontalBarChart ? yScale : xScale
+ const rowTickValuesResolved = resolveRowTickValues({
+ hideHorizontalEdgeLines,
+ numTicksRows,
+ rowTickValues,
+ yScale
+ })
+ const columnTickValuesResolved =
+ vertical &&
+ columnScale &&
+ typeof columnScale === "function" &&
+ hideVerticalEdgeLines
+ ? (() => {
+ const ticks = columnScale.ticks?.(numTicksColumns) ?? []
+ const filtered = hideEdgeTicks(ticks, true)
+ return filtered.length > 0 ? filtered : undefined
+ })()
+ : undefined
+ const uniqueId = useId()
+
+ // Horizontal fade mask (for grid rows - fades left/right)
+ const hMaskId = `grid-rows-fade-${uniqueId}`
+ const hGradientId = `${hMaskId}-gradient`
+ const shimmerGradientId = `grid-shimmer-${uniqueId}`
+
+ // Vertical fade mask (for grid columns - fades top/bottom)
+ const vMaskId = `grid-cols-fade-${uniqueId}`
+ const vGradientId = `${vMaskId}-gradient`
+ const horizontalFadeMask = fadeHorizontal ? `url(#${hMaskId})` : undefined
+
+ return (
+
+ {/* Gradient mask for horizontal grid lines - fades at left/right */}
+ {horizontal && fadeHorizontal && (
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ {horizontal && shimmerEnabled ? (
+
+
+
+
+
+
+
+
+
+ ) : null}
+
+ {/* Gradient mask for vertical grid lines - fades at top/bottom */}
+ {vertical && fadeVertical && (
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ {horizontal && (
+
+
+ {shimmerEnabled ? (
+
+ ) : null}
+
+ )}
+ {horizontal && highlightRowValues && highlightRowValues.length > 0 ? (
+
+ {highlightRowValues.map(value => {
+ const y = yScale(value)
+ if (y == null || !Number.isFinite(y)) {
+ return null
+ }
+
+ return (
+
+ )
+ })}
+
+ ) : null}
+ {vertical && columnScale && typeof columnScale === "function" && (
+
+
+
+ )}
+
+ )
+}
+
+Grid.displayName = "Grid"
+
+export default Grid
diff --git a/src/components/charts/indicator-fade.ts b/src/components/charts/indicator-fade.ts
new file mode 100644
index 00000000..35641b49
--- /dev/null
+++ b/src/components/charts/indicator-fade.ts
@@ -0,0 +1,64 @@
+export type IndicatorFadeEdges = "both" | "none" | "top" | "bottom"
+
+export interface VerticalFadeSides {
+ top: boolean
+ bottom: boolean
+ any: boolean
+}
+
+export function resolveVerticalFadeSides(
+ fade: IndicatorFadeEdges | boolean
+): VerticalFadeSides {
+ if (fade === false || fade === "none") {
+ return { top: false, bottom: false, any: false }
+ }
+ if (fade === true || fade === "both") {
+ return { top: true, bottom: true, any: true }
+ }
+ if (fade === "top") {
+ return { top: true, bottom: false, any: true }
+ }
+ return { top: false, bottom: true, any: true }
+}
+
+export interface IndicatorFadeGradientStop {
+ offset: string
+ opacity: number
+}
+
+/** Opacity stops for the crosshair vertical gradient. */
+export function indicatorFadeGradientStops(
+ sides: VerticalFadeSides,
+ fadeLengthPercent = 10
+): IndicatorFadeGradientStop[] {
+ const fade = Math.min(40, Math.max(2, fadeLengthPercent))
+ const innerEnd = 100 - fade
+
+ if (!sides.any) {
+ return [{ offset: "0%", opacity: 1 }]
+ }
+
+ if (sides.top && sides.bottom) {
+ return [
+ { offset: "0%", opacity: 0 },
+ { offset: `${fade}%`, opacity: 1 },
+ { offset: "50%", opacity: 1 },
+ { offset: `${innerEnd}%`, opacity: 1 },
+ { offset: "100%", opacity: 0 }
+ ]
+ }
+
+ if (sides.top) {
+ return [
+ { offset: "0%", opacity: 0 },
+ { offset: `${fade}%`, opacity: 1 },
+ { offset: "100%", opacity: 1 }
+ ]
+ }
+
+ return [
+ { offset: "0%", opacity: 1 },
+ { offset: `${innerEnd}%`, opacity: 1 },
+ { offset: "100%", opacity: 0 }
+ ]
+}
diff --git a/src/components/charts/line-loading-timing.ts b/src/components/charts/line-loading-timing.ts
new file mode 100644
index 00000000..f4b1a617
--- /dev/null
+++ b/src/components/charts/line-loading-timing.ts
@@ -0,0 +1,12 @@
+export const LINE_LOADING_PULSE_CYCLE_S = 2.2
+
+/** Idle gap before the loading line pulse restarts (milliseconds). */
+export const LINE_LOADING_LOOP_PAUSE_MS = 280
+
+/** Loading label exit on loading → ready (seconds). */
+export const LOADING_LABEL_EXIT_S = 0.45
+
+/** Loading label drops this many pixels while exiting. */
+export const LOADING_LABEL_EXIT_Y_PX = 30
+
+export const LINE_LOADING_PULSE_EASE = [0.85, 0, 0.15, 1] as const
diff --git a/src/components/charts/loading-sweep.tsx b/src/components/charts/loading-sweep.tsx
new file mode 100644
index 00000000..d9d78e0e
--- /dev/null
+++ b/src/components/charts/loading-sweep.tsx
@@ -0,0 +1,481 @@
+"use client"
+
+import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"
+import { scaleLinear } from "@visx/scale"
+import { AreaClosed, LinePath } from "@visx/shape"
+import type { CurveFactory, CurveFactoryLineOnly } from "@visx/vendor/d3-shape"
+import { motion, useReducedMotion } from "motion/react"
+
+import { chartCssVars, useChartStable } from "./chart-context"
+import {
+ LINE_LOADING_PULSE_EASE,
+ LOADING_LABEL_EXIT_S
+} from "./line-loading-timing"
+
+/**
+ * Shared "sweep" loading visuals. A soft diagonal shimmer band travels across a
+ * self-contained skeleton silhouette on a loop, painted via an SVG mask. The
+ * silhouette re-randomizes between passes (held steady during a pass, re-rolled
+ * once the band clears the right edge) so it reads as live and still loading,
+ * without warping mid-sweep. Used as the `loadingStyle="sweep"` alternative to
+ * the traveling pulse on `` and ` `, and as the skeleton for
+ * ``.
+ */
+
+type LoadingSweepCurveFactory = CurveFactory | CurveFactoryLineOnly
+
+/** One shimmer sweep, in seconds. */
+const DEFAULT_SWEEP_DURATION_S = 2
+/** Sweep travel in objectBoundingBox space: off the left edge to off the right. */
+const SWEEP_START_X = -1
+const SWEEP_END_X = 2
+/** Diagonal tilt of the shimmer band, in degrees. */
+const SWEEP_ANGLE_DEG = 25
+const HEIGHT_MIN_PCT = 20
+const HEIGHT_MAX_PCT = 80
+const DEFAULT_POINT_COUNT = 14
+const BAR_CORNER_RADIUS = 2
+const DEFAULT_BAR_COUNT = 12
+const DEFAULT_FILL = "var(--foreground)"
+const DEFAULT_BAR_FILL_OPACITY = 0.45
+const LINE_STROKE_OPACITY = 0.55
+const AREA_FILL_TOP_OPACITY = 0.18
+const AREA_FILL_BOTTOM_OPACITY = 0.02
+/** Bar width as a fraction of its band (the rest is the inter-bar gap). */
+const DEFAULT_BAR_FRACTION = 0.7
+
+// ─── Pure, SSR-safe helpers ──────────────────────────────────────────────
+// Heights come from a deterministic hash of (index, seed), never
+// `Math.random()`, so the first server render and first client render agree
+// (no Next.js hydration mismatch). Re-randomizing only bumps the numeric seed
+// on the client, after a sweep completes.
+
+/** Cheap deterministic hash to a fractional part in [0, 1). */
+function hashFract(n: number): number {
+ const x = Math.sin(n) * 43_758.5453
+ return x - Math.floor(x)
+}
+
+/** Deterministic heights (percentages of the available height) for a seed. */
+export function getSkeletonHeights(
+ count: number,
+ seed = 0,
+ min = HEIGHT_MIN_PCT,
+ max = HEIGHT_MAX_PCT
+): number[] {
+ const range = max - min
+ return Array.from(
+ { length: count },
+ (_, i) => min + Math.floor(hashFract((i + 1) * 12.9898 + seed) * range)
+ )
+}
+
+/** Deterministic up/down (±1) per bar for the "center" baseline. */
+function getSkeletonSigns(count: number, seed = 0): number[] {
+ return Array.from({ length: count }, (_, i) =>
+ hashFract((i + 1) * 78.233 + seed) < 0.5 ? -1 : 1
+ )
+}
+
+/** Bell-curve opacity stops (sin squared) for the shimmer band's soft edges. */
+function generateEasedGradientStops(
+ steps = 17,
+ minOpacity = 0.05,
+ maxOpacity = 0.9
+) {
+ return Array.from({ length: steps }, (_, i) => {
+ const t = i / (steps - 1)
+ const eased = Math.sin(t * Math.PI) ** 2
+ const opacity = minOpacity + eased * (maxOpacity - minOpacity)
+ return {
+ offset: `${(t * 100).toFixed(0)}%`,
+ opacity: Number(opacity.toFixed(3))
+ }
+ })
+}
+
+// ─── Shared mask defs (`${chartId}-mask` is the mask id) ─────────────────────
+
+function LoadingSweepMask({
+ chartId,
+ width,
+ height,
+ durationSeconds,
+ onSweepComplete
+}: {
+ chartId: string
+ width: number
+ height: number
+ durationSeconds: number
+ onSweepComplete: () => void
+}) {
+ const gradientStops = useMemo(() => generateEasedGradientStops(), [])
+ const lastXRef = useRef(SWEEP_START_X)
+
+ const handleUpdate = useCallback(
+ (latest: { x?: number }) => {
+ const xValue = typeof latest.x === "number" ? latest.x : SWEEP_START_X
+ // Re-roll once the band has cleared the visible area (crossed past 1),
+ // so the silhouette never changes shape under the user's eye.
+ if (xValue >= 1 && lastXRef.current < 1) {
+ onSweepComplete()
+ }
+ lastXRef.current = xValue
+ },
+ [onSweepComplete]
+ )
+
+ return (
+ <>
+
+ {gradientStops.map(({ offset, opacity }) => (
+
+ ))}
+
+
+
+
+
+
+
+ >
+ )
+}
+
+// ─── Line / Area loading sweep (its own re-randomizing silhouette) ───────────
+
+export interface LineLoadingSweepProps {
+ /** Curve factory from the host `` / ` `, so the silhouette matches
+ * the chart's interpolation (step, smooth, linear, …). */
+ curve: LoadingSweepCurveFactory
+ /** Fill the silhouette as an area (for ` `); otherwise stroke only. */
+ withArea?: boolean
+ /** Loading phase: `"loop"` (steady), `"exit"` (loading → ready), or `"enter"`
+ * (ready → loading). Exit/enter fade the silhouette and then signal the chart
+ * to continue its reveal. Default: `"loop"`. */
+ mode?: "loop" | "exit" | "enter"
+ /** Fired when an exit/enter transition finishes, to advance the chart phase. */
+ onTransitionComplete?: () => void
+ stroke?: string
+ strokeOpacity?: number
+ strokeWidth?: number
+ pointCount?: number
+ durationSeconds?: number
+}
+
+/**
+ * Renders a placeholder line/area silhouette (its own, not the chart's skeleton)
+ * with the shimmer sweeping across it. The silhouette re-randomizes between
+ * passes. Reads inner dimensions from chart context.
+ */
+export function LineLoadingSweep({
+ curve,
+ withArea = false,
+ mode = "loop",
+ onTransitionComplete,
+ stroke = chartCssVars.foreground,
+ strokeOpacity = LINE_STROKE_OPACITY,
+ strokeWidth = 2,
+ pointCount = DEFAULT_POINT_COUNT,
+ durationSeconds = DEFAULT_SWEEP_DURATION_S
+}: LineLoadingSweepProps) {
+ const { innerWidth, innerHeight } = useChartStable()
+ const reduceMotion = useReducedMotion()
+ const reactId = useId()
+ const chartId = `line-sweep-${reactId.replace(/[^a-zA-Z0-9_-]/g, "")}`
+ const isLoop = mode === "loop"
+
+ const [tick, setTick] = useState(0)
+ // Re-randomize only while looping; hold the silhouette steady through a
+ // transition so it fades out (or in) as one piece.
+ const onSweepComplete = useCallback(() => {
+ if (isLoop) {
+ setTick(prev => prev + 1)
+ }
+ }, [isLoop])
+ const heights = useMemo(
+ () => getSkeletonHeights(pointCount, tick),
+ [pointCount, tick]
+ )
+
+ // With reduced motion there is no fade to await, so signal the handoff
+ // immediately or the phase machine would stall mid-transition.
+ useEffect(() => {
+ if (reduceMotion && !isLoop) {
+ onTransitionComplete?.()
+ }
+ }, [reduceMotion, isLoop, onTransitionComplete])
+
+ if (innerWidth <= 0 || innerHeight <= 0 || heights.length < 2) {
+ return null
+ }
+
+ const xScale = scaleLinear({
+ domain: [0, heights.length - 1],
+ range: [0, innerWidth]
+ })
+ const yScale = scaleLinear({ domain: [0, 100], range: [innerHeight, 0] })
+ const points = heights.map((value, index) => ({ index, value }))
+ const getX = (d: { index: number }) => xScale(d.index)
+ const getY = (d: { value: number }) => yScale(d.value)
+
+ const silhouette = (
+ <>
+ {withArea ? (
+
+ ) : null}
+
+ >
+ )
+
+ const areaGradient = withArea ? (
+
+
+
+
+ ) : null
+
+ if (reduceMotion) {
+ return (
+ <>
+ {areaGradient ? {areaGradient} : null}
+ {silhouette}
+ >
+ )
+ }
+
+ const maskUrl = `url(#${chartId}-mask)`
+ const defs = (
+
+ {areaGradient}
+
+
+ )
+
+ if (isLoop) {
+ return (
+ <>
+ {defs}
+ {silhouette}
+ >
+ )
+ }
+
+ // Transition: fade the swept silhouette out (exit) or in (enter), then hand
+ // off to the chart so it can reveal the real series.
+ return (
+ <>
+ {defs}
+
+ {silhouette}
+
+ >
+ )
+}
+
+LineLoadingSweep.displayName = "LineLoadingSweep"
+
+// ─── Bar loading skeleton (seeded bars under the sweep, inner coords) ─────────
+
+function SkeletonBars({
+ heights,
+ signs,
+ innerWidth,
+ innerHeight,
+ baseline,
+ barFraction,
+ fill,
+ fillOpacity
+}: {
+ heights: number[]
+ signs: number[]
+ innerWidth: number
+ innerHeight: number
+ baseline: "bottom" | "center"
+ barFraction: number
+ fill: string
+ fillOpacity: number
+}) {
+ const bandWidth = innerWidth / heights.length
+ const barW = bandWidth * barFraction
+ const xOffset = (bandWidth * (1 - barFraction)) / 2
+ const isCenter = baseline === "center"
+ const baselineY = isCenter ? innerHeight / 2 : innerHeight
+ const halfBarH = isCenter ? innerHeight / 2 : innerHeight
+
+ return (
+ <>
+ {heights.map((value, i) => {
+ const sign = isCenter ? (signs[i] ?? 1) : 1
+ const barH = Math.max(1, halfBarH * (value / 100))
+ const x = i * bandWidth + xOffset
+ const y = sign === 1 ? baselineY - barH : baselineY
+ return (
+
+ )
+ })}
+ >
+ )
+}
+
+export interface BarLoadingSkeletonProps {
+ innerWidth: number
+ innerHeight: number
+ /** Number of skeleton bars. Default: 12 */
+ barCount?: number
+ /** Bar fill color. Default: `var(--foreground)` */
+ fill?: string
+ /** Bar fill opacity. Default: 0.45 */
+ fillOpacity?: number
+ /** Bars rise from the bottom or diverge from the vertical center. Default: `"bottom"` */
+ baseline?: "bottom" | "center"
+ /** Bar width as a fraction of its band (0–1). Default: 0.7 */
+ barFraction?: number
+ /** One shimmer sweep, in seconds. Default: 2 */
+ durationSeconds?: number
+}
+
+/**
+ * Skeleton bars masked by the shimmer sweep, re-randomizing between passes.
+ * Rendered in the chart's inner coordinate space (origin at the inner top-left),
+ * so a `BarChart` drops it inside its margin-translated group.
+ */
+export function BarLoadingSkeleton({
+ innerWidth,
+ innerHeight,
+ barCount = DEFAULT_BAR_COUNT,
+ fill = DEFAULT_FILL,
+ fillOpacity = DEFAULT_BAR_FILL_OPACITY,
+ baseline = "bottom",
+ barFraction = DEFAULT_BAR_FRACTION,
+ durationSeconds = DEFAULT_SWEEP_DURATION_S
+}: BarLoadingSkeletonProps) {
+ const reduceMotion = useReducedMotion()
+ const reactId = useId()
+ const chartId = `bar-sweep-${reactId.replace(/[^a-zA-Z0-9_-]/g, "")}`
+ const [tick, setTick] = useState(0)
+ const onSweepComplete = useCallback(() => setTick(prev => prev + 1), [])
+ const heights = useMemo(
+ () => getSkeletonHeights(barCount, tick),
+ [barCount, tick]
+ )
+ const signs = useMemo(
+ () => getSkeletonSigns(barCount, tick),
+ [barCount, tick]
+ )
+
+ if (innerWidth <= 0 || innerHeight <= 0) {
+ return null
+ }
+
+ const bars = (
+
+ )
+
+ if (reduceMotion) {
+ return bars
+ }
+
+ return (
+ <>
+
+
+
+ {bars}
+ >
+ )
+}
+
+BarLoadingSkeleton.displayName = "BarLoadingSkeleton"
diff --git a/src/components/charts/motion-utils.ts b/src/components/charts/motion-utils.ts
new file mode 100644
index 00000000..560119ad
--- /dev/null
+++ b/src/components/charts/motion-utils.ts
@@ -0,0 +1,59 @@
+import type { Transition } from "motion/react"
+
+import { DEFAULT_CHART_ENTER_TRANSITION } from "./animation"
+
+export function transitionWithDelay(
+ transition: Transition | undefined,
+ delaySeconds: number,
+ fallback: Transition = DEFAULT_CHART_ENTER_TRANSITION
+): Transition {
+ const base = transition ?? fallback
+ return { ...base, delay: delaySeconds }
+}
+
+export interface SpringOptions {
+ stiffness: number
+ damping: number
+ mass?: number
+}
+
+export function springOptionsFromTransition(
+ transition?: Transition,
+ fallback: SpringOptions = { stiffness: 60, damping: 20 }
+): SpringOptions {
+ if (!transition) {
+ return fallback
+ }
+ if (transition.type === "spring") {
+ const bounce =
+ typeof transition.bounce === "number" ? transition.bounce : undefined
+ const baseStiffness =
+ typeof transition.stiffness === "number"
+ ? transition.stiffness
+ : fallback.stiffness
+ const baseDamping =
+ typeof transition.damping === "number"
+ ? transition.damping
+ : fallback.damping
+ return {
+ stiffness:
+ bounce == null
+ ? baseStiffness
+ : Math.min(400, Math.max(80, baseStiffness * (1 + bounce * 0.35))),
+ damping:
+ bounce == null
+ ? baseDamping
+ : Math.max(8, baseDamping * (1 - bounce * 0.25)),
+ mass:
+ typeof transition.mass === "number" ? transition.mass : fallback.mass
+ }
+ }
+ const duration =
+ "duration" in transition && typeof transition.duration === "number"
+ ? transition.duration
+ : 0.8
+ return {
+ stiffness: Math.min(500, Math.max(40, 280 / duration)),
+ damping: Math.min(40, Math.max(12, 18 + duration * 4))
+ }
+}
diff --git a/src/components/charts/pie-chart.tsx b/src/components/charts/pie-chart.tsx
new file mode 100644
index 00000000..5e5ae239
--- /dev/null
+++ b/src/components/charts/pie-chart.tsx
@@ -0,0 +1,493 @@
+"use client"
+
+import {
+ Children,
+ isValidElement,
+ memo,
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type ReactElement,
+ type ReactNode
+} from "react"
+import { Group } from "@visx/group"
+import { ParentSize } from "@visx/responsive"
+import { arc as arcGenerator } from "@visx/shape"
+import { pie as d3Pie } from "d3-shape"
+import type { Transition } from "motion/react"
+
+import { cn } from "@/lib/utils"
+import {
+ defaultPieColors,
+ PieProvider,
+ type PieArcData,
+ type PieContextValue,
+ type PieData
+} from "./pie-context"
+
+export const DEFAULT_HOVER_OFFSET = 10
+
+export interface PieChartProps {
+ data: PieData[]
+ size?: number
+ innerRadius?: number
+ padAngle?: number
+ cornerRadius?: number
+ startAngle?: number
+ endAngle?: number
+ className?: string
+ hoveredIndex?: number | null
+ onHoverChange?: (index: number | null) => void
+ hoverOffset?: number
+ children: ReactNode
+ enterTransition?: Transition
+ enterStaggerScale?: number
+ geometryScrubbing?: boolean
+}
+
+interface PieChartInnerProps {
+ width: number
+ height: number
+ data: PieData[]
+ innerRadius: number
+ padAngle: number
+ cornerRadius: number
+ startAngle: number
+ endAngle: number
+ hoverOffset: number
+ children: ReactNode
+ containerRef: React.RefObject
+ hoveredIndexProp?: number | null
+ onHoverChange?: (index: number | null) => void
+ enterTransition?: Transition
+ enterStaggerScale: number
+ geometryScrubbing: boolean
+}
+
+function generatePieArcPath(
+ innerRadius: number,
+ outerRadius: number,
+ startAngle: number,
+ endAngle: number,
+ cornerRadius: number,
+ padAngle: number
+): string {
+ const generator = arcGenerator({
+ innerRadius,
+ outerRadius,
+ cornerRadius,
+ padAngle
+ })
+
+ return generator({ startAngle, endAngle } as unknown as null) || ""
+}
+
+function isPieCenter(child: ReactNode): boolean {
+ return (
+ isValidElement(child) &&
+ typeof child.type === "function" &&
+ ((child.type as { displayName?: string }).displayName === "PieCenter" ||
+ (child.type as { name?: string }).name === "PieCenter")
+ )
+}
+
+function isPieSlice(child: ReactNode): boolean {
+ return (
+ isValidElement(child) &&
+ typeof child.type === "function" &&
+ ((child.type as { displayName?: string }).displayName === "PieSlice" ||
+ (child.type as { name?: string }).name === "PieSlice")
+ )
+}
+
+function isDefsComponent(child: ReactElement): boolean {
+ const displayName =
+ (child.type as { displayName?: string })?.displayName ||
+ (child.type as { name?: string })?.name ||
+ ""
+
+ return (
+ displayName.includes("Gradient") ||
+ displayName.includes("Pattern") ||
+ displayName === "LinearGradient" ||
+ displayName === "RadialGradient"
+ )
+}
+
+function PieChartInner(props: PieChartInnerProps) {
+ const size = Math.min(props.width, props.height)
+
+ if (size < 10) {
+ return null
+ }
+
+ return
+}
+
+const PieChartCore = memo(function PieChartCore({
+ width,
+ height,
+ data,
+ innerRadius: innerRadiusProp,
+ padAngle,
+ cornerRadius,
+ startAngle,
+ endAngle,
+ hoverOffset,
+ children,
+ containerRef,
+ hoveredIndexProp,
+ onHoverChange,
+ enterTransition,
+ enterStaggerScale,
+ geometryScrubbing
+}: PieChartInnerProps) {
+ const [internalHoveredIndex, setInternalHoveredIndex] = useState<
+ number | null
+ >(null)
+ const [animationKey] = useState(0)
+ const [isLoaded, setIsLoaded] = useState(false)
+
+ const isControlled = hoveredIndexProp !== undefined
+ const hoveredIndex = isControlled ? hoveredIndexProp : internalHoveredIndex
+
+ const setHoveredIndex = useCallback(
+ (index: number | null) => {
+ if (isControlled) {
+ onHoverChange?.(index)
+ } else {
+ setInternalHoveredIndex(index)
+ }
+ },
+ [isControlled, onHoverChange]
+ )
+
+ const size = Math.min(width, height)
+ const center = size / 2
+ const padding = hoverOffset
+ const outerRadius = center - padding
+ const innerRadius = innerRadiusProp
+
+ const totalValue = useMemo(
+ () => data.reduce((sum, item) => sum + item.value, 0),
+ [data]
+ )
+
+ const getColor = useCallback(
+ (index: number) => {
+ const item = data[index]
+ if (item?.color) {
+ return item.color
+ }
+
+ return defaultPieColors[index % defaultPieColors.length] as string
+ },
+ [data]
+ )
+
+ const getFill = useCallback(
+ (index: number) => {
+ const item = data[index]
+ if (item?.fill) {
+ return item.fill
+ }
+
+ return getColor(index)
+ },
+ [data, getColor]
+ )
+
+ const arcs = useMemo(() => {
+ const pieGenerator = d3Pie()
+ .value(item => item.value)
+ .startAngle(startAngle)
+ .endAngle(endAngle)
+ .padAngle(padAngle)
+ .sort(null)
+
+ return pieGenerator(data).map((arc, index) => ({
+ data: arc.data,
+ index,
+ startAngle: arc.startAngle,
+ endAngle: arc.endAngle,
+ padAngle: arc.padAngle,
+ value: arc.value
+ })) as PieArcData[]
+ }, [data, startAngle, endAngle, padAngle])
+
+ const scrubSlicePaths = useMemo((): readonly string[] | null => {
+ if (!geometryScrubbing) {
+ return null
+ }
+
+ return arcs.map(arc =>
+ generatePieArcPath(
+ innerRadius,
+ outerRadius,
+ arc.startAngle,
+ arc.endAngle,
+ cornerRadius,
+ arc.padAngle
+ )
+ )
+ }, [geometryScrubbing, arcs, innerRadius, outerRadius, cornerRadius])
+
+ const effectiveIsLoaded = geometryScrubbing || isLoaded
+
+ useEffect(() => {
+ if (geometryScrubbing) {
+ return
+ }
+
+ setIsLoaded(false)
+ const timer = setTimeout(() => {
+ setIsLoaded(true)
+ }, 100)
+
+ return () => clearTimeout(timer)
+ }, [enterTransition, enterStaggerScale, geometryScrubbing])
+
+ const { svgChildren, centerChildren, defsChildren } = useMemo(() => {
+ const svgNodes: ReactNode[] = []
+ const centerNodes: ReactNode[] = []
+ const defsNodes: ReactElement[] = []
+
+ Children.forEach(children, child => {
+ if (!isValidElement(child)) {
+ svgNodes.push(child)
+ return
+ }
+
+ if (isPieCenter(child)) {
+ centerNodes.push(child)
+ } else if (isDefsComponent(child)) {
+ defsNodes.push(child)
+ } else if (geometryScrubbing && isPieSlice(child)) {
+ return
+ } else {
+ svgNodes.push(child)
+ }
+ })
+
+ return {
+ svgChildren: svgNodes,
+ centerChildren: centerNodes,
+ defsChildren: defsNodes
+ }
+ }, [children, geometryScrubbing])
+
+ const scrubSliceFills = useMemo(() => {
+ if (!(geometryScrubbing && scrubSlicePaths)) {
+ return null
+ }
+
+ return scrubSlicePaths.map((_, index) => getFill(index))
+ }, [geometryScrubbing, scrubSlicePaths, getFill])
+
+ const contextValue: PieContextValue = useMemo(
+ () => ({
+ data,
+ arcs,
+ size,
+ center,
+ outerRadius,
+ innerRadius,
+ padAngle,
+ cornerRadius,
+ hoverOffset,
+ hoveredIndex,
+ setHoveredIndex,
+ animationKey,
+ isLoaded: effectiveIsLoaded,
+ enterTransition,
+ enterStaggerScale,
+ containerRef,
+ totalValue,
+ getColor,
+ getFill,
+ geometryScrubbing,
+ scrubSlicePaths
+ }),
+ [
+ data,
+ arcs,
+ size,
+ center,
+ outerRadius,
+ innerRadius,
+ padAngle,
+ cornerRadius,
+ hoverOffset,
+ hoveredIndex,
+ setHoveredIndex,
+ animationKey,
+ effectiveIsLoaded,
+ enterTransition,
+ enterStaggerScale,
+ containerRef,
+ totalValue,
+ getColor,
+ getFill,
+ geometryScrubbing,
+ scrubSlicePaths
+ ]
+ )
+
+ return (
+
+
+
+ {defsChildren.length > 0 && {defsChildren} }
+
+
+ {scrubSlicePaths && scrubSliceFills
+ ? scrubSlicePaths.map((path, index) =>
+ path ? (
+
+ ) : null
+ )
+ : null}
+ {svgChildren}
+
+
+
+ {centerChildren.length > 0 && (
+
+ {centerChildren}
+
+ )}
+
+
+ )
+}, pieChartCorePropsEqual)
+
+function pieChartCorePropsEqual(
+ previous: PieChartInnerProps,
+ next: PieChartInnerProps
+): boolean {
+ return (
+ previous.width === next.width &&
+ previous.height === next.height &&
+ previous.data === next.data &&
+ previous.innerRadius === next.innerRadius &&
+ previous.padAngle === next.padAngle &&
+ previous.cornerRadius === next.cornerRadius &&
+ previous.startAngle === next.startAngle &&
+ previous.endAngle === next.endAngle &&
+ previous.hoverOffset === next.hoverOffset &&
+ previous.hoveredIndexProp === next.hoveredIndexProp &&
+ previous.onHoverChange === next.onHoverChange &&
+ previous.enterTransition === next.enterTransition &&
+ previous.enterStaggerScale === next.enterStaggerScale &&
+ previous.geometryScrubbing === next.geometryScrubbing &&
+ previous.children === next.children
+ )
+}
+
+export function PieChart({
+ data,
+ size: fixedSize,
+ innerRadius = 0,
+ padAngle = 0,
+ cornerRadius = 0,
+ startAngle = -Math.PI / 2,
+ endAngle = (3 * Math.PI) / 2,
+ className = "",
+ hoveredIndex,
+ onHoverChange,
+ hoverOffset = DEFAULT_HOVER_OFFSET,
+ enterTransition,
+ enterStaggerScale = 1,
+ geometryScrubbing = false,
+ children
+}: PieChartProps) {
+ const containerRef = useRef(null)
+
+ if (fixedSize) {
+ return (
+
+ )
+ }
+
+ return (
+
+
+ {({ width, height }) => (
+
+ {children}
+
+ )}
+
+
+ )
+}
+
+PieChart.displayName = "PieChart"
+
+export default PieChart
diff --git a/src/components/charts/pie-context.tsx b/src/components/charts/pie-context.tsx
new file mode 100644
index 00000000..1c4716e1
--- /dev/null
+++ b/src/components/charts/pie-context.tsx
@@ -0,0 +1,177 @@
+"use client"
+
+import {
+ createContext,
+ useContext,
+ useMemo,
+ type ReactNode,
+ type RefObject
+} from "react"
+import type { Transition } from "motion/react"
+
+export const pieCssVars = {
+ background: "var(--chart-background)",
+ foreground: "var(--chart-foreground)",
+ foregroundMuted: "var(--chart-foreground-muted)",
+ label: "var(--chart-label)",
+ slice1: "var(--chart-1)",
+ slice2: "var(--chart-2)",
+ slice3: "var(--chart-3)",
+ slice4: "var(--chart-4)",
+ slice5: "var(--chart-5)"
+}
+
+export const defaultPieColors = [
+ pieCssVars.slice1,
+ pieCssVars.slice2,
+ pieCssVars.slice3,
+ pieCssVars.slice4,
+ pieCssVars.slice5
+]
+
+export interface PieData {
+ label: string
+ value: number
+ color?: string
+ fill?: string
+}
+
+export interface PieArcData {
+ data: PieData
+ index: number
+ startAngle: number
+ endAngle: number
+ padAngle: number
+ value: number
+}
+
+export interface PieHoverContextValue {
+ hoveredIndex: number | null
+ setHoveredIndex: (index: number | null) => void
+}
+
+export interface PieStableContextValue {
+ data: PieData[]
+ arcs: PieArcData[]
+ size: number
+ center: number
+ outerRadius: number
+ innerRadius: number
+ padAngle: number
+ cornerRadius: number
+ hoverOffset: number
+ animationKey: number
+ isLoaded: boolean
+ enterTransition?: Transition
+ enterStaggerScale: number
+ containerRef: RefObject
+ totalValue: number
+ getColor: (index: number) => string
+ getFill: (index: number) => string
+ geometryScrubbing: boolean
+ scrubSlicePaths: readonly string[] | null
+}
+
+export type PieContextValue = PieStableContextValue & PieHoverContextValue
+
+const PieStableContext = createContext(null)
+const PieHoverContext = createContext(null)
+
+export function PieProvider({
+ children,
+ value
+}: {
+ children: ReactNode
+ value: PieContextValue
+}) {
+ const stable = useMemo(
+ () => ({
+ data: value.data,
+ arcs: value.arcs,
+ size: value.size,
+ center: value.center,
+ outerRadius: value.outerRadius,
+ innerRadius: value.innerRadius,
+ padAngle: value.padAngle,
+ cornerRadius: value.cornerRadius,
+ hoverOffset: value.hoverOffset,
+ animationKey: value.animationKey,
+ isLoaded: value.isLoaded,
+ enterTransition: value.enterTransition,
+ enterStaggerScale: value.enterStaggerScale,
+ containerRef: value.containerRef,
+ totalValue: value.totalValue,
+ getColor: value.getColor,
+ getFill: value.getFill,
+ geometryScrubbing: value.geometryScrubbing,
+ scrubSlicePaths: value.scrubSlicePaths
+ }),
+ [
+ value.data,
+ value.arcs,
+ value.size,
+ value.center,
+ value.outerRadius,
+ value.innerRadius,
+ value.padAngle,
+ value.cornerRadius,
+ value.hoverOffset,
+ value.animationKey,
+ value.isLoaded,
+ value.enterTransition,
+ value.enterStaggerScale,
+ value.containerRef,
+ value.totalValue,
+ value.getColor,
+ value.getFill,
+ value.geometryScrubbing,
+ value.scrubSlicePaths
+ ]
+ )
+
+ const hover = useMemo(
+ () => ({
+ hoveredIndex: value.hoveredIndex,
+ setHoveredIndex: value.setHoveredIndex
+ }),
+ [value.hoveredIndex, value.setHoveredIndex]
+ )
+
+ return (
+
+
+ {children}
+
+
+ )
+}
+
+export function usePieStable(): PieStableContextValue {
+ const context = useContext(PieStableContext)
+
+ if (!context) {
+ throw new Error(
+ "usePieStable must be used within a PieProvider. Make sure your component is wrapped in ."
+ )
+ }
+
+ return context
+}
+
+export function usePieHover(): PieHoverContextValue {
+ const context = useContext(PieHoverContext)
+
+ if (!context) {
+ throw new Error(
+ "usePieHover must be used within a PieProvider. Make sure your component is wrapped in ."
+ )
+ }
+
+ return context
+}
+
+export function usePie(): PieContextValue {
+ return { ...usePieStable(), ...usePieHover() }
+}
+
+export default PieStableContext
diff --git a/src/components/charts/pie-slice.tsx b/src/components/charts/pie-slice.tsx
new file mode 100644
index 00000000..628796b7
--- /dev/null
+++ b/src/components/charts/pie-slice.tsx
@@ -0,0 +1,490 @@
+"use client"
+
+import { memo, useEffect } from "react"
+import { arc as arcGenerator } from "@visx/shape"
+import { motion, useSpring, useTransform } from "motion/react"
+
+import { usePieHover, usePieStable } from "./pie-context"
+import { useEnterComplete } from "./use-enter-complete"
+import { useMountProgress } from "./use-mount-progress"
+
+function generateArcPath(
+ innerRadius: number,
+ outerRadius: number,
+ startAngle: number,
+ endAngle: number,
+ cornerRadius: number,
+ padAngle: number
+): string {
+ const generator = arcGenerator({
+ innerRadius,
+ outerRadius,
+ cornerRadius,
+ padAngle
+ })
+
+ return generator({ startAngle, endAngle } as unknown as null) || ""
+}
+
+function getSliceOffset(
+ startAngle: number,
+ endAngle: number,
+ distance: number
+): { x: number; y: number } {
+ const midAngle = (startAngle + endAngle) / 2
+
+ return {
+ x: Math.sin(midAngle) * distance,
+ y: -Math.cos(midAngle) * distance
+ }
+}
+
+export type PieSliceHoverEffect = "translate" | "grow" | "none"
+
+export interface PieSliceProps {
+ index: number
+ color?: string
+ fill?: string
+ animate?: boolean
+ showGlow?: boolean
+ hoverEffect?: PieSliceHoverEffect
+ hoverOffset?: number
+ className?: string
+}
+
+interface AnimatedSliceTranslateProps {
+ index: number
+ innerRadius: number
+ outerRadius: number
+ startAngle: number
+ endAngle: number
+ cornerRadius: number
+ padAngle: number
+ fill: string
+ color: string
+ isHovered: boolean
+ isFaded: boolean
+ animationKey: number
+ showGlow: boolean
+ hoverOffset: number
+}
+
+function AnimatedSliceTranslate({
+ index,
+ innerRadius,
+ outerRadius,
+ startAngle,
+ endAngle,
+ cornerRadius,
+ padAngle,
+ fill,
+ color,
+ isHovered,
+ isFaded,
+ animationKey,
+ showGlow,
+ hoverOffset
+}: AnimatedSliceTranslateProps) {
+ const {
+ enterTransition,
+ enterStaggerScale,
+ animationKey: pieAnimationKey
+ } = usePieStable()
+ const animationDelay = (0.1 + index * 0.08) * enterStaggerScale
+ const mountProgress = useMountProgress(
+ enterTransition,
+ animationDelay,
+ pieAnimationKey
+ )
+ const enterComplete = useEnterComplete(mountProgress)
+
+ const animatedPath = useTransform(mountProgress, mount => {
+ const currentEndAngle = startAngle + (endAngle - startAngle) * mount
+
+ if (currentEndAngle <= startAngle + 0.01) {
+ return ""
+ }
+
+ return generateArcPath(
+ innerRadius,
+ outerRadius,
+ startAngle,
+ currentEndAngle,
+ cornerRadius,
+ padAngle
+ )
+ })
+
+ const offset = getSliceOffset(startAngle, endAngle, hoverOffset)
+ const glowColor = color
+ const hitboxPath = generateArcPath(
+ innerRadius,
+ outerRadius,
+ startAngle,
+ endAngle,
+ cornerRadius,
+ padAngle
+ )
+
+ if (enterComplete) {
+ const shouldTranslate = isHovered
+
+ return (
+
+ )
+ }
+
+ return (
+
+ )
+}
+
+interface AnimatedSliceGrowProps {
+ index: number
+ innerRadius: number
+ outerRadius: number
+ startAngle: number
+ endAngle: number
+ cornerRadius: number
+ padAngle: number
+ fill: string
+ color: string
+ isHovered: boolean
+ isFaded: boolean
+ animationKey: number
+ showGlow: boolean
+ hoverOffset: number
+}
+
+function AnimatedSliceGrow({
+ index,
+ innerRadius,
+ outerRadius,
+ startAngle,
+ endAngle,
+ cornerRadius,
+ padAngle,
+ fill,
+ color,
+ isHovered,
+ isFaded,
+ animationKey,
+ showGlow,
+ hoverOffset
+}: AnimatedSliceGrowProps) {
+ const {
+ enterTransition,
+ enterStaggerScale,
+ animationKey: pieAnimationKey
+ } = usePieStable()
+ const animationDelay = (0.1 + index * 0.08) * enterStaggerScale
+ const mountProgress = useMountProgress(
+ enterTransition,
+ animationDelay,
+ pieAnimationKey
+ )
+ const enterComplete = useEnterComplete(mountProgress)
+
+ const growSpring = useSpring(outerRadius, {
+ stiffness: 400,
+ damping: 25
+ })
+
+ useEffect(() => {
+ growSpring.set(isHovered ? outerRadius + hoverOffset : outerRadius)
+ }, [isHovered, hoverOffset, outerRadius, growSpring])
+
+ const animatedPath = useTransform(
+ [mountProgress, growSpring],
+ ([mount, currentOuterRadius]) => {
+ const currentEndAngle =
+ startAngle + (endAngle - startAngle) * (mount as number)
+
+ if (currentEndAngle <= startAngle + 0.01) {
+ return ""
+ }
+
+ return generateArcPath(
+ innerRadius,
+ currentOuterRadius as number,
+ startAngle,
+ currentEndAngle,
+ cornerRadius,
+ padAngle
+ )
+ }
+ )
+
+ const glowColor = color
+ const grownOuterRadius = isHovered ? outerRadius + hoverOffset : outerRadius
+ const grownPath = generateArcPath(
+ innerRadius,
+ grownOuterRadius,
+ startAngle,
+ endAngle,
+ cornerRadius,
+ padAngle
+ )
+
+ if (enterComplete) {
+ return (
+
+ )
+ }
+
+ return (
+
+ )
+}
+
+export const PieSlice = memo(function PieSlice({
+ index,
+ color: colorProp,
+ fill: fillProp,
+ animate = true,
+ showGlow = true,
+ hoverEffect = "translate",
+ hoverOffset: hoverOffsetProp
+}: PieSliceProps) {
+ const {
+ arcs,
+ innerRadius,
+ outerRadius,
+ cornerRadius,
+ hoverOffset: contextHoverOffset,
+ animationKey,
+ geometryScrubbing,
+ scrubSlicePaths,
+ getColor,
+ getFill
+ } = usePieStable()
+ const { hoveredIndex, setHoveredIndex } = usePieHover()
+
+ const hoverOffset = hoverOffsetProp ?? contextHoverOffset
+ const arcData = arcs[index]
+
+ if (!arcData) {
+ return null
+ }
+
+ const color = colorProp || getColor(index)
+ const fill = fillProp || getFill(index)
+
+ if (geometryScrubbing) {
+ const scrubPath = scrubSlicePaths?.[index]
+
+ if (!scrubPath) {
+ return null
+ }
+
+ return
+ }
+
+ const isHovered = hoveredIndex === index
+ const isFaded = hoveredIndex !== null && hoveredIndex !== index
+
+ const offset = getSliceOffset(
+ arcData.startAngle,
+ arcData.endAngle,
+ hoverOffset
+ )
+
+ const hitboxPath = generateArcPath(
+ innerRadius,
+ outerRadius,
+ arcData.startAngle,
+ arcData.endAngle,
+ cornerRadius,
+ arcData.padAngle
+ )
+
+ const grownOuterRadius = isHovered ? outerRadius + hoverOffset : outerRadius
+ const grownPath = generateArcPath(
+ innerRadius,
+ grownOuterRadius,
+ arcData.startAngle,
+ arcData.endAngle,
+ cornerRadius,
+ arcData.padAngle
+ )
+
+ const renderAnimatedSlice = () => {
+ if (hoverEffect === "grow") {
+ return (
+
+ )
+ }
+
+ return (
+
+ )
+ }
+
+ const renderStaticSlice = () => {
+ if (hoverEffect === "grow") {
+ return (
+
+ )
+ }
+
+ const shouldTranslate = hoverEffect !== "none" && isHovered
+ const translateX = shouldTranslate ? offset.x : 0
+ const translateY = shouldTranslate ? offset.y : 0
+
+ return (
+
+ )
+ }
+
+ return (
+
+ setHoveredIndex(index)}
+ onMouseLeave={() => setHoveredIndex(null)}
+ />
+ {animate ? renderAnimatedSlice() : renderStaticSlice()}
+
+ )
+})
+
+PieSlice.displayName = "PieSlice"
+
+export default PieSlice
diff --git a/src/components/charts/reference-area-config.ts b/src/components/charts/reference-area-config.ts
new file mode 100644
index 00000000..4dccd028
--- /dev/null
+++ b/src/components/charts/reference-area-config.ts
@@ -0,0 +1,69 @@
+import {
+ Children,
+ isValidElement,
+ type ReactElement,
+ type ReactNode
+} from "react"
+
+import { normalizeYAxisId } from "./y-axis-scales"
+
+export interface ReferenceAreaConfig {
+ yAxisId: string
+ y1?: number
+ y2?: number
+ axisLabelColor?: string
+}
+
+interface ReferenceAreaConfigProps {
+ yAxisId?: string | number
+ y1?: number
+ y2?: number
+ axisLabelColor?: string
+}
+
+function getChildComponentName(child: ReactElement) {
+ const childType = child.type as { displayName?: string; name?: string }
+ return typeof child.type === "function"
+ ? childType.displayName || childType.name || ""
+ : ""
+}
+
+function isReferenceAreaElement(child: ReactElement): boolean {
+ return getChildComponentName(child) === "ReferenceArea"
+}
+
+/** Collect {@link ReferenceArea} props from chart children for axis label styling. */
+export function extractReferenceAreaConfigs(
+ children: ReactNode
+): ReferenceAreaConfig[] {
+ const configs: ReferenceAreaConfig[] = []
+
+ const visit = (node: ReactNode) => {
+ Children.forEach(node, child => {
+ if (!isValidElement(child)) {
+ return
+ }
+
+ if (isReferenceAreaElement(child)) {
+ const props = child.props as ReferenceAreaConfigProps | undefined
+ if (props) {
+ configs.push({
+ yAxisId: normalizeYAxisId(props.yAxisId),
+ y1: props.y1,
+ y2: props.y2,
+ axisLabelColor: props.axisLabelColor
+ })
+ }
+ return
+ }
+
+ const childProps = child.props as { children?: ReactNode } | undefined
+ if (childProps?.children) {
+ visit(childProps.children)
+ }
+ })
+ }
+
+ visit(children)
+ return configs
+}
diff --git a/src/components/charts/static-chart-preview-context.tsx b/src/components/charts/static-chart-preview-context.tsx
new file mode 100644
index 00000000..59eec596
--- /dev/null
+++ b/src/components/charts/static-chart-preview-context.tsx
@@ -0,0 +1,22 @@
+"use client"
+
+import { createContext, useContext, type ReactNode } from "react"
+
+const StaticChartPreviewContext = createContext(false)
+
+/** Disables cartesian reveal clip-path for static docs previews. */
+export function StaticChartPreviewProvider({
+ children
+}: {
+ children: ReactNode
+}) {
+ return (
+
+ {children}
+
+ )
+}
+
+export function useStaticChartPreview() {
+ return useContext(StaticChartPreviewContext)
+}
diff --git a/src/components/charts/tooltip/chart-tooltip.tsx b/src/components/charts/tooltip/chart-tooltip.tsx
new file mode 100644
index 00000000..84f95895
--- /dev/null
+++ b/src/components/charts/tooltip/chart-tooltip.tsx
@@ -0,0 +1,398 @@
+"use client"
+
+import { memo, useEffect, useMemo, useState } from "react"
+import { createPortal } from "react-dom"
+import { motion, useSpring } from "motion/react"
+
+import {
+ resolveTooltipBoxMotion,
+ useChartConfig,
+ type SpringConfig
+} from "../chart-config-context"
+import {
+ chartCssVars,
+ useChart,
+ useChartStable,
+ type LineConfig
+} from "../chart-context"
+import { weekdayDateFmt } from "../chart-formatters"
+import type { IndicatorFadeEdges } from "../indicator-fade"
+import { DateTicker } from "./date-ticker"
+import { TooltipBox } from "./tooltip-box"
+import { TooltipContent, type TooltipRow } from "./tooltip-content"
+import { TooltipDot } from "./tooltip-dot"
+import { TooltipIndicator } from "./tooltip-indicator"
+
+export interface ChartTooltipProps {
+ /** Whether to show the date pill at bottom. Default: true */
+ showDatePill?: boolean
+ /** Whether to show the vertical crosshair line. Default: true */
+ showCrosshair?: boolean
+ /** Whether to show dots on the lines. Default: true */
+ showDots?: boolean
+ /**
+ * Color for the crosshair/indicator line. When a function, receives the hovered point
+ * (e.g. for candlestick: match candle color from close vs open). Default: --chart-crosshair.
+ */
+ indicatorColor?: string | ((point: Record) => string)
+ /** Custom content renderer for the tooltip box */
+ content?: (props: {
+ point: Record
+ index: number
+ }) => React.ReactNode
+ /** Custom row renderer - return array of TooltipRow */
+ rows?: (point: Record) => TooltipRow[]
+ /**
+ * Override tooltip dot fill. When omitted and `rows` is set, dot colors match row colors.
+ * When a function, receives the hovered point and line config.
+ */
+ dotColor?:
+ | string
+ | ((point: Record, line: LineConfig) => string)
+ /** Additional content to show below rows (e.g., markers) */
+ children?: React.ReactNode
+ /** Custom class name */
+ className?: string
+ /** Per-chart override for the crosshair / dot / date-pill spring. */
+ springConfig?: SpringConfig
+ /**
+ * When `true`, the floating panel uses the crosshair spring and stays in sync.
+ * Default `false` — panel follow uses `damping` (`20`).
+ */
+ matchCrosshair?: boolean
+ /**
+ * Spring damping for the floating tooltip panel when `matchCrosshair` is `false`.
+ * `0` disables spring motion (instant). Default: `20`.
+ */
+ damping?: number
+ /** SVG stroke dash pattern for the crosshair. Omit for solid. */
+ indicatorDasharray?: string
+ /** Vertical crosshair fade: `both`, `top`, `bottom`, or `none` (solid). Default: `both`. */
+ indicatorFadeEdges?: IndicatorFadeEdges
+ /** Crosshair fade zone size (% of height). Default: `10`. */
+ indicatorFadeLength?: number
+ /** Per-chart override for the floating-panel spring. */
+ boxSpringConfig?: SpringConfig
+ /** Inline styles for the tooltip panel (background, blur, etc.). */
+ panelStyle?: React.CSSProperties
+ /**
+ * Tooltip panel background color (CSS variable or color value).
+ * Default: `var(--chart-tooltip-background)`.
+ */
+ backgroundColor?: string
+}
+
+interface ChartTooltipInnerProps extends ChartTooltipProps {
+ container: HTMLElement
+}
+
+const ChartTooltipInner = memo(function ChartTooltipInner({
+ showDatePill = true,
+ showCrosshair = true,
+ showDots = true,
+ indicatorColor: indicatorColorProp,
+ content,
+ rows: rowsRenderer,
+ dotColor: dotColorProp,
+ children,
+ className = "",
+ container,
+ springConfig,
+ matchCrosshair = false,
+ damping,
+ indicatorDasharray,
+ indicatorFadeEdges,
+ indicatorFadeLength,
+ boxSpringConfig,
+ panelStyle,
+ backgroundColor
+}: ChartTooltipInnerProps) {
+ const {
+ tooltipData,
+ width,
+ height,
+ innerHeight,
+ margin,
+ columnWidth,
+ lines,
+ xAccessor,
+ dateLabels,
+ containerRef,
+ orientation,
+ barXAccessor
+ } = useChart()
+ const { tooltipSpring } = useChartConfig()
+
+ const isHorizontal = orientation === "horizontal"
+ const discreteInteraction = dateLabels.length > 60
+ const boxMotion = useMemo(() => {
+ if (boxSpringConfig) {
+ return {
+ animate: !discreteInteraction,
+ springConfig: boxSpringConfig
+ }
+ }
+ if (matchCrosshair) {
+ return {
+ animate: !discreteInteraction,
+ springConfig: springConfig ?? tooltipSpring
+ }
+ }
+ return resolveTooltipBoxMotion(damping)
+ }, [
+ boxSpringConfig,
+ damping,
+ discreteInteraction,
+ matchCrosshair,
+ springConfig,
+ tooltipSpring
+ ])
+
+ const visible = tooltipData !== null
+ const x = tooltipData?.x ?? 0
+ const xWithMargin = x + margin.left
+
+ // For horizontal charts, get the y position from the first line's yPosition (center of bar)
+ const firstLineDataKey = lines[0]?.dataKey
+ const firstLineY = firstLineDataKey
+ ? (tooltipData?.yPositions[firstLineDataKey] ?? 0)
+ : 0
+ const yWithMargin = firstLineY + margin.top
+
+ const tooltipRows = useMemo(() => {
+ if (!tooltipData) {
+ return []
+ }
+
+ if (rowsRenderer) {
+ return rowsRenderer(tooltipData.point)
+ }
+
+ // Default: generate rows from registered lines
+ return lines.map(line => ({
+ color: line.stroke,
+ label: line.dataKey,
+ value: (tooltipData.point[line.dataKey] as number) ?? 0
+ }))
+ }, [tooltipData, lines, rowsRenderer])
+
+ const resolveDotColor = useMemo(() => {
+ return (line: LineConfig, index: number): string => {
+ if (rowsRenderer && tooltipRows[index]?.color) {
+ return tooltipRows[index].color
+ }
+ if (dotColorProp != null) {
+ if (typeof dotColorProp === "function" && tooltipData) {
+ return dotColorProp(tooltipData.point, line)
+ }
+ if (typeof dotColorProp === "string") {
+ return dotColorProp
+ }
+ }
+ return line.stroke
+ }
+ }, [dotColorProp, rowsRenderer, tooltipData, tooltipRows])
+
+ // Resolve indicator color (static or from hovered point)
+ const indicatorColor = useMemo(() => {
+ if (indicatorColorProp == null) {
+ return chartCssVars.crosshair
+ }
+ if (typeof indicatorColorProp === "function") {
+ return tooltipData
+ ? indicatorColorProp(tooltipData.point)
+ : chartCssVars.crosshair
+ }
+ return indicatorColorProp
+ }, [indicatorColorProp, tooltipData])
+
+ // Title from date or category
+ const title = useMemo(() => {
+ if (!tooltipData) {
+ return undefined
+ }
+ // For bar charts (horizontal or vertical), use the category name
+ if (barXAccessor) {
+ return barXAccessor(tooltipData.point)
+ }
+ // For line/area charts, use the date
+ return weekdayDateFmt.format(xAccessor(tooltipData.point))
+ }, [tooltipData, barXAccessor, xAccessor])
+
+ const tooltipContent = (
+ <>
+ {/* Crosshair indicator - rendered as SVG overlay */}
+ {showCrosshair && (
+
+
+
+
+
+ )}
+
+ {/* Dots on bars/lines - show for vertical charts only */}
+ {showDots && visible && !isHorizontal && (
+
+
+ {lines.map((line, index) => (
+
+ ))}
+
+
+ )}
+
+ {/* Tooltip Box */}
+
+ {content && tooltipData
+ ? content({
+ point: tooltipData.point,
+ index: tooltipData.index
+ })
+ : !content && (
+
+ {children}
+
+ )}
+
+
+ {/* Date/Category Ticker - only show for vertical charts */}
+
+ >
+ )
+
+ return createPortal(tooltipContent, container)
+})
+
+export function ChartTooltip(props: ChartTooltipProps) {
+ const { containerRef } = useChartStable()
+ const [container, setContainer] = useState(null)
+ const [mounted, setMounted] = useState(false)
+
+ // Only render portals on client side after mount
+ useEffect(() => {
+ setMounted(true)
+ setContainer(containerRef.current)
+ }, [containerRef])
+
+ if (!(mounted && container)) {
+ return null
+ }
+
+ return
+}
+
+ChartTooltip.displayName = "ChartTooltip"
+
+interface DatePillTrackerProps {
+ enabled: boolean
+ visible: boolean
+ labels: string[]
+ currentIndex: number
+ xWithMargin: number
+ discreteInteraction: boolean
+ springConfig?: SpringConfig
+}
+
+// Inner-only-on-visible so `useSpring` initializes at the real cursor x
+// instead of `margin.left` on first hover.
+function DatePillTracker(props: DatePillTrackerProps) {
+ if (!(props.enabled && props.visible && props.labels.length > 0)) {
+ return null
+ }
+ return
+}
+
+function DatePillTrackerInner({
+ labels,
+ currentIndex,
+ xWithMargin,
+ discreteInteraction,
+ springConfig,
+ visible
+}: DatePillTrackerProps) {
+ const { tooltipSpring } = useChartConfig()
+ const effectiveSpring = springConfig ?? tooltipSpring
+ const animatedX = useSpring(xWithMargin, effectiveSpring)
+
+ if (!discreteInteraction) {
+ animatedX.set(xWithMargin)
+ }
+
+ // biome-ignore lint/correctness/useExhaustiveDependencies: we need to jump the animatedX when the visible prop changes
+ useEffect(() => {
+ animatedX.set(xWithMargin)
+ }, [animatedX, visible, xWithMargin])
+
+ return (
+
+
+
+ )
+}
+
+export default ChartTooltip
diff --git a/src/components/charts/tooltip/date-ticker.tsx b/src/components/charts/tooltip/date-ticker.tsx
new file mode 100644
index 00000000..98c9a476
--- /dev/null
+++ b/src/components/charts/tooltip/date-ticker.tsx
@@ -0,0 +1,161 @@
+"use client"
+
+import { memo, useEffect, useMemo, useRef } from "react"
+import { motion, useSpring } from "motion/react"
+
+const TICKER_ITEM_HEIGHT = 24
+/** Full scroll stacks are skipped above this count — single label + instant updates. */
+const COMPACT_TICKER_THRESHOLD = 60
+
+export interface DateTickerProps {
+ currentIndex: number
+ labels: string[]
+ visible: boolean
+}
+
+const DateTickerCompact = memo(function DateTickerCompact({
+ currentIndex,
+ labels
+}: Omit) {
+ const label = labels[currentIndex] ?? labels[0] ?? ""
+
+ return (
+
+ )
+})
+
+const DateTickerInner = memo(function DateTickerInner({
+ currentIndex,
+ labels
+}: Omit) {
+ // Parse labels into month and day parts
+ const parsedLabels = useMemo(() => {
+ return labels.map((label, index) => {
+ const parts = label.split(" ")
+ const month = parts[0] || ""
+ const day = parts[1] || ""
+ return { month, day, full: label, key: `${label}::${index}` }
+ })
+ }, [labels])
+
+ // Month segments: one entry per consecutive run (Jan → Feb → …), keyed by start index
+ const monthSegments = useMemo(() => {
+ const segments: { month: string; key: string; startIndex: number }[] = []
+
+ parsedLabels.forEach((label, index) => {
+ const prev = segments.at(-1)
+ if (!prev || prev.month !== label.month) {
+ segments.push({
+ month: label.month,
+ key: `${label.month}-${index}`,
+ startIndex: index
+ })
+ }
+ })
+
+ return segments
+ }, [parsedLabels])
+
+ // Index into monthSegments for the current data point
+ const currentMonthIndex = useMemo(() => {
+ if (currentIndex < 0 || currentIndex >= parsedLabels.length) {
+ return 0
+ }
+ for (let i = monthSegments.length - 1; i >= 0; i--) {
+ const segment = monthSegments[i]
+ if (segment && segment.startIndex <= currentIndex) {
+ return i
+ }
+ }
+ return 0
+ }, [currentIndex, parsedLabels.length, monthSegments])
+
+ // Track previous month index
+ const prevMonthIndexRef = useRef(-1)
+
+ // Animated Y offsets
+ const dayY = useSpring(0, { stiffness: 400, damping: 35 })
+ const monthY = useSpring(0, { stiffness: 400, damping: 35 })
+
+ useEffect(() => {
+ dayY.set(-currentIndex * TICKER_ITEM_HEIGHT)
+
+ if (currentMonthIndex < 0) {
+ return
+ }
+
+ const isFirstRender = prevMonthIndexRef.current === -1
+ const monthChanged = prevMonthIndexRef.current !== currentMonthIndex
+
+ if (isFirstRender || monthChanged) {
+ monthY.set(-currentMonthIndex * TICKER_ITEM_HEIGHT)
+ prevMonthIndexRef.current = currentMonthIndex
+ }
+ }, [currentIndex, currentMonthIndex, dayY, monthY])
+
+ return (
+
+
+
+ {/* Month stack */}
+
+
+ {monthSegments.map(segment => (
+
+
+ {segment.month}
+
+
+ ))}
+
+
+
+ {/* Day stack */}
+
+
+ {parsedLabels.map(label => (
+
+
+ {label.day}
+
+
+ ))}
+
+
+
+
+
+ )
+})
+
+export function DateTicker({ currentIndex, labels, visible }: DateTickerProps) {
+ if (!visible || labels.length === 0) {
+ return null
+ }
+
+ if (labels.length > COMPACT_TICKER_THRESHOLD) {
+ return
+ }
+
+ return
+}
+
+DateTicker.displayName = "DateTicker"
+
+export default DateTicker
diff --git a/src/components/charts/tooltip/index.ts b/src/components/charts/tooltip/index.ts
new file mode 100644
index 00000000..acb80794
--- /dev/null
+++ b/src/components/charts/tooltip/index.ts
@@ -0,0 +1,14 @@
+export { ChartTooltip, type ChartTooltipProps } from "./chart-tooltip"
+export { DateTicker, type DateTickerProps } from "./date-ticker"
+export { TooltipBox, type TooltipBoxProps } from "./tooltip-box"
+export {
+ TooltipContent,
+ type TooltipContentProps,
+ type TooltipRow
+} from "./tooltip-content"
+export { TooltipDot, type TooltipDotProps } from "./tooltip-dot"
+export {
+ type IndicatorWidth,
+ TooltipIndicator,
+ type TooltipIndicatorProps
+} from "./tooltip-indicator"
diff --git a/src/components/charts/tooltip/tooltip-box.tsx b/src/components/charts/tooltip/tooltip-box.tsx
new file mode 100644
index 00000000..f9902d6d
--- /dev/null
+++ b/src/components/charts/tooltip/tooltip-box.tsx
@@ -0,0 +1,221 @@
+"use client"
+
+import type { RefObject } from "react"
+import { useEffect, useLayoutEffect, useRef, useState } from "react"
+import { createPortal } from "react-dom"
+import { motion, useSpring } from "motion/react"
+
+import { cn } from "@/lib/utils"
+import { useChartConfig, type SpringConfig } from "../chart-config-context"
+import { chartCssVars } from "../chart-context"
+
+export interface TooltipBoxProps {
+ /** X position in pixels (relative to container) */
+ x: number
+ /** Y position in pixels (relative to container) */
+ y: number
+ /** Whether the tooltip is visible */
+ visible: boolean
+ /** Container ref for portal rendering */
+ containerRef: RefObject
+ /** Container width for flip detection */
+ containerWidth: number
+ /** Container height for bounds clamping */
+ containerHeight: number
+ /** Offset from the target position */
+ offset?: number
+ /** Custom class name */
+ className?: string
+ /** Tooltip content */
+ children: React.ReactNode
+ /** Override left position (bypasses internal calculation) */
+ left?: number | ReturnType
+ /** Override top position (bypasses internal calculation) */
+ top?: number | ReturnType
+ /** Force flip direction (for custom positioning) */
+ flipped?: boolean
+ /** Per-chart override; falls back to `ChartConfigProvider.tooltipBoxSpring`. */
+ springConfig?: SpringConfig
+ /** Animate panel position with a spring. Default: true */
+ animate?: boolean
+ /** Inline styles for the inner tooltip panel. */
+ panelStyle?: React.CSSProperties
+ /**
+ * Tooltip panel background color (CSS variable or color value).
+ * Default: `var(--chart-tooltip-background)`.
+ */
+ backgroundColor?: string
+}
+
+// Inner-only-on-visible so `useSpring` initializes at the cursor's actual x/y
+// instead of (0, 0) on first hover.
+export function TooltipBox(props: TooltipBoxProps) {
+ const [container, setContainer] = useState(null)
+ const [mounted, setMounted] = useState(false)
+
+ useEffect(() => {
+ setMounted(true)
+ setContainer(props.containerRef.current)
+ }, [props.containerRef])
+
+ if (!(mounted && container)) {
+ return null
+ }
+ if (!props.visible) {
+ return null
+ }
+ return
+}
+
+function TooltipBoxInner({
+ x,
+ y,
+ containerWidth,
+ containerHeight,
+ offset = 16,
+ className = "",
+ children,
+ left: leftOverride,
+ top: topOverride,
+ flipped: flippedOverride,
+ springConfig,
+ animate = true,
+ panelStyle,
+ backgroundColor = chartCssVars.tooltipBackground,
+ container
+}: Omit & {
+ container: HTMLElement
+}) {
+ const { tooltipBoxSpring } = useChartConfig()
+ const effectiveSpring = springConfig ?? tooltipBoxSpring
+
+ const tooltipRef = useRef(null)
+ const tooltipWidthRef = useRef(180)
+ const tooltipHeightRef = useRef(80)
+ const [staticPosition, setStaticPosition] = useState({ left: x, top: y })
+ const [tooltipSize, setTooltipSize] = useState({ width: 180, height: 80 })
+
+ const tw = tooltipSize.width
+ const th = tooltipSize.height
+ const shouldFlipX = x + tw + offset > containerWidth
+ const targetX = shouldFlipX ? x - offset - tw : x + offset
+ const targetY = Math.max(
+ offset,
+ Math.min(y - th / 2, containerHeight - th - offset)
+ )
+
+ const animatedLeft = useSpring(targetX, effectiveSpring)
+ const animatedTop = useSpring(targetY, effectiveSpring)
+
+ if (animate && leftOverride === undefined) {
+ animatedLeft.set(targetX)
+ }
+ if (animate && topOverride === undefined) {
+ animatedTop.set(targetY)
+ }
+
+ useLayoutEffect(() => {
+ if (!tooltipRef.current) {
+ return
+ }
+ const el = tooltipRef.current
+ const w = el.offsetWidth
+ const h = el.offsetHeight
+ if (w > 0) {
+ tooltipWidthRef.current = w
+ }
+ if (h > 0) {
+ tooltipHeightRef.current = h
+ }
+ const w2 = tooltipWidthRef.current
+ const h2 = tooltipHeightRef.current
+ setTooltipSize(size =>
+ size.width === w2 && size.height === h2 ? size : { width: w2, height: h2 }
+ )
+ const flip = x + w2 + offset > containerWidth
+ const tx = flip ? x - offset - w2 : x + offset
+ const ty = Math.max(
+ offset,
+ Math.min(y - h2 / 2, containerHeight - h2 - offset)
+ )
+ if (!animate) {
+ setStaticPosition({ left: tx, top: ty })
+ return
+ }
+ if (leftOverride === undefined) {
+ animatedLeft.set(tx)
+ }
+ if (topOverride === undefined) {
+ animatedTop.set(ty)
+ }
+ }, [
+ x,
+ y,
+ containerWidth,
+ containerHeight,
+ offset,
+ leftOverride,
+ topOverride,
+ animate,
+ animatedLeft,
+ animatedTop
+ ])
+
+ const prevFlipRef = useRef(shouldFlipX)
+ const [flipKey, setFlipKey] = useState(0)
+
+ useEffect(() => {
+ if (prevFlipRef.current !== shouldFlipX) {
+ setFlipKey(k => k + 1)
+ prevFlipRef.current = shouldFlipX
+ }
+ }, [shouldFlipX])
+
+ const finalLeft = animate
+ ? (leftOverride ?? animatedLeft)
+ : staticPosition.left
+ const finalTop = animate ? (topOverride ?? animatedTop) : staticPosition.top
+ const isFlipped = flippedOverride ?? shouldFlipX
+ const transformOrigin = isFlipped ? "right top" : "left top"
+
+ return createPortal(
+
+
+ {children}
+
+ ,
+ container
+ )
+}
+
+TooltipBox.displayName = "TooltipBox"
+
+export default TooltipBox
diff --git a/src/components/charts/tooltip/tooltip-content.tsx b/src/components/charts/tooltip/tooltip-content.tsx
new file mode 100644
index 00000000..a7ef36b8
--- /dev/null
+++ b/src/components/charts/tooltip/tooltip-content.tsx
@@ -0,0 +1,69 @@
+"use client"
+
+import type { ReactNode } from "react"
+
+import { intFmt } from "../chart-formatters"
+
+export interface TooltipRow {
+ color: string
+ label: string
+ value: string | number
+}
+
+export interface TooltipContentProps {
+ title?: string
+ rows: TooltipRow[]
+ /** Optional additional content (e.g., markers) */
+ children?: ReactNode
+}
+
+export function TooltipContent({ title, rows, children }: TooltipContentProps) {
+ return (
+
+
+ {title && (
+
+ {title}
+
+ )}
+
+ {rows.map(row => (
+
+
+
+
+ {row.label}
+
+
+
+ {typeof row.value === "number" ? intFmt(row.value) : row.value}
+
+
+ ))}
+
+
+ {children && (
+
+ {children}
+
+ )}
+
+
+ )
+}
+
+TooltipContent.displayName = "TooltipContent"
+
+export default TooltipContent
diff --git a/src/components/charts/tooltip/tooltip-dot.tsx b/src/components/charts/tooltip/tooltip-dot.tsx
new file mode 100644
index 00000000..6f2832a1
--- /dev/null
+++ b/src/components/charts/tooltip/tooltip-dot.tsx
@@ -0,0 +1,74 @@
+"use client"
+
+import { motion, useSpring } from "motion/react"
+
+import { useChartConfig, type SpringConfig } from "../chart-config-context"
+import { chartCssVars } from "../chart-context"
+
+export interface TooltipDotProps {
+ x: number
+ y: number
+ visible: boolean
+ color: string
+ size?: number
+ strokeColor?: string
+ strokeWidth?: number
+ /** Per-chart override; falls back to `ChartConfigProvider.tooltipSpring`. */
+ springConfig?: SpringConfig
+ /** Animate position with a spring. Default: true */
+ animate?: boolean
+}
+
+export function TooltipDot({
+ x,
+ y,
+ visible,
+ color,
+ size = 5,
+ strokeColor = chartCssVars.background,
+ strokeWidth = 2,
+ springConfig,
+ animate = true
+}: TooltipDotProps) {
+ const { tooltipSpring } = useChartConfig()
+ const effectiveSpring = springConfig ?? tooltipSpring
+ const animatedX = useSpring(x, effectiveSpring)
+ const animatedY = useSpring(y, effectiveSpring)
+
+ if (animate) {
+ animatedX.set(x)
+ animatedY.set(y)
+ }
+
+ if (!visible) {
+ return null
+ }
+
+ if (!animate) {
+ return (
+
+ )
+ }
+
+ return (
+
+ )
+}
+
+TooltipDot.displayName = "TooltipDot"
+
+export default TooltipDot
diff --git a/src/components/charts/tooltip/tooltip-indicator.tsx b/src/components/charts/tooltip/tooltip-indicator.tsx
new file mode 100644
index 00000000..90af0cb2
--- /dev/null
+++ b/src/components/charts/tooltip/tooltip-indicator.tsx
@@ -0,0 +1,212 @@
+"use client"
+
+import { useEffect } from "react"
+import { motion, useSpring } from "motion/react"
+
+import { useChartConfig, type SpringConfig } from "../chart-config-context"
+import { chartCssVars } from "../chart-context"
+import {
+ indicatorFadeGradientStops,
+ resolveVerticalFadeSides,
+ type IndicatorFadeEdges
+} from "../indicator-fade"
+
+export type IndicatorWidth =
+ | number // Pixel width
+ | "line" // 1px line (default)
+ | "thin" // 2px
+ | "medium" // 4px
+ | "thick" // 8px
+
+export interface TooltipIndicatorProps {
+ /** X position in pixels (center of the indicator) */
+ x: number
+ /** Height of the indicator */
+ height: number
+ /** Whether the indicator is visible */
+ visible: boolean
+ /**
+ * Width of the indicator - number (pixels) or preset.
+ * Ignored if `span` is provided.
+ */
+ width?: IndicatorWidth
+ /**
+ * Number of columns/days to span, with current point centered.
+ * Requires `columnWidth` to be set.
+ */
+ span?: number
+ /** Width of a single column/day in pixels. Required when using `span`. */
+ columnWidth?: number
+ /** Primary color at edges (10% and 90%) */
+ colorEdge?: string
+ /** Secondary color at center (50%) */
+ colorMid?: string
+ /** Vertical fade: both ends, top, bottom, or none (solid). */
+ fadeEdges?: IndicatorFadeEdges | boolean
+ /** Fade zone size as a percentage of indicator height. Default: 10 */
+ fadeLength?: number
+ /** Animate position with a spring. Default: true */
+ animate?: boolean
+ /** Unique ID for the gradient */
+ gradientId?: string
+ /** Per-chart override; falls back to `ChartConfigProvider.tooltipSpring`. */
+ springConfig?: SpringConfig
+ /** SVG stroke dash pattern. When set, renders a dashed stroke instead of a solid fill. */
+ strokeDasharray?: string
+}
+
+function resolveWidth(width: IndicatorWidth): number {
+ if (typeof width === "number") {
+ return width
+ }
+ switch (width) {
+ case "line":
+ return 1
+ case "thin":
+ return 2
+ case "medium":
+ return 4
+ case "thick":
+ return 8
+ default:
+ return 1
+ }
+}
+
+// Inner-only-on-visible so `useSpring` initializes at the real cursor x
+// instead of 0 on first hover.
+export function TooltipIndicator(props: TooltipIndicatorProps) {
+ if (!props.visible) {
+ return null
+ }
+ return
+}
+
+function TooltipIndicatorInner({
+ x,
+ visible,
+ height,
+ width = "line",
+ span,
+ columnWidth,
+ colorEdge = chartCssVars.crosshair,
+ colorMid = chartCssVars.crosshair,
+ fadeEdges = "both",
+ fadeLength = 10,
+ animate = true,
+ gradientId = "tooltip-indicator-gradient",
+ springConfig,
+ strokeDasharray
+}: TooltipIndicatorProps) {
+ const { tooltipSpring } = useChartConfig()
+ const effectiveSpring = springConfig ?? tooltipSpring
+
+ const pixelWidth =
+ span !== undefined && columnWidth !== undefined
+ ? span * columnWidth
+ : resolveWidth(width)
+
+ const rectX = x - pixelWidth / 2
+ const lineX = x
+ const animatedX = useSpring(rectX, effectiveSpring)
+ const animatedLineX = useSpring(lineX, effectiveSpring)
+
+ if (animate) {
+ animatedX.set(rectX)
+ animatedLineX.set(lineX)
+ }
+
+ // biome-ignore lint/correctness/useExhaustiveDependencies: we need to jump the animatedX when the visible prop changes
+ useEffect(() => {
+ animatedX.set(rectX)
+ animatedLineX.set(lineX)
+ }, [animatedLineX, animatedX, lineX, rectX, visible])
+
+ const indicatorFill = colorMid || colorEdge
+ const fadeSides = resolveVerticalFadeSides(fadeEdges)
+ const dashed = Boolean(strokeDasharray)
+
+ if (dashed) {
+ const strokeWidth = Math.max(1, pixelWidth)
+ return animate ? (
+
+ ) : (
+
+ )
+ }
+
+ if (!fadeSides.any) {
+ return animate ? (
+
+ ) : (
+
+ )
+ }
+
+ const fadeStops = indicatorFadeGradientStops(fadeSides, fadeLength)
+
+ return (
+
+
+
+ {fadeStops.map(stop => (
+
+ ))}
+
+
+ {animate ? (
+
+ ) : (
+
+ )}
+
+ )
+}
+
+TooltipIndicator.displayName = "TooltipIndicator"
+
+export default TooltipIndicator
diff --git a/src/components/charts/use-animated-y-domains.ts b/src/components/charts/use-animated-y-domains.ts
new file mode 100644
index 00000000..19a2c10e
--- /dev/null
+++ b/src/components/charts/use-animated-y-domains.ts
@@ -0,0 +1,254 @@
+"use client"
+
+import { useEffect, useRef, useState } from "react"
+import { animate, useReducedMotion } from "motion/react"
+
+import type { ChartPhase } from "./chart-phase"
+import { LINE_LOADING_PULSE_EASE } from "./line-loading-timing"
+import {
+ domainsEqual,
+ isYDomainTweenPhase,
+ resolveAnimatedYDestinationDomains,
+ shouldTweenYDomain,
+ type YDomain
+} from "./y-domain-utils"
+
+function lerpDomain(from: YDomain, to: YDomain, progress: number): YDomain {
+ return [
+ from[0] + (to[0] - from[0]) * progress,
+ from[1] + (to[1] - from[1]) * progress
+ ]
+}
+
+function snapDomains(
+ domains: Record,
+ setAnimatedByAxis: (domains: Record) => void,
+ animatedRef: { current: Record }
+) {
+ if (domainsEqual(animatedRef.current, domains)) {
+ return
+ }
+ setAnimatedByAxis(domains)
+ animatedRef.current = domains
+}
+
+function tweenDomains({
+ destination,
+ durationMs,
+ enabled,
+ reducedMotion,
+ animatedRef,
+ setAnimatedByAxis,
+ onSettled
+}: {
+ destination: Record
+ durationMs: number
+ enabled: boolean
+ reducedMotion: boolean | null
+ animatedRef: { current: Record }
+ setAnimatedByAxis: (domains: Record) => void
+ onSettled?: () => void
+}) {
+ if (domainsEqual(animatedRef.current, destination)) {
+ onSettled?.()
+ return
+ }
+
+ if (!enabled || reducedMotion) {
+ snapDomains(destination, setAnimatedByAxis, animatedRef)
+ onSettled?.()
+ return
+ }
+
+ const axisIds = Object.keys(destination)
+ const fromSnapshot = animatedRef.current
+
+ let needsTween = false
+ for (const axisId of axisIds) {
+ const from =
+ fromSnapshot[axisId] ?? destination[axisId] ?? ([0, 100] as YDomain)
+ const to = destination[axisId] ?? from
+ if (shouldTweenYDomain(from, to)) {
+ needsTween = true
+ break
+ }
+ }
+
+ if (!needsTween) {
+ snapDomains(destination, setAnimatedByAxis, animatedRef)
+ onSettled?.()
+ return
+ }
+
+ const fromByAxis: Record = {}
+ for (const axisId of axisIds) {
+ fromByAxis[axisId] = fromSnapshot[axisId] ?? destination[axisId] ?? [0, 100]
+ }
+
+ const control = animate(0, 1, {
+ duration: durationMs / 1000,
+ ease: [...LINE_LOADING_PULSE_EASE],
+ onUpdate: progress => {
+ const next: Record = {}
+ for (const axisId of axisIds) {
+ const from =
+ fromByAxis[axisId] ?? destination[axisId] ?? ([0, 100] as YDomain)
+ const to = destination[axisId] ?? from
+ next[axisId] = shouldTweenYDomain(from, to)
+ ? lerpDomain(from, to, progress)
+ : to
+ }
+ animatedRef.current = next
+ setAnimatedByAxis(next)
+ },
+ onComplete: () => {
+ snapDomains(destination, setAnimatedByAxis, animatedRef)
+ onSettled?.()
+ }
+ })
+
+ return control
+}
+
+export interface UseAnimatedYDomainsOptions {
+ enabled: boolean
+ durationMs: number
+ chartPhase: ChartPhase
+ skeletonByAxis: Record
+ targetByAxis: Record
+ onSettled?: () => void
+ /** When true, tweens y-domains on target changes while the chart is in the ready phase (e.g. brush zoom). */
+ tweenOnTargetChange?: boolean
+}
+
+export function useAnimatedYDomains({
+ enabled,
+ durationMs,
+ chartPhase,
+ skeletonByAxis,
+ targetByAxis,
+ onSettled,
+ tweenOnTargetChange = false
+}: UseAnimatedYDomainsOptions): Record {
+ const reducedMotion = useReducedMotion()
+ const destinationByAxis = resolveAnimatedYDestinationDomains(
+ chartPhase,
+ skeletonByAxis,
+ targetByAxis
+ )
+ const destinationRef = useRef(destinationByAxis)
+ const skeletonRef = useRef(skeletonByAxis)
+ const targetRef = useRef(targetByAxis)
+
+ const [animatedByAxis, setAnimatedByAxis] = useState(destinationByAxis)
+ const animatedRef = useRef(animatedByAxis)
+ const prevPhaseRef = useRef(chartPhase)
+ const onSettledRef = useRef(onSettled)
+
+ useEffect(() => {
+ destinationRef.current = destinationByAxis
+ }, [destinationByAxis])
+
+ useEffect(() => {
+ skeletonRef.current = skeletonByAxis
+ }, [skeletonByAxis])
+
+ useEffect(() => {
+ targetRef.current = targetByAxis
+ }, [targetByAxis])
+
+ useEffect(() => {
+ onSettledRef.current = onSettled
+ }, [onSettled])
+
+ useEffect(() => {
+ animatedRef.current = animatedByAxis
+ }, [animatedByAxis])
+
+ useEffect(() => {
+ if (prevPhaseRef.current === chartPhase) {
+ return
+ }
+ prevPhaseRef.current = chartPhase
+
+ const settle = () => {
+ onSettledRef.current?.()
+ }
+
+ // Keep grid spacing frozen while the series exits the viewport.
+ if (chartPhase === "exiting") {
+ snapDomains(skeletonRef.current, setAnimatedByAxis, animatedRef)
+ return
+ }
+ if (chartPhase === "exitingReady") {
+ snapDomains(targetRef.current, setAnimatedByAxis, animatedRef)
+ return
+ }
+ if (chartPhase === "loading") {
+ snapDomains(skeletonRef.current, setAnimatedByAxis, animatedRef)
+ return
+ }
+ if (chartPhase === "revealing" || chartPhase === "ready") {
+ snapDomains(targetRef.current, setAnimatedByAxis, animatedRef)
+ return
+ }
+
+ if (!isYDomainTweenPhase(chartPhase)) {
+ return
+ }
+
+ const control = tweenDomains({
+ destination: destinationRef.current,
+ durationMs,
+ enabled,
+ reducedMotion,
+ animatedRef,
+ setAnimatedByAxis,
+ onSettled: settle
+ })
+
+ return () => control?.stop()
+ }, [chartPhase, durationMs, enabled, reducedMotion])
+
+ const targetSignature = JSON.stringify(targetByAxis)
+ const prevTargetSignatureRef = useRef(targetSignature)
+
+ useEffect(() => {
+ const inLivePhase = chartPhase === "ready" || chartPhase === "revealing"
+
+ if (!inLivePhase) {
+ prevTargetSignatureRef.current = targetSignature
+ return
+ }
+
+ if (prevTargetSignatureRef.current === targetSignature) {
+ return
+ }
+ prevTargetSignatureRef.current = targetSignature
+
+ if (tweenOnTargetChange && chartPhase === "ready") {
+ const control = tweenDomains({
+ destination: targetRef.current,
+ durationMs,
+ enabled,
+ reducedMotion,
+ animatedRef,
+ setAnimatedByAxis,
+ onSettled: () => onSettledRef.current?.()
+ })
+
+ return () => control?.stop()
+ }
+
+ snapDomains(targetRef.current, setAnimatedByAxis, animatedRef)
+ }, [
+ chartPhase,
+ durationMs,
+ enabled,
+ reducedMotion,
+ targetSignature,
+ tweenOnTargetChange
+ ])
+
+ return animatedByAxis
+}
diff --git a/src/components/charts/use-chart-interaction.ts b/src/components/charts/use-chart-interaction.ts
new file mode 100644
index 00000000..63d1d3eb
--- /dev/null
+++ b/src/components/charts/use-chart-interaction.ts
@@ -0,0 +1,353 @@
+"use client"
+
+import { useCallback, useEffect, useRef, useState } from "react"
+import { localPoint } from "@visx/event"
+import type { scaleLinear, scaleTime } from "@visx/scale"
+
+import type { LineConfig, Margin, TooltipData } from "./chart-context"
+import { useScheduledTooltip } from "./use-scheduled-tooltip"
+import { normalizeYAxisId } from "./y-axis-scales"
+
+type ScaleTime = ReturnType>
+type ScaleLinear = ReturnType>
+
+export interface ChartSelection {
+ startX: number
+ endX: number
+ startIndex: number
+ endIndex: number
+ active: boolean
+}
+
+interface UseChartInteractionParams {
+ xScale: ScaleTime
+ yScale: ScaleLinear
+ yScales: Record
+ data: Record[]
+ lines: LineConfig[]
+ margin: Margin
+ xAccessor: (d: Record) => Date
+ bisectDate: (
+ data: Record[],
+ date: Date,
+ lo: number
+ ) => number
+ canInteract: boolean
+}
+
+interface ChartInteractionResult {
+ tooltipData: TooltipData | null
+ setTooltipData: React.Dispatch>
+ selection: ChartSelection | null
+ clearSelection: () => void
+ interactionHandlers: {
+ onMouseMove?: (event: React.MouseEvent) => void
+ onMouseLeave?: () => void
+ onMouseDown?: (event: React.MouseEvent) => void
+ onMouseUp?: () => void
+ onTouchStart?: (event: React.TouchEvent) => void
+ onTouchMove?: (event: React.TouchEvent) => void
+ onTouchEnd?: () => void
+ }
+ interactionStyle: React.CSSProperties
+}
+
+export function useChartInteraction({
+ xScale,
+ yScale,
+ yScales,
+ data,
+ lines,
+ margin,
+ xAccessor,
+ bisectDate,
+ canInteract
+}: UseChartInteractionParams): ChartInteractionResult {
+ const [selection, setSelection] = useState(null)
+ const {
+ tooltipData,
+ setTooltipData,
+ scheduleTooltip,
+ clearTooltip,
+ resetTooltipDedupe
+ } = useScheduledTooltip()
+
+ const isDraggingRef = useRef(false)
+ const dragStartXRef = useRef(0)
+ const lastHoveredXRef = useRef(null)
+
+ const resolveTooltipFromX = useCallback(
+ (pixelX: number): TooltipData | null => {
+ const x0 = xScale.invert(pixelX)
+ const index = bisectDate(data, x0, 1)
+ const d0 = data[index - 1]
+ const d1 = data[index]
+
+ if (!d0) {
+ return null
+ }
+
+ let d = d0
+ let finalIndex = index - 1
+ if (d1) {
+ const d0Time = xAccessor(d0).getTime()
+ const d1Time = xAccessor(d1).getTime()
+ if (x0.getTime() - d0Time > d1Time - x0.getTime()) {
+ d = d1
+ finalIndex = index
+ }
+ }
+
+ const yPositions: Record = {}
+ for (const line of lines) {
+ const value = d[line.dataKey]
+ if (typeof value === "number") {
+ const axisScale = yScales[normalizeYAxisId(line.yAxisId)] ?? yScale
+ yPositions[line.dataKey] = axisScale(value) ?? 0
+ }
+ }
+
+ return {
+ point: d,
+ index: finalIndex,
+ x: xScale(xAccessor(d)) ?? 0,
+ yPositions
+ }
+ },
+ [xScale, yScale, yScales, data, lines, xAccessor, bisectDate]
+ )
+
+ const resolveIndexFromX = useCallback(
+ (pixelX: number): number => {
+ const x0 = xScale.invert(pixelX)
+ const index = bisectDate(data, x0, 1)
+ const d0 = data[index - 1]
+ const d1 = data[index]
+ if (!d0) {
+ return 0
+ }
+ if (d1) {
+ const d0Time = xAccessor(d0).getTime()
+ const d1Time = xAccessor(d1).getTime()
+ if (x0.getTime() - d0Time > d1Time - x0.getTime()) {
+ return index
+ }
+ }
+ return index - 1
+ },
+ [xScale, data, xAccessor, bisectDate]
+ )
+
+ const getChartX = useCallback(
+ (
+ event: React.MouseEvent | React.TouchEvent,
+ touchIndex = 0
+ ): number | null => {
+ let point: { x: number; y: number } | null = null
+
+ if ("touches" in event) {
+ const touch = event.touches[touchIndex]
+ if (!touch) {
+ return null
+ }
+ const svg = event.currentTarget.ownerSVGElement
+ if (!svg) {
+ return null
+ }
+ point = localPoint(svg, touch as unknown as MouseEvent)
+ } else {
+ point = localPoint(event)
+ }
+
+ if (!point) {
+ return null
+ }
+ return point.x - margin.left
+ },
+ [margin.left]
+ )
+
+ const handleMouseMove = useCallback(
+ (event: React.MouseEvent) => {
+ const chartX = getChartX(event)
+ if (chartX === null) {
+ return
+ }
+
+ if (isDraggingRef.current) {
+ const startX = Math.min(dragStartXRef.current, chartX)
+ const endX = Math.max(dragStartXRef.current, chartX)
+ setSelection({
+ startX,
+ endX,
+ startIndex: resolveIndexFromX(startX),
+ endIndex: resolveIndexFromX(endX),
+ active: true
+ })
+ return
+ }
+
+ lastHoveredXRef.current = chartX
+ const tooltip = resolveTooltipFromX(chartX)
+ if (tooltip) {
+ scheduleTooltip(tooltip)
+ }
+ },
+ [getChartX, resolveTooltipFromX, resolveIndexFromX, scheduleTooltip]
+ )
+
+ const handleMouseLeave = useCallback(() => {
+ lastHoveredXRef.current = null
+ clearTooltip()
+ if (isDraggingRef.current) {
+ isDraggingRef.current = false
+ }
+ setSelection(null)
+ }, [clearTooltip])
+
+ const handleMouseDown = useCallback(
+ (event: React.MouseEvent) => {
+ const chartX = getChartX(event)
+ if (chartX === null) {
+ return
+ }
+ isDraggingRef.current = true
+ dragStartXRef.current = chartX
+ clearTooltip()
+ setSelection(null)
+ },
+ [getChartX, clearTooltip]
+ )
+
+ const handleMouseUp = useCallback(() => {
+ if (isDraggingRef.current) {
+ isDraggingRef.current = false
+ }
+ setSelection(null)
+ }, [])
+
+ const handleTouchStart = useCallback(
+ (event: React.TouchEvent) => {
+ if (event.touches.length === 1) {
+ event.preventDefault()
+ const chartX = getChartX(event, 0)
+ if (chartX === null) {
+ return
+ }
+ lastHoveredXRef.current = chartX
+ const tooltip = resolveTooltipFromX(chartX)
+ if (tooltip) {
+ scheduleTooltip(tooltip)
+ }
+ } else if (event.touches.length === 2) {
+ event.preventDefault()
+ resetTooltipDedupe()
+ clearTooltip()
+ const x0 = getChartX(event, 0)
+ const x1 = getChartX(event, 1)
+ if (x0 === null || x1 === null) {
+ return
+ }
+ const startX = Math.min(x0, x1)
+ const endX = Math.max(x0, x1)
+ setSelection({
+ startX,
+ endX,
+ startIndex: resolveIndexFromX(startX),
+ endIndex: resolveIndexFromX(endX),
+ active: true
+ })
+ }
+ },
+ [
+ getChartX,
+ resolveTooltipFromX,
+ resolveIndexFromX,
+ scheduleTooltip,
+ resetTooltipDedupe,
+ clearTooltip
+ ]
+ )
+
+ const handleTouchMove = useCallback(
+ (event: React.TouchEvent) => {
+ if (event.touches.length === 1) {
+ event.preventDefault()
+ const chartX = getChartX(event, 0)
+ if (chartX === null) {
+ return
+ }
+ lastHoveredXRef.current = chartX
+ const tooltip = resolveTooltipFromX(chartX)
+ if (tooltip) {
+ scheduleTooltip(tooltip)
+ }
+ } else if (event.touches.length === 2) {
+ event.preventDefault()
+ const x0 = getChartX(event, 0)
+ const x1 = getChartX(event, 1)
+ if (x0 === null || x1 === null) {
+ return
+ }
+ const startX = Math.min(x0, x1)
+ const endX = Math.max(x0, x1)
+ setSelection({
+ startX,
+ endX,
+ startIndex: resolveIndexFromX(startX),
+ endIndex: resolveIndexFromX(endX),
+ active: true
+ })
+ }
+ },
+ [getChartX, resolveTooltipFromX, resolveIndexFromX, scheduleTooltip]
+ )
+
+ const handleTouchEnd = useCallback(() => {
+ clearTooltip()
+ setSelection(null)
+ }, [clearTooltip])
+
+ const clearSelection = useCallback(() => {
+ setSelection(null)
+ }, [])
+
+ // Re-anchor tooltip/crosshair when x-scale or visible data changes (e.g. brush zoom commit).
+ useEffect(() => {
+ if (!canInteract || lastHoveredXRef.current === null) {
+ return
+ }
+ const tooltip = resolveTooltipFromX(lastHoveredXRef.current)
+ if (tooltip) {
+ scheduleTooltip(tooltip, `${tooltip.index}:${Math.round(tooltip.x)}`)
+ return
+ }
+ clearTooltip()
+ }, [canInteract, clearTooltip, resolveTooltipFromX, scheduleTooltip])
+
+ const interactionHandlers = canInteract
+ ? {
+ onMouseMove: handleMouseMove,
+ onMouseLeave: handleMouseLeave,
+ onMouseDown: handleMouseDown,
+ onMouseUp: handleMouseUp,
+ onTouchStart: handleTouchStart,
+ onTouchMove: handleTouchMove,
+ onTouchEnd: handleTouchEnd
+ }
+ : {}
+
+ const interactionStyle: React.CSSProperties = {
+ cursor: canInteract ? "crosshair" : "default",
+ touchAction: "none"
+ }
+
+ return {
+ tooltipData,
+ setTooltipData,
+ selection,
+ clearSelection,
+ interactionHandlers,
+ interactionStyle
+ }
+}
diff --git a/src/components/charts/use-chart-phase-orchestrator.ts b/src/components/charts/use-chart-phase-orchestrator.ts
new file mode 100644
index 00000000..5a9bf878
--- /dev/null
+++ b/src/components/charts/use-chart-phase-orchestrator.ts
@@ -0,0 +1,187 @@
+"use client"
+
+import { useCallback, useEffect, useRef, useState } from "react"
+
+import {
+ resolveRestingChartPhase,
+ type ChartPhase,
+ type ChartStatus
+} from "./chart-phase"
+
+export interface UseChartPhaseOrchestratorOptions {
+ chartStatus: ChartStatus
+ targetData: Record[]
+ skeletonData: Record[]
+ animationDuration: number
+ yDomainTweenDuration: number
+ /** Signature of motion URL state — replays clip reveal in Studio. */
+ revealSignature?: string
+ /** Skip mount/signature enter reveal (static docs previews). */
+ skipEnterReveal?: boolean
+}
+
+export function useChartPhaseOrchestrator({
+ chartStatus,
+ targetData,
+ skeletonData,
+ animationDuration,
+ yDomainTweenDuration,
+ revealSignature = "",
+ skipEnterReveal = false
+}: UseChartPhaseOrchestratorOptions) {
+ const [chartPhase, setChartPhase] = useState(() =>
+ resolveRestingChartPhase(chartStatus)
+ )
+ const [plotData, setPlotData] = useState[]>(() =>
+ chartStatus === "loading" ? skeletonData : targetData
+ )
+ const [revealEpoch, setRevealEpoch] = useState(0)
+ const [concealEpoch, setConcealEpoch] = useState(0)
+ const [isLoaded, setIsLoaded] = useState(() => chartStatus === "ready")
+ const prevStatusRef = useRef(chartStatus)
+ const phaseRef = useRef(chartPhase)
+
+ useEffect(() => {
+ phaseRef.current = chartPhase
+ }, [chartPhase])
+
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: status transition branches for animation durations
+ useEffect(() => {
+ const prevStatus = prevStatusRef.current
+ if (prevStatus === chartStatus) {
+ return
+ }
+ prevStatusRef.current = chartStatus
+
+ if (chartStatus === "ready" && prevStatus === "loading") {
+ setIsLoaded(false)
+ if (animationDuration <= 0) {
+ if (yDomainTweenDuration <= 0) {
+ setPlotData(targetData)
+ setChartPhase("revealing")
+ } else {
+ setChartPhase("gridTweenReady")
+ }
+ } else {
+ setChartPhase("exiting")
+ }
+ return
+ }
+
+ if (chartStatus === "loading" && prevStatus === "ready") {
+ setIsLoaded(false)
+ if (animationDuration <= 0) {
+ if (yDomainTweenDuration <= 0) {
+ setPlotData(skeletonData)
+ setChartPhase("loading")
+ } else {
+ setChartPhase("gridTweenLoading")
+ }
+ } else {
+ setConcealEpoch(epoch => epoch + 1)
+ setChartPhase("exitingReady")
+ }
+ }
+ }, [
+ animationDuration,
+ chartStatus,
+ skeletonData,
+ targetData,
+ yDomainTweenDuration
+ ])
+
+ // biome-ignore lint/correctness/useExhaustiveDependencies: revealSignature replays enter
+ useEffect(() => {
+ if (skipEnterReveal) {
+ return
+ }
+ if (chartStatus !== "ready") {
+ return
+ }
+ if (phaseRef.current !== "ready") {
+ return
+ }
+
+ setChartPhase("revealing")
+ setIsLoaded(false)
+ }, [animationDuration, chartStatus, revealSignature, skipEnterReveal])
+
+ useEffect(() => {
+ switch (chartPhase) {
+ case "loading":
+ if (chartStatus === "loading") {
+ setPlotData(skeletonData)
+ }
+ break
+ case "exiting":
+ setPlotData(skeletonData)
+ break
+ case "exitingReady":
+ case "gridTweenLoading":
+ case "gridTweenReady":
+ case "revealing":
+ case "ready":
+ setPlotData(targetData)
+ break
+ default:
+ break
+ }
+ }, [chartPhase, chartStatus, skeletonData, targetData])
+
+ /** Loading pulse exit finished — tween grid to ready spacing next. */
+ const notifyLoadingPulseComplete = useCallback(() => {
+ if (phaseRef.current !== "exiting") {
+ return
+ }
+ setChartPhase("gridTweenReady")
+ }, [])
+
+ /** Ready series conceal finished — tween grid to loading spacing next. */
+ const notifyRevealConcealComplete = useCallback(() => {
+ if (phaseRef.current !== "exitingReady") {
+ return
+ }
+ setChartPhase("gridTweenLoading")
+ }, [])
+
+ /** Grid tween finished — enter the next resting phase. */
+ const notifyYDomainTweenComplete = useCallback(() => {
+ if (phaseRef.current === "gridTweenLoading") {
+ setChartPhase("loading")
+ return
+ }
+ if (phaseRef.current === "gridTweenReady") {
+ setChartPhase("revealing")
+ }
+ }, [])
+
+ useEffect(() => {
+ if (chartPhase !== "revealing") {
+ return
+ }
+
+ setRevealEpoch(epoch => epoch + 1)
+ if (animationDuration <= 0) {
+ setChartPhase("ready")
+ setIsLoaded(true)
+ return
+ }
+
+ const timer = window.setTimeout(() => {
+ setChartPhase("ready")
+ setIsLoaded(true)
+ }, animationDuration)
+ return () => window.clearTimeout(timer)
+ }, [animationDuration, chartPhase])
+
+ return {
+ chartPhase,
+ plotData,
+ revealEpoch,
+ concealEpoch,
+ isLoaded,
+ notifyLoadingPulseComplete,
+ notifyRevealConcealComplete,
+ notifyYDomainTweenComplete
+ }
+}
diff --git a/src/components/charts/use-enter-complete.ts b/src/components/charts/use-enter-complete.ts
new file mode 100644
index 00000000..43eed397
--- /dev/null
+++ b/src/components/charts/use-enter-complete.ts
@@ -0,0 +1,28 @@
+"use client"
+
+import { useEffect, useState } from "react"
+import type { MotionValue } from "motion/react"
+
+/**
+ * Returns true once a mount-progress MotionValue reaches 1.
+ * Use to swap animated MotionValue-driven props for static values after
+ * enter completes — drops per-frame subscriptions during pan/hover.
+ */
+export function useEnterComplete(mountProgress: MotionValue): boolean {
+ const [complete, setComplete] = useState(() => mountProgress.get() >= 1)
+
+ useEffect(() => {
+ if (mountProgress.get() >= 1) {
+ setComplete(true)
+ return
+ }
+
+ return mountProgress.on("change", value => {
+ if (value >= 1) {
+ setComplete(true)
+ }
+ })
+ }, [mountProgress])
+
+ return complete
+}
diff --git a/src/components/charts/use-grid-shimmer.ts b/src/components/charts/use-grid-shimmer.ts
new file mode 100644
index 00000000..d950ff41
--- /dev/null
+++ b/src/components/charts/use-grid-shimmer.ts
@@ -0,0 +1,111 @@
+"use client"
+
+import { useEffect } from "react"
+import {
+ animate,
+ useMotionValue,
+ useReducedMotion,
+ useTransform
+} from "motion/react"
+
+import {
+ LINE_LOADING_LOOP_PAUSE_MS,
+ LINE_LOADING_PULSE_CYCLE_S,
+ LINE_LOADING_PULSE_EASE
+} from "./line-loading-timing"
+
+export interface UseGridShimmerOptions {
+ innerWidth: number
+ shimmer: boolean
+ shimmerLength: number
+ shimmerSpeed: number
+ shimmerSync: boolean
+ /** When false, shimmer animation is paused (e.g. during exit transition). */
+ active: boolean
+ /** Run a single synced sweep (loading → ready handoff). */
+ oneShot?: boolean
+}
+
+export function useGridShimmer({
+ innerWidth,
+ shimmer,
+ shimmerLength,
+ shimmerSpeed,
+ shimmerSync,
+ active,
+ oneShot = false
+}: UseGridShimmerOptions) {
+ const progress = useMotionValue(0)
+ const reducedMotion = useReducedMotion()
+ const shimmerCycleS = LINE_LOADING_PULSE_CYCLE_S / Math.max(shimmerSpeed, 0.1)
+ const shimmerEnabled =
+ active && shimmer && reducedMotion !== true && innerWidth > 0
+
+ useEffect(() => {
+ if (!shimmerEnabled) {
+ return
+ }
+
+ let cancelled = false
+ let timeoutId: number | undefined
+ let controls: ReturnType | undefined
+
+ const runSyncedCycle = () => {
+ if (cancelled) {
+ return
+ }
+
+ progress.set(0)
+ controls = animate(progress, 1, {
+ duration: shimmerCycleS,
+ ease: [...LINE_LOADING_PULSE_EASE],
+ onComplete: () => {
+ if (cancelled) {
+ return
+ }
+ timeoutId = window.setTimeout(
+ runSyncedCycle,
+ LINE_LOADING_LOOP_PAUSE_MS
+ )
+ }
+ })
+ }
+
+ if (shimmerSync && oneShot) {
+ progress.set(0)
+ controls = animate(progress, 1, {
+ duration: shimmerCycleS / 2,
+ ease: [...LINE_LOADING_PULSE_EASE]
+ })
+ return () => controls?.stop()
+ }
+
+ if (shimmerSync) {
+ runSyncedCycle()
+ return () => {
+ cancelled = true
+ controls?.stop()
+ if (timeoutId !== undefined) {
+ window.clearTimeout(timeoutId)
+ }
+ }
+ }
+
+ progress.set(0)
+ controls = animate(progress, 1, {
+ duration: shimmerCycleS,
+ repeat: Number.POSITIVE_INFINITY,
+ ease: [...LINE_LOADING_PULSE_EASE]
+ })
+
+ return () => controls?.stop()
+ }, [oneShot, progress, shimmerCycleS, shimmerEnabled, shimmerSync])
+
+ const shimmerX = useTransform(
+ progress,
+ value => -shimmerLength + value * (innerWidth + shimmerLength * 2)
+ )
+ const shimmerTransform = useTransform(shimmerX, x => `translate(${x}, 0)`)
+
+ return { shimmerEnabled, shimmerTransform }
+}
diff --git a/src/components/charts/use-mount-progress.ts b/src/components/charts/use-mount-progress.ts
new file mode 100644
index 00000000..3c29cd17
--- /dev/null
+++ b/src/components/charts/use-mount-progress.ts
@@ -0,0 +1,33 @@
+"use client"
+
+import { useEffect, useRef } from "react"
+import { animate, useMotionValue, type Transition } from "motion/react"
+
+import { DEFAULT_CHART_ENTER_TRANSITION } from "./animation"
+
+/** Drives 0→1 enter progress using the studio motion transition (spring or tween). */
+export function useMountProgress(
+ enterTransition: Transition | undefined,
+ delaySeconds: number,
+ replayKey: number | string
+) {
+ const progress = useMotionValue(0)
+ const transitionRef = useRef(enterTransition)
+
+ useEffect(() => {
+ transitionRef.current = enterTransition
+ }, [enterTransition])
+
+ // replayKey intentionally retriggers enter when motion settings change
+ // biome-ignore lint/correctness/useExhaustiveDependencies: replayKey
+ useEffect(() => {
+ progress.set(0)
+ const controls = animate(progress, 1, {
+ ...(transitionRef.current ?? DEFAULT_CHART_ENTER_TRANSITION),
+ delay: delaySeconds
+ })
+ return () => controls.stop()
+ }, [delaySeconds, replayKey, progress])
+
+ return progress
+}
diff --git a/src/components/charts/use-scheduled-tooltip.ts b/src/components/charts/use-scheduled-tooltip.ts
new file mode 100644
index 00000000..77213627
--- /dev/null
+++ b/src/components/charts/use-scheduled-tooltip.ts
@@ -0,0 +1,97 @@
+"use client"
+
+import { useCallback, useEffect, useRef, useState } from "react"
+
+export interface ScheduledTooltipControls {
+ tooltipData: T | null
+ setTooltipData: React.Dispatch>
+ scheduleTooltip: (tooltip: T, dedupeKey?: string) => void
+ clearTooltip: () => void
+ resetTooltipDedupe: () => void
+}
+
+function defaultDedupeKey(tooltip: T): string {
+ if (
+ typeof tooltip === "object" &&
+ tooltip !== null &&
+ "index" in tooltip &&
+ typeof (tooltip as { index: unknown }).index === "number"
+ ) {
+ const { index, x } = tooltip as { index: number; x?: number }
+ if (typeof x === "number") {
+ return `${index}:${Math.round(x)}`
+ }
+ return String(index)
+ }
+ return JSON.stringify(tooltip)
+}
+
+export function useScheduledTooltip(): ScheduledTooltipControls {
+ const [tooltipData, setTooltipData] = useState(null)
+ const lastKeyRef = useRef(null)
+ const pendingRef = useRef(null)
+ const rafRef = useRef(null)
+ const pendingKeyRef = useRef(null)
+
+ useEffect(() => {
+ return () => {
+ if (rafRef.current !== null) {
+ cancelAnimationFrame(rafRef.current)
+ }
+ }
+ }, [])
+
+ const commitTooltip = useCallback((tooltip: T, dedupeKey: string) => {
+ if (dedupeKey === lastKeyRef.current) {
+ return
+ }
+ lastKeyRef.current = dedupeKey
+ setTooltipData(tooltip)
+ }, [])
+
+ const scheduleTooltip = useCallback(
+ (tooltip: T, dedupeKey?: string) => {
+ const key = dedupeKey ?? defaultDedupeKey(tooltip)
+ pendingRef.current = tooltip
+ pendingKeyRef.current = key
+ if (key === lastKeyRef.current) {
+ return
+ }
+ if (rafRef.current !== null) {
+ return
+ }
+ rafRef.current = requestAnimationFrame(() => {
+ rafRef.current = null
+ const next = pendingRef.current
+ const nextKey = pendingKeyRef.current
+ if (next && nextKey) {
+ commitTooltip(next, nextKey)
+ }
+ })
+ },
+ [commitTooltip]
+ )
+
+ const clearTooltip = useCallback(() => {
+ if (rafRef.current !== null) {
+ cancelAnimationFrame(rafRef.current)
+ rafRef.current = null
+ }
+ pendingRef.current = null
+ pendingKeyRef.current = null
+ lastKeyRef.current = null
+ setTooltipData(null)
+ }, [])
+
+ const resetTooltipDedupe = useCallback(() => {
+ lastKeyRef.current = null
+ }, [])
+
+ return {
+ tooltipData,
+ setTooltipData,
+ scheduleTooltip,
+ clearTooltip,
+ resetTooltipDedupe
+ }
+}
diff --git a/src/components/charts/y-axis-scales.ts b/src/components/charts/y-axis-scales.ts
new file mode 100644
index 00000000..dad8d124
--- /dev/null
+++ b/src/components/charts/y-axis-scales.ts
@@ -0,0 +1,116 @@
+import { scaleLinear } from "@visx/scale"
+
+import type { LineConfig } from "./chart-context"
+
+/** Default axis id when `yAxisId` is omitted (Recharts-style `0` / primary left axis). */
+export const DEFAULT_Y_AXIS_ID = "left"
+
+export type YAxisOrientation = "left" | "right"
+
+export function normalizeYAxisId(id?: string | number): string {
+ if (id == null || id === "") {
+ return DEFAULT_Y_AXIS_ID
+ }
+ return String(id)
+}
+
+export function groupLinesByYAxisId(
+ lines: LineConfig[]
+): Map {
+ const groups = new Map()
+ for (const line of lines) {
+ const axisId = normalizeYAxisId(line.yAxisId)
+ const bucket = groups.get(axisId) ?? []
+ bucket.push(line)
+ groups.set(axisId, bucket)
+ }
+ return groups
+}
+
+type YScale = ReturnType>
+
+export function getPrimaryYScale(
+ yScales: Record,
+ fallback: YScale
+): YScale {
+ const primary = yScales[DEFAULT_Y_AXIS_ID]
+ if (primary) {
+ return primary
+ }
+ const first = Object.values(yScales)[0]
+ return first ?? fallback
+}
+
+export function buildYScalesForLines({
+ lines,
+ innerHeight,
+ resolveDomain
+}: {
+ lines: LineConfig[]
+ /** Passed by callers; domain is resolved via `resolveDomain`. */
+ data?: Record[]
+ innerHeight: number
+ resolveDomain: (dataKeys: string[]) => [number, number]
+}): Record {
+ const groups = groupLinesByYAxisId(lines)
+ const scales: Record = {}
+
+ for (const [axisId, axisLines] of groups) {
+ const dataKeys = axisLines.map(line => line.dataKey)
+ const domain = resolveDomain(dataKeys)
+ scales[axisId] = scaleLinear({
+ range: [innerHeight, 0],
+ domain,
+ nice: true
+ })
+ }
+
+ if (!scales[DEFAULT_Y_AXIS_ID]) {
+ scales[DEFAULT_Y_AXIS_ID] = scaleLinear({
+ range: [innerHeight, 0],
+ domain: [0, 100],
+ nice: true
+ })
+ }
+
+ return scales
+}
+
+/** Build y-scales from pre-computed (already nice'd) domain endpoints. */
+export function buildYScalesFromDomains({
+ lines,
+ innerHeight,
+ domainsByAxis
+}: {
+ lines: LineConfig[]
+ innerHeight: number
+ domainsByAxis: Record
+}): Record {
+ const groups = groupLinesByYAxisId(lines)
+ const scales: Record = {}
+
+ for (const [axisId] of groups) {
+ const domain =
+ domainsByAxis[axisId] ??
+ domainsByAxis[DEFAULT_Y_AXIS_ID] ??
+ ([0, 100] as [number, number])
+ scales[axisId] = scaleLinear({
+ range: [innerHeight, 0],
+ domain
+ })
+ }
+
+ if (!scales[DEFAULT_Y_AXIS_ID]) {
+ scales[DEFAULT_Y_AXIS_ID] = scaleLinear({
+ range: [innerHeight, 0],
+ domain: domainsByAxis[DEFAULT_Y_AXIS_ID] ?? [0, 100]
+ })
+ }
+
+ return scales
+}
+
+/** Single-axis charts (bar, scatter, candlestick, live line). */
+export function wrapSingleYScale(yScale: YScale): Record {
+ return { [DEFAULT_Y_AXIS_ID]: yScale }
+}
diff --git a/src/components/charts/y-axis-ticks.ts b/src/components/charts/y-axis-ticks.ts
new file mode 100644
index 00000000..9a5ade7b
--- /dev/null
+++ b/src/components/charts/y-axis-ticks.ts
@@ -0,0 +1,25 @@
+export const Y_AXIS_DEFAULT_TICK_COUNT = 5
+
+/** Minimum valid `numTicks` for `scale.ticks()` — values ≤ 0 yield no ticks. */
+export const Y_AXIS_MIN_TICK_COUNT = 1
+
+/**
+ * Upper bound for the tick count hint. D3 may return more "nice" ticks above ~10;
+ * keeping the hint in a modest range avoids overcrowded axes.
+ */
+export const Y_AXIS_MAX_TICK_COUNT = 10
+
+/** Clamps a user `numTicks` value to a valid d3 tick-count hint. */
+export function resolveYAxisTickCount(numTicks?: number): number {
+ if (numTicks == null || !Number.isFinite(numTicks)) {
+ return Y_AXIS_DEFAULT_TICK_COUNT
+ }
+ const rounded = Math.round(numTicks)
+ if (rounded < Y_AXIS_MIN_TICK_COUNT) {
+ return Y_AXIS_MIN_TICK_COUNT
+ }
+ if (rounded > Y_AXIS_MAX_TICK_COUNT) {
+ return Y_AXIS_MAX_TICK_COUNT
+ }
+ return rounded
+}
diff --git a/src/components/charts/y-domain-utils.ts b/src/components/charts/y-domain-utils.ts
new file mode 100644
index 00000000..9acf6337
--- /dev/null
+++ b/src/components/charts/y-domain-utils.ts
@@ -0,0 +1,128 @@
+import { scaleLinear } from "@visx/scale"
+
+import type { LineConfig } from "./chart-context"
+import { Y_DOMAIN_TWEEN_SKIP_THRESHOLD, type ChartPhase } from "./chart-phase"
+import { groupLinesByYAxisId, normalizeYAxisId } from "./y-axis-scales"
+
+export type YDomain = [number, number]
+
+/** Apply visx `nice()` to raw domain endpoints for stable grid ticks. */
+export function niceYDomain(domain: YDomain): YDomain {
+ const scale = scaleLinear({ domain, range: [0, 1], nice: true })
+ const niceDomain = scale.domain()
+ return [niceDomain[0] ?? domain[0], niceDomain[1] ?? domain[1]]
+}
+
+/**
+ * Skip Y tween when both endpoints move less than the threshold relative to span.
+ * When in doubt callers should tween — beauty wins over micro-optimization.
+ */
+export function shouldTweenYDomain(from: YDomain, to: YDomain): boolean {
+ const span = Math.max(Math.abs(to[1] - to[0]), Math.abs(from[1] - from[0]), 1)
+ const deltaMin = Math.abs(to[0] - from[0]) / span
+ const deltaMax = Math.abs(to[1] - from[1]) / span
+ return (
+ deltaMin >= Y_DOMAIN_TWEEN_SKIP_THRESHOLD ||
+ deltaMax >= Y_DOMAIN_TWEEN_SKIP_THRESHOLD
+ )
+}
+
+/** Phases where the chart shows loading chrome (shimmer, pulse, label). */
+export function isLoadingChromePhase(phase: ChartPhase): boolean {
+ return phase === "loading" || phase === "revealingLoading"
+}
+
+/** Phases where grid lines use loading stroke styling (muted / dashed chrome). */
+export function isLoadingGridChromePhase(phase: ChartPhase): boolean {
+ return (
+ phase === "loading" || phase === "exiting" || phase === "gridTweenLoading"
+ )
+}
+
+/** Phases where Y-domain tween runs after the series has exited. */
+export function isYDomainTweenPhase(phase: ChartPhase): boolean {
+ return phase === "gridTweenLoading" || phase === "gridTweenReady"
+}
+
+/** Phases where {@link ReferenceArea} bands are shown (fade in/out on transitions). */
+export function isReferenceAreaVisiblePhase(phase: ChartPhase): boolean {
+ return (
+ phase === "ready" || phase === "revealing" || phase === "gridTweenReady"
+ )
+}
+
+export function resolveAnimatedYDestinationDomains(
+ chartPhase: ChartPhase,
+ skeletonByAxis: Record,
+ targetByAxis: Record
+): Record {
+ switch (chartPhase) {
+ case "loading":
+ case "exiting":
+ case "gridTweenLoading":
+ return skeletonByAxis
+ case "exitingReady":
+ case "gridTweenReady":
+ case "revealing":
+ case "ready":
+ return targetByAxis
+ default:
+ return targetByAxis
+ }
+}
+
+export function computeYDomainsByAxis({
+ lines,
+ resolveDomain
+}: {
+ lines: LineConfig[]
+ resolveDomain: (dataKeys: string[]) => YDomain
+}): Record {
+ const groups = groupLinesByYAxisId(lines)
+ const domains: Record = {}
+
+ for (const [axisId, axisLines] of groups) {
+ const dataKeys = axisLines.map(line => line.dataKey)
+ domains[normalizeYAxisId(axisId)] = niceYDomain(resolveDomain(dataKeys))
+ }
+
+ if (!domains.left) {
+ domains.left = niceYDomain([0, 100])
+ }
+
+ return domains
+}
+
+/** Merge domain maps, normalizing axis ids to strings. */
+export function mergeYDomainRecords(
+ ...records: Record[]
+): Record {
+ const merged: Record = {}
+ for (const record of records) {
+ for (const [axisId, domain] of Object.entries(record)) {
+ merged[normalizeYAxisId(axisId)] = domain
+ }
+ }
+ return merged
+}
+
+export function domainsEqual(
+ left: Record,
+ right: Record
+): boolean {
+ const leftKeys = Object.keys(left)
+ const rightKeys = Object.keys(right)
+ if (leftKeys.length !== rightKeys.length) {
+ return false
+ }
+
+ for (const axisId of leftKeys) {
+ const from = left[axisId]
+ const to = right[axisId]
+ if (!(from && to) || from[0] !== to[0] || from[1] !== to[1]) {
+ return false
+ }
+ }
+
+ return true
+}
diff --git a/src/components/dashboard/app-sidebar.tsx b/src/components/dashboard/app-sidebar.tsx
index b74c0442..00cd7ce9 100644
--- a/src/components/dashboard/app-sidebar.tsx
+++ b/src/components/dashboard/app-sidebar.tsx
@@ -13,6 +13,7 @@ import * as Sentry from "@sentry/nextjs"
import { type feedbackIntegration } from "@sentry/nextjs"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import {
+ Banknote,
ChevronRight,
ChevronsUpDown,
Crown,
@@ -84,6 +85,16 @@ type NavigationItem = {
const navigation: NavigationItem[] = [
{ title: "Menús", url: "/dashboard", icon: LayoutTemplate },
+ {
+ title: "Ventas",
+ url: "/dashboard/sales",
+ icon: Banknote,
+ items: [
+ { title: "Ventas", url: "/dashboard/sales" },
+ { title: "Punto de venta", url: "/dashboard/sales/new" },
+ { title: "Cierre diario", url: "/dashboard/sales/closing" }
+ ]
+ },
{
title: "Catálogo",
url: "/dashboard/menu-items",
@@ -197,7 +208,13 @@ export default function AppSidebar({
-
+
{
+ return accumulator + char.charCodeAt(0)
+ }, 0)
+
+ return productPlaceholderColors[hash % productPlaceholderColors.length]
+}
+
+const productAbbreviationStopWords = new Set([
+ "a",
+ "al",
+ "con",
+ "de",
+ "del",
+ "e",
+ "el",
+ "en",
+ "la",
+ "las",
+ "los",
+ "o",
+ "u",
+ "y"
+])
+
+function getProductAbbreviation(name: string) {
+ const words = name
+ .trim()
+ .split(/\s+/)
+ .filter(Boolean)
+ .filter(word => !productAbbreviationStopWords.has(word.toLowerCase()))
+
+ const fallbackWords = name.trim().split(/\s+/).filter(Boolean)
+
+ if (words.length === 0) return "?"
+
+ if (words.length === 1) {
+ return (words[0] ?? fallbackWords[0] ?? "?").slice(0, 2).toUpperCase()
+ }
+
+ return words
+ .slice(0, 2)
+ .map(word => word[0])
+ .join("")
+ .toUpperCase()
+}
+
+type CartLine = SaleCartItemInput & {
+ key: string
+ productName: string
+ variantName: string | null
+ unitPrice: number
+ lineTotal: number
+ image: string | null
+ currency: "MXN" | "USD"
+}
+
+export function QuickSaleScreen({
+ catalog,
+ isPro
+}: {
+ catalog: SalesCatalogData
+ isPro: boolean
+}) {
+ const router = useRouter()
+ const [search, setSearch] = useState("")
+ const deferredSearch = useDeferredValue(search)
+ const [selectedCategory, setSelectedCategory] = useState("all")
+ const [orderType, setOrderType] = useState
("DINE_IN")
+ const [cart, setCart] = useState([])
+ const [cartScrollSignal, setCartScrollSignal] = useState(0)
+ const [selectedProduct, setSelectedProduct] =
+ useState(null)
+
+ const { guard: guardSaleCompletion, dialog: upgradeDialog } = useProGuard(
+ isPro,
+ {
+ title: "Actualiza a Pro",
+ description:
+ "El registro de ventas está disponible solo en el plan Pro. Actualiza para completar ventas desde el dashboard."
+ }
+ )
+
+ const currency = cart[0]?.currency ?? catalog.products[0]?.currency ?? "MXN"
+
+ const { execute, status, reset } = useAction(completeSale, {
+ onSuccess: ({ data }) => {
+ if (data?.failure?.reason) {
+ toast.error(data.failure.reason)
+ reset()
+ return
+ }
+
+ if (data?.success) {
+ toast.success(
+ `Venta completada · ${formatPrice(data.success.total, currency)}`
+ )
+ }
+
+ setCart([])
+ setSearch("")
+ setSelectedCategory("all")
+ setOrderType("DINE_IN")
+ setSelectedProduct(null)
+ router.refresh()
+ reset()
+ },
+ onError: () => {
+ toast.error("No se pudo completar la venta")
+ reset()
+ }
+ })
+
+ const categoryOptions = useMemo(
+ () => [
+ { value: "all", label: "Todos" },
+ ...catalog.categories.map(category => ({
+ value: category.id,
+ label: category.name
+ })),
+ ...(catalog.uncategorizedCount > 0
+ ? [{ value: "uncategorized", label: "Sin categoría" }]
+ : [])
+ ],
+ [catalog.categories, catalog.uncategorizedCount]
+ )
+
+ const filteredProducts = useMemo(() => {
+ const query = deferredSearch.trim().toLowerCase()
+
+ return catalog.products.filter(product => {
+ const matchesCategory =
+ selectedCategory === "all"
+ ? true
+ : selectedCategory === "uncategorized"
+ ? product.categoryId === null
+ : product.categoryId === selectedCategory
+
+ const matchesSearch =
+ query.length === 0
+ ? true
+ : [
+ product.name,
+ product.description ?? "",
+ product.categoryName ?? ""
+ ].some(value => value.toLowerCase().includes(query))
+
+ return matchesCategory && matchesSearch
+ })
+ }, [catalog.products, deferredSearch, selectedCategory])
+
+ const subtotal = useMemo(
+ () => roundMoney(cart.reduce((sum, item) => sum + item.lineTotal, 0)),
+ [cart]
+ )
+
+ const totalItems = useMemo(
+ () => cart.reduce((sum, item) => sum + item.quantity, 0),
+ [cart]
+ )
+
+ const cartProductIds = useMemo(
+ () => new Set(cart.map(item => item.menuItemId)),
+ [cart]
+ )
+
+ const addProduct = (product: SalesCatalogProduct, variantId?: string) => {
+ const selectedVariant = variantId
+ ? product.variants.find(variant => variant.id === variantId)
+ : product.variants[0]
+
+ if (!selectedVariant) {
+ toast.error("Selecciona una variante")
+ return
+ }
+
+ const key = `${product.id}:${selectedVariant.id}`
+ const lineTotal = Number(
+ (selectedVariant.price + Number.EPSILON).toFixed(2)
+ )
+
+ setCart(current => {
+ const existing = current.find(item => item.key === key)
+ if (existing) {
+ return current.map(item =>
+ item.key === key
+ ? {
+ ...item,
+ quantity: item.quantity + 1,
+ lineTotal: Number(
+ ((item.quantity + 1) * item.unitPrice).toFixed(2)
+ )
+ }
+ : item
+ )
+ }
+
+ return [
+ ...current,
+ {
+ key,
+ menuItemId: product.id,
+ variantId: selectedVariant.id,
+ quantity: 1,
+ productName: product.name,
+ variantName: selectedVariant.name,
+ unitPrice: selectedVariant.price,
+ lineTotal,
+ image: product.image,
+ currency: product.currency
+ }
+ ]
+ })
+
+ setCartScrollSignal(signal => signal + 1)
+ }
+
+ const handleProductSelect = (product: SalesCatalogProduct) => {
+ if (product.variantCount === 0) {
+ toast.error("Este producto no tiene variantes disponibles")
+ return
+ }
+
+ if (product.variantCount === 1) {
+ addProduct(product)
+ return
+ }
+
+ setSelectedProduct(product)
+ }
+
+ const updateQuantity = (key: string, nextQuantity: number) => {
+ setCart(current =>
+ current
+ .map(item =>
+ item.key === key
+ ? {
+ ...item,
+ quantity: nextQuantity,
+ lineTotal: Number((nextQuantity * item.unitPrice).toFixed(2))
+ }
+ : item
+ )
+ .filter(item => item.quantity > 0)
+ )
+ }
+
+ const handleCompleteSale = () => {
+ if (cart.length === 0) {
+ toast.error("Agrega al menos un producto")
+ return
+ }
+
+ execute({
+ orderType,
+ items: cart.map(item => ({
+ menuItemId: item.menuItemId,
+ variantId: item.variantId,
+ quantity: item.quantity
+ }))
+ })
+ }
+
+ const clearCart = () => {
+ setCart([])
+ setSelectedProduct(null)
+ }
+
+ return (
+ <>
+
+
+
+
+
+ setSearch(event.target.value)}
+ placeholder="Buscar producto"
+ inputMode="search"
+ enterKeyHint="search"
+ className="h-11 text-base"
+ />
+
+
+
+
+
+
+ {filteredProducts.length} productos
+
+ {cart.length} líneas
+
+
+
+
+
+
+
+
+
+
+
+
+ Venta actual
+
+
+ Limpiar
+
+
+
+
+
+
+
+
+ guardSaleCompletion(handleCompleteSale)}
+ >
+ {status === "executing" ? (
+
+ ) : (
+
+ )}
+
+ {status === "executing"
+ ? "Completando..."
+ : "Completar venta"}
+
+
+
+
+
+
+
+ {
+ if (!open) setSelectedProduct(null)
+ }}
+ >
+
+
+
+ {selectedProduct?.name}
+
+
+ Elige una variante para agregarla a la venta
+
+
+
+ {selectedProduct && (
+
+
+
+
+ {selectedProduct.name}
+
+
+ {selectedProduct.categoryName ?? "Sin categoría"}
+
+
+
+ {selectedProduct.variantCount} opciones
+
+
+
+
+ {selectedProduct.variants.map(variant => (
+ {
+ addProduct(selectedProduct, variant.id)
+ setSelectedProduct(null)
+ }}
+ >
+
+
+ {variant.name}
+
+ {variant.description ? (
+
+ {variant.description}
+
+ ) : (
+
+ Agregar variante
+
+ )}
+
+
+ {formatPrice(variant.price, selectedProduct.currency)}
+
+
+ ))}
+
+
+ )}
+
+
+
+ {upgradeDialog}
+ >
+ )
+}
+
+function CategoryFilter({
+ options,
+ value,
+ onValueChange
+}: {
+ options: Array<{ value: string; label: string }>
+ value: string
+ onValueChange: (value: string) => void
+}) {
+ return (
+
+
Categorías
+
{
+ if (next) onValueChange(next)
+ }}
+ className="flex w-full flex-wrap justify-start gap-2"
+ variant="outline"
+ size="sm"
+ >
+ {options.map(option => (
+
+ {option.label}
+
+ ))}
+
+
+ )
+}
+
+function ProductGrid({
+ products,
+ cartProductIds,
+ onSelect
+}: {
+ products: SalesCatalogProduct[]
+ cartProductIds: Set
+ onSelect: (product: SalesCatalogProduct) => void
+}) {
+ if (products.length === 0) {
+ return (
+
+
+
+
+
+
+
+ No hay productos
+
+ Ajusta el filtro o la búsqueda para encontrar productos
+ disponibles.
+
+
+
+
+
+ )
+ }
+
+ return (
+
+ {products.map(product => (
+
+ ))}
+
+ )
+}
+
+function ProductCard({
+ product,
+ isInCart,
+ onSelect
+}: {
+ product: SalesCatalogProduct
+ isInCart: boolean
+ onSelect: (product: SalesCatalogProduct) => void
+}) {
+ return (
+ onSelect(product)}
+ className={cn(
+ "group block w-full cursor-pointer text-left focus-visible:outline-none"
+ )}
+ >
+
+
+ {product.image ? (
+
+ ) : (
+
+
+ {getProductAbbreviation(product.name)}
+
+
+ )}
+ {product.variantCount > 1 && (
+
+
+ {product.variantCount} opciones
+
+
+ )}
+
+
+
+
+ {product.name}
+
+
+ {product.priceLabel}
+
+
+
+
+ )
+}
+
+function OrderTypeSelector({
+ value,
+ onValueChange
+}: {
+ value: SalesOrderType
+ onValueChange: (value: SalesOrderType) => void
+}) {
+ return (
+
+
Tipo de orden
+
{
+ if (next) onValueChange(next as SalesOrderType)
+ }}
+ className="flex gap-1.5"
+ variant="outline"
+ size="sm"
+ >
+ {salesOrderTypeOptions.map(option => (
+
+ {option.label}
+
+ ))}
+
+
+ )
+}
+
+function SaleCart({
+ cart,
+ currency,
+ scrollToBottomSignal,
+ onQuantityChange
+}: {
+ cart: CartLine[]
+ currency: "MXN" | "USD"
+ scrollToBottomSignal: number
+ onQuantityChange: (key: string, nextQuantity: number) => void
+}) {
+ const scrollContainerRef = useRef(null)
+ const scrollResetTimeoutRef = useRef(null)
+
+ useLayoutEffect(() => {
+ const scrollContainer = scrollContainerRef.current
+ if (!scrollContainer) return
+
+ if (scrollContainer.scrollHeight <= scrollContainer.clientHeight) return
+
+ const scrollToBottom = () => {
+ scrollContainer.scrollTop = scrollContainer.scrollHeight
+ }
+
+ scrollToBottom()
+
+ if (scrollResetTimeoutRef.current !== null) {
+ window.clearTimeout(scrollResetTimeoutRef.current)
+ }
+
+ scrollResetTimeoutRef.current = window.setTimeout(scrollToBottom, 220)
+
+ return () => {
+ if (scrollResetTimeoutRef.current === null) return
+
+ window.clearTimeout(scrollResetTimeoutRef.current)
+ scrollResetTimeoutRef.current = null
+ }
+ }, [scrollToBottomSignal])
+
+ if (cart.length === 0) {
+ return (
+
+
+
+
+
+
+ Sin productos
+
+ Toca un producto para agregarlo a la venta.
+
+
+
+
+ )
+ }
+
+ return (
+
+
+ {cart.map(item => (
+
+
+
+
+ {item.productName}
+
+ {item.variantName && (
+
+ {item.variantName}
+
+ )}
+
+
+
+
{
+ const nextQuantity = details.valueAsNumber
+
+ if (!Number.isFinite(nextQuantity)) return
+
+ onQuantityChange(item.key, nextQuantity)
+ }}
+ className="w-40 shrink-0"
+ >
+
+
+
+
+
+
+
+ {formatPrice(item.lineTotal, currency)}
+
+
+
+
+ ))}
+
+
+ )
+}
+
+function SaleSummary({
+ subtotal,
+ total,
+ currency,
+ itemCount
+}: {
+ subtotal: number
+ total: number
+ currency: "MXN" | "USD"
+ itemCount: number
+}) {
+ return (
+
+
+ Subtotal
+ {formatPrice(subtotal, currency)}
+
+
+ Productos
+ {itemCount}
+
+
+
+
+
+ {formatPrice(total, currency)}
+
+
+
+ )
+}
diff --git a/src/components/sales/sales-best-sellers-pie-chart.tsx b/src/components/sales/sales-best-sellers-pie-chart.tsx
new file mode 100644
index 00000000..88ca9279
--- /dev/null
+++ b/src/components/sales/sales-best-sellers-pie-chart.tsx
@@ -0,0 +1,413 @@
+"use client"
+
+import { useMemo, useState } from "react"
+import NumberFlow from "@number-flow/react"
+
+import { PieChart } from "@/components/charts/pie-chart"
+import { pieCssVars } from "@/components/charts/pie-context"
+import { PieSlice } from "@/components/charts/pie-slice"
+import {
+ Item,
+ ItemActions,
+ ItemContent,
+ ItemDescription,
+ ItemGroup,
+ ItemMedia,
+ ItemTitle
+} from "@/components/ui/item"
+import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
+import { formatPrice, type Currency } from "@/lib/currency"
+import type { SalesBestSeller } from "@/lib/types/sales"
+import { cn } from "@/lib/utils"
+
+const bestSellerMetricOptions = [
+ {
+ value: "quantity",
+ label: "Unidades"
+ },
+ {
+ value: "revenue",
+ label: "Ingresos"
+ }
+] as const
+
+const bestSellerSegmentColors = [
+ pieCssVars.slice1,
+ pieCssVars.slice2,
+ pieCssVars.slice3,
+ pieCssVars.slice4,
+ pieCssVars.slice5,
+ `color-mix(in oklab, ${pieCssVars.slice1} 76%, ${pieCssVars.background})`,
+ `color-mix(in oklab, ${pieCssVars.slice2} 76%, ${pieCssVars.background})`,
+ `color-mix(in oklab, ${pieCssVars.slice3} 76%, ${pieCssVars.background})`,
+ `color-mix(in oklab, ${pieCssVars.slice4} 76%, ${pieCssVars.background})`,
+ `color-mix(in oklab, ${pieCssVars.slice5} 76%, ${pieCssVars.background})`
+] as const
+
+type BestSellerMetric = (typeof bestSellerMetricOptions)[number]["value"]
+
+type BestSellerChartDatum = SalesBestSeller & {
+ color: string
+ label: string
+ value: number
+ share: number
+}
+
+type BestSellerRankItem = SalesBestSeller & {
+ color: string
+ label: string
+ value: number
+}
+
+type SalesBestSellersPieChartProps = {
+ bestSellers: SalesBestSeller[]
+ currency: Currency
+}
+
+function formatMetricValue({
+ value,
+ metric,
+ currency,
+ locales,
+ withCurrencyCode = true
+}: {
+ value: number
+ metric: BestSellerMetric
+ currency: Currency
+ locales: string
+ withCurrencyCode?: boolean
+}) {
+ if (metric === "revenue") {
+ if (withCurrencyCode) {
+ return formatPrice(value, currency)
+ }
+
+ return new Intl.NumberFormat(locales, {
+ style: "currency",
+ currency,
+ currencyDisplay: "symbol",
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 2
+ }).format(value)
+ }
+
+ return `${new Intl.NumberFormat(locales, {
+ maximumFractionDigits: 0
+ }).format(value)} unidades`
+}
+
+function formatMetricCompactValue({
+ value,
+ metric,
+ currency,
+ locales
+}: {
+ value: number
+ metric: BestSellerMetric
+ currency: Currency
+ locales: string
+}) {
+ if (metric === "revenue") {
+ return new Intl.NumberFormat(locales, {
+ style: "currency",
+ currency,
+ currencyDisplay: "symbol",
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 2
+ }).format(value)
+ }
+
+ return new Intl.NumberFormat(locales, {
+ maximumFractionDigits: 0
+ }).format(value)
+}
+
+function formatShare(share: number) {
+ return new Intl.NumberFormat("es-MX", {
+ style: "percent",
+ minimumFractionDigits: share < 0.1 ? 1 : 0,
+ maximumFractionDigits: 1
+ }).format(share)
+}
+
+function compareBestSellerSegments(
+ a: BestSellerRankItem,
+ b: BestSellerRankItem,
+ metric: BestSellerMetric
+) {
+ if (metric === "quantity") {
+ if (b.quantity !== a.quantity) return b.quantity - a.quantity
+ if (b.revenue !== a.revenue) return b.revenue - a.revenue
+ } else {
+ if (b.revenue !== a.revenue) return b.revenue - a.revenue
+ if (b.quantity !== a.quantity) return b.quantity - a.quantity
+ }
+
+ return a.productName.localeCompare(b.productName)
+}
+
+export function SalesBestSellersPieChart({
+ bestSellers,
+ currency
+}: SalesBestSellersPieChartProps) {
+ const [metric, setMetric] = useState("quantity")
+ const [hoveredIndex, setHoveredIndex] = useState(null)
+
+ const locales = currency === "MXN" ? "es-MX" : "en-US"
+
+ const chartState = useMemo(() => {
+ const rankedItems: BestSellerRankItem[] = bestSellers
+ .slice(0, 10)
+ .map((item, index) => ({
+ ...item,
+ color: bestSellerSegmentColors[index] ?? bestSellerSegmentColors[0],
+ label: item.productName,
+ value: metric === "quantity" ? item.quantity : item.revenue
+ }))
+ rankedItems.sort((a, b) => compareBestSellerSegments(a, b, metric))
+
+ const totalValue = rankedItems.reduce((sum, item) => sum + item.value, 0)
+ const averageValue =
+ rankedItems.length > 0 ? totalValue / rankedItems.length : 0
+
+ const rankedSegments: BestSellerChartDatum[] = rankedItems.map(item => ({
+ ...item,
+ share: totalValue > 0 ? item.value / totalValue : 0
+ }))
+
+ const leadingSegment = rankedSegments[0] ?? null
+
+ return {
+ averageValue,
+ totalValue,
+ leadingSegment,
+ segments: rankedSegments
+ }
+ }, [bestSellers, metric])
+
+ const activeMetricLabel = metric === "quantity" ? "Unidades" : "Ingresos"
+ const activeSegment =
+ hoveredIndex === null ? null : (chartState.segments[hoveredIndex] ?? null)
+ const centerValue = activeSegment?.value ?? chartState.totalValue
+ const centerTitle = activeSegment?.productName ?? activeMetricLabel
+ const centerMeta = activeSegment
+ ? metric === "revenue"
+ ? `${activeSegment.quantity} unidades · ${formatShare(activeSegment.share)}`
+ : `${formatPrice(activeSegment.revenue, currency)} · ${formatShare(
+ activeSegment.share
+ )}`
+ : `Promedio ${formatMetricCompactValue({
+ value: chartState.averageValue,
+ metric,
+ currency,
+ locales
+ })}${metric === "quantity" ? " u." : ""}`
+
+ return (
+
+
+
+
+
+ Productos más vendidos
+
+
+
+
{
+ if (value === "quantity" || value === "revenue") {
+ setMetric(value)
+ setHoveredIndex(null)
+ }
+ }}
+ aria-label="Cambiar métrica del gráfico de productos más vendidos"
+ className="bg-muted grid w-full grid-cols-2 gap-1 rounded-lg p-[3px]
+ sm:inline-flex sm:w-auto sm:flex-nowrap"
+ variant="default"
+ size="sm"
+ >
+ {bestSellerMetricOptions.map(option => (
+
+ {option.label}
+
+ ))}
+
+
+
+
+
+
+
+
+ {chartState.segments.map((segment, index) => (
+
+ ))}
+
+
+
+
+ {centerTitle}
+
+
+
+ {centerMeta}
+
+
+
+
+
+
+ Basado en los 10 productos del ranking
+
+ {chartState.leadingSegment && (
+
+ Lidera {chartState.leadingSegment.productName} ·{" "}
+ {formatShare(chartState.leadingSegment.share)}
+
+ )}
+
+
+
+
+ {chartState.segments.map((segment, index) => {
+ const isActive = hoveredIndex === index
+ const isMuted = hoveredIndex !== null && hoveredIndex !== index
+ const secondaryValue =
+ metric === "revenue"
+ ? `${segment.quantity} unidades`
+ : formatPrice(segment.revenue, currency)
+
+ return (
+ - setHoveredIndex(index)}
+ onMouseLeave={() => setHoveredIndex(null)}
+ className={cn(
+ "px-3 py-2 transition",
+ isActive &&
+ `border-foreground/15 bg-background
+ shadow-[0_18px_30px_rgba(0,0,0,0.08)]`,
+ isMuted && "opacity-55"
+ )}
+ >
+
+
+
+
+
+ {segment.productName}
+
+
+
+ {formatMetricValue({
+ value: segment.value,
+ metric,
+ currency,
+ locales
+ })}
+
+ •
+ {secondaryValue}
+ •
+ {formatShare(segment.share)}
+
+
+
+
+ #{index + 1}
+
+
+
+ )
+ })}
+
+
+
+
+ )
+}
diff --git a/src/components/sales/sales-closing-date-filter.tsx b/src/components/sales/sales-closing-date-filter.tsx
new file mode 100644
index 00000000..f1ca87e1
--- /dev/null
+++ b/src/components/sales/sales-closing-date-filter.tsx
@@ -0,0 +1,100 @@
+"use client"
+
+import * as React from "react"
+import { Calendar as CalendarIcon } from "lucide-react"
+import { parseAsString, useQueryState } from "nuqs"
+
+import { Button } from "@/components/ui/button"
+import { Calendar } from "@/components/ui/calendar"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger
+} from "@/components/ui/popover"
+import {
+ formatSalesClosingDateLabel,
+ formatSalesClosingDateValue,
+ parseSalesClosingDateValue,
+ resolveSalesClosingDateValue
+} from "@/lib/sales-closing-date"
+import { cn } from "@/lib/utils"
+
+export function SalesClosingDateFilter({
+ className,
+ label = "Fecha",
+ selectedDateValue
+}: {
+ className?: string
+ label?: string
+ selectedDateValue: string
+}) {
+ const queryState = React.useMemo(
+ () =>
+ parseAsString.withDefault(selectedDateValue).withOptions({
+ shallow: false,
+ scroll: false
+ }),
+ [selectedDateValue]
+ )
+ const [dateValue, setDateValue] = useQueryState("date", queryState)
+
+ const resolvedDateValue = React.useMemo(
+ () => resolveSalesClosingDateValue(dateValue, selectedDateValue),
+ [dateValue, selectedDateValue]
+ )
+
+ const selectedDate = React.useMemo(
+ () => parseSalesClosingDateValue(resolvedDateValue),
+ [resolvedDateValue]
+ )
+
+ const [open, setOpen] = React.useState(false)
+
+ React.useEffect(() => {
+ if (dateValue !== resolvedDateValue) {
+ void setDateValue(resolvedDateValue)
+ }
+ }, [dateValue, resolvedDateValue, setDateValue])
+
+ return (
+
+
+
+
+
+ {selectedDate
+ ? formatSalesClosingDateLabel(resolvedDateValue)
+ : "Seleccionar fecha"}
+
+
+
+
+ date > new Date()}
+ onSelect={date => {
+ if (!date) return
+
+ const nextValue = formatSalesClosingDateValue(date)
+ void setDateValue(nextValue)
+ setOpen(false)
+ }}
+ />
+
+
+ )
+}
diff --git a/src/components/sales/sales-closing-export.tsx b/src/components/sales/sales-closing-export.tsx
new file mode 100644
index 00000000..d305b8c2
--- /dev/null
+++ b/src/components/sales/sales-closing-export.tsx
@@ -0,0 +1,234 @@
+"use client"
+
+import { Download } from "lucide-react"
+import Papa from "papaparse"
+
+import { Button } from "@/components/ui/button"
+import { salesOrderTypeLabels, type SalesClosingData } from "@/lib/types/sales"
+import { cn } from "@/lib/utils"
+
+type ClosingCsvRow = {
+ section: string
+ label: string
+ detail: string
+ orderType: string
+ quantity: number | ""
+ orders: number | ""
+ revenue: number | ""
+ currency: string
+ hour: string
+ previousOrders: number | ""
+ previousRevenue: number | ""
+ createdAt: string
+}
+
+const closingCsvFields: (keyof ClosingCsvRow)[] = [
+ "section",
+ "label",
+ "detail",
+ "orderType",
+ "quantity",
+ "orders",
+ "revenue",
+ "currency",
+ "hour",
+ "previousOrders",
+ "previousRevenue",
+ "createdAt"
+]
+
+function buildRows(data: SalesClosingData): ClosingCsvRow[] {
+ const rows: ClosingCsvRow[] = [
+ {
+ section: "summary",
+ label: "Ingresos del día",
+ detail: "",
+ orderType: "",
+ quantity: "",
+ orders: "",
+ revenue: data.todayRevenue,
+ currency: data.currency,
+ hour: "",
+ previousOrders: "",
+ previousRevenue: "",
+ createdAt: ""
+ },
+ {
+ section: "summary",
+ label: "Órdenes del día",
+ detail: "",
+ orderType: "",
+ quantity: "",
+ orders: data.todayOrders,
+ revenue: "",
+ currency: data.currency,
+ hour: "",
+ previousOrders: "",
+ previousRevenue: "",
+ createdAt: ""
+ },
+ {
+ section: "summary",
+ label: "Ticket promedio",
+ detail: "",
+ orderType: "",
+ quantity: "",
+ orders: "",
+ revenue: data.todayAverageTicket,
+ currency: data.currency,
+ hour: "",
+ previousOrders: "",
+ previousRevenue: "",
+ createdAt: ""
+ },
+ {
+ section: "summary",
+ label: "Producto más vendido",
+ detail: data.topProduct?.productName ?? "",
+ orderType: "",
+ quantity: data.topProduct?.quantity ?? "",
+ orders: "",
+ revenue: data.topProduct?.revenue ?? "",
+ currency: data.currency,
+ hour: "",
+ previousOrders: "",
+ previousRevenue: "",
+ createdAt: ""
+ },
+ {
+ section: "previous_day",
+ label: "Ingresos del día anterior",
+ detail: data.previousDateValue,
+ orderType: "",
+ quantity: "",
+ orders: "",
+ revenue: data.previous.revenue,
+ currency: data.currency,
+ hour: "",
+ previousOrders: "",
+ previousRevenue: "",
+ createdAt: ""
+ },
+ {
+ section: "previous_day",
+ label: "Órdenes del día anterior",
+ detail: data.previousDateValue,
+ orderType: "",
+ quantity: "",
+ orders: data.previous.orders,
+ revenue: "",
+ currency: data.currency,
+ hour: "",
+ previousOrders: "",
+ previousRevenue: "",
+ createdAt: ""
+ },
+ {
+ section: "previous_day",
+ label: "Ticket promedio del día anterior",
+ detail: data.previousDateValue,
+ orderType: "",
+ quantity: "",
+ orders: "",
+ revenue: data.previous.averageTicket,
+ currency: data.currency,
+ hour: "",
+ previousOrders: "",
+ previousRevenue: "",
+ createdAt: ""
+ },
+ ...data.hourly.map(bucket => ({
+ section: "hourly",
+ label: bucket.label,
+ detail: "",
+ orderType: "",
+ quantity: "",
+ orders: bucket.todayOrders,
+ revenue: bucket.todayRevenue,
+ currency: data.currency,
+ hour: String(bucket.hour),
+ previousOrders: bucket.previousOrders,
+ previousRevenue: bucket.previousRevenue,
+ createdAt: ""
+ })),
+ ...data.bestSellers.map(item => ({
+ section: "best_sellers",
+ label: item.productName,
+ detail: "",
+ orderType: "",
+ quantity: item.quantity,
+ orders: "",
+ revenue: item.revenue,
+ currency: data.currency,
+ hour: "",
+ previousOrders: "",
+ previousRevenue: "",
+ createdAt: ""
+ })),
+ ...data.revenueByOrderType.map(item => ({
+ section: "order_type",
+ label: salesOrderTypeLabels[item.orderType],
+ detail: "",
+ orderType: item.orderType,
+ quantity: "",
+ orders: item.orders,
+ revenue: item.revenue,
+ currency: data.currency,
+ hour: "",
+ previousOrders: "",
+ previousRevenue: "",
+ createdAt: ""
+ })),
+ ...data.recentSales.map(sale => ({
+ section: "recent_sales",
+ label: salesOrderTypeLabels[sale.orderType],
+ detail: "",
+ orderType: sale.orderType,
+ quantity: sale.items,
+ orders: "",
+ revenue: sale.total,
+ currency: data.currency,
+ hour: "",
+ previousOrders: "",
+ previousRevenue: "",
+ createdAt: sale.createdAt
+ }))
+ ]
+
+ return rows
+}
+
+function downloadCsv(rows: ClosingCsvRow[], fileDateValue: string) {
+ const csv = Papa.unparse(rows, { columns: closingCsvFields })
+ const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" })
+ const url = URL.createObjectURL(blob)
+ const link = document.createElement("a")
+ link.href = url
+ link.download = `cierre-ventas-${fileDateValue}.csv`
+ document.body.appendChild(link)
+ link.click()
+ document.body.removeChild(link)
+ URL.revokeObjectURL(url)
+}
+
+export function SalesClosingExportButton({
+ className,
+ data
+}: {
+ className?: string
+ data: SalesClosingData
+}) {
+ const fileDateValue = data.selectedDateValue
+
+ return (
+ downloadCsv(buildRows(data), fileDateValue)}
+ >
+
+ Exportar CSV
+
+ )
+}
diff --git a/src/components/sales/sales-closing-hourly-chart.tsx b/src/components/sales/sales-closing-hourly-chart.tsx
new file mode 100644
index 00000000..e2d8bfdb
--- /dev/null
+++ b/src/components/sales/sales-closing-hourly-chart.tsx
@@ -0,0 +1,117 @@
+"use client"
+
+import { Clock } from "lucide-react"
+
+import { Bar } from "@/components/charts/bar"
+import { BarChart } from "@/components/charts/bar-chart"
+import { BarXAxis } from "@/components/charts/bar-x-axis"
+import { chartCssVars, useYScale } from "@/components/charts/chart-context"
+import { Grid } from "@/components/charts/grid"
+import { ChartTooltip } from "@/components/charts/tooltip/chart-tooltip"
+import {
+ Empty,
+ EmptyDescription,
+ EmptyHeader,
+ EmptyMedia,
+ EmptyTitle
+} from "@/components/ui/empty"
+import { formatPrice, type Currency } from "@/lib/currency"
+import type { SalesClosingHourlyBucket } from "@/lib/types/sales"
+
+function SalesClosingHourlyYAxis() {
+ const yScale = useYScale()
+
+ const ticks = yScale.ticks?.(3) ?? []
+
+ if (ticks.length === 0) {
+ return null
+ }
+
+ return (
+
+ {ticks.map(tick => (
+
+ {tick}
+
+ ))}
+
+ )
+}
+
+export function SalesClosingHourlyChart({
+ hourly,
+ currency
+}: {
+ hourly: SalesClosingHourlyBucket[]
+ currency: Currency
+}) {
+ const hasSales = hourly.some(bucket => bucket.todayOrders > 0)
+
+ if (!hasSales) {
+ return (
+
+
+
+
+
+ Sin ventas por hora
+
+ Cuando haya ventas verás las horas con más actividad.
+
+
+
+ )
+ }
+
+ return (
+
+
+
+
+
+
+ {
+ const label = String(point.label ?? "")
+ const todayOrders =
+ typeof point.todayOrders === "number" ? point.todayOrders : 0
+ const todayRevenue =
+ typeof point.todayRevenue === "number" ? point.todayRevenue : 0
+
+ return (
+
+
{label}
+
+
+ Hoy: {todayOrders} ventas ·{" "}
+ {formatPrice(todayRevenue, currency)}
+
+
+
+ )
+ }}
+ />
+
+
+ )
+}
diff --git a/src/components/sales/sales-closing.tsx b/src/components/sales/sales-closing.tsx
new file mode 100644
index 00000000..d55b62d6
--- /dev/null
+++ b/src/components/sales/sales-closing.tsx
@@ -0,0 +1,431 @@
+import { Fragment } from "react"
+import NumberFlow, { type Format } from "@number-flow/react"
+import {
+ Banknote,
+ ShoppingCart,
+ TrendingDown,
+ TrendingUp,
+ Trophy,
+ WalletCards
+} from "lucide-react"
+
+import { SalesClosingHourlyChart } from "@/components/sales/sales-closing-hourly-chart"
+import { Badge } from "@/components/ui/badge"
+import {
+ Empty,
+ EmptyDescription,
+ EmptyHeader,
+ EmptyMedia,
+ EmptyTitle
+} from "@/components/ui/empty"
+import {
+ Item,
+ ItemActions,
+ ItemContent,
+ ItemGroup,
+ ItemMedia,
+ ItemSeparator,
+ ItemTitle
+} from "@/components/ui/item"
+import { Separator } from "@/components/ui/separator"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow
+} from "@/components/ui/table"
+import { formatPrice } from "@/lib/currency"
+import { formatSalesClosingDateLongLabel } from "@/lib/sales-closing-date"
+import {
+ salesOrderTypeBadgeVariants,
+ salesOrderTypeLabels,
+ type SalesClosingData
+} from "@/lib/types/sales"
+import { cn } from "@/lib/utils"
+
+const closingTrendPercentFormatter = new Intl.NumberFormat("es-MX", {
+ style: "percent",
+ signDisplay: "exceptZero",
+ maximumFractionDigits: 0
+})
+
+type ClosingTrend = {
+ label: string
+ tone: string
+ icon: typeof TrendingUp
+}
+
+function getClosingTrend(
+ currentValue: number,
+ previousValue: number
+): ClosingTrend | null {
+ if (previousValue <= 0) {
+ if (currentValue <= 0) return null
+ return {
+ label: "Nuevo vs. ayer",
+ tone: "text-green-600 dark:text-green-400",
+ icon: TrendingUp
+ }
+ }
+
+ const change = (currentValue - previousValue) / previousValue
+
+ if (Math.abs(change) < 0.005) {
+ return null
+ }
+
+ const label = `${closingTrendPercentFormatter.format(change)} vs. ayer`
+
+ return change > 0
+ ? { label, tone: "text-green-600 dark:text-green-400", icon: TrendingUp }
+ : { label, tone: "text-muted-foreground", icon: TrendingDown }
+}
+
+function formatClosingTime(value: string) {
+ return new Intl.DateTimeFormat("es-MX", {
+ timeStyle: "short"
+ }).format(new Date(value))
+}
+
+type SalesClosingSummaryItem = {
+ title: string
+ icon: typeof Banknote
+ trend?: ClosingTrend | null
+} & (
+ | { kind: "currency"; value: number }
+ | { kind: "count"; value: number }
+ | { kind: "text"; value: string; meta?: string }
+)
+
+function getSummaryItems(data: SalesClosingData): SalesClosingSummaryItem[] {
+ return [
+ {
+ title: "Ingresos del día",
+ kind: "currency",
+ value: data.todayRevenue,
+ icon: Banknote,
+ trend: getClosingTrend(data.todayRevenue, data.previous.revenue)
+ },
+ {
+ title: "Órdenes del día",
+ kind: "count",
+ value: data.todayOrders,
+ icon: ShoppingCart,
+ trend: getClosingTrend(data.todayOrders, data.previous.orders)
+ },
+ {
+ title: "Ticket promedio",
+ kind: "currency",
+ value: data.todayAverageTicket,
+ icon: WalletCards,
+ trend: getClosingTrend(
+ data.todayAverageTicket,
+ data.previous.averageTicket
+ )
+ },
+ {
+ title: "Producto más vendido",
+ kind: "text",
+ value: data.topProduct?.productName ?? "Sin ventas",
+ icon: Trophy,
+ meta: data.topProduct
+ ? `${data.topProduct.quantity} unidades · ${formatPrice(
+ data.topProduct.revenue,
+ data.currency
+ )}`
+ : undefined
+ }
+ ]
+}
+
+export function SalesClosingReport({ data }: { data: SalesClosingData }) {
+ const selectedDateLabel =
+ formatSalesClosingDateLongLabel(data.selectedDateValue) ||
+ "la fecha seleccionada"
+ const summaryItems = getSummaryItems(data)
+ const currencyLocale = data.currency === "MXN" ? "es-MX" : "en-US"
+ const currencyFormat: Format = {
+ style: "currency",
+ currency: data.currency,
+ currencyDisplay: "symbol",
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 2
+ }
+
+ return (
+
+
+
+ {summaryItems.map((item, index) => (
+ -
+
+
+ {item.title}
+
+ {item.kind === "text" ? (
+
+ {item.value}
+
+ ) : (
+
+ )}
+ {item.trend && (
+
+
+ {item.trend.label}
+
+ )}
+ {item.kind === "text" && item.meta && !item.trend && (
+
+ {item.meta}
+
+ )}
+
+
+
+
+
+ ))}
+
+
+
+
+
+
+ Ventas por hora
+
+
+
+
+
+
+
+
+
+
+ Ingresos por tipo de orden
+
+ {data.revenueByOrderType.length}
+
+
+
+
+
+
+ Tipo de orden
+ Órdenes
+
+ Ingresos
+
+
+
+
+ {data.revenueByOrderType.map(item => (
+
+
+
+ {salesOrderTypeLabels[item.orderType]}
+
+
+
+ {item.orders}
+
+
+ {formatPrice(item.revenue, data.currency)}
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+ Productos más vendidos
+
+ {data.bestSellers.length}
+
+
+ {data.bestSellers.length === 0 ? (
+
+
+
+
+
+ Sin ventas para este día
+
+ No hay productos vendidos para el cierre de{" "}
+ {selectedDateLabel}.
+
+
+
+ ) : (
+
+ {data.bestSellers.map((item, index) => (
+
+ -
+
+
+ #{index + 1}
+
+
+
+
+ {item.productName}
+
+
+
+
+ {formatPrice(item.revenue, data.currency)}
+
+
+ {item.quantity} unidades
+
+
+
+ {index < data.bestSellers.length - 1 && }
+
+ ))}
+
+ )}
+
+
+
+
+
+
+ Ventas recientes
+
+ {data.recentSales.length}
+
+
+ {data.recentSales.length === 0 ? (
+
+
+
+
+
+ Sin ventas registradas
+
+ Aún no hay ventas registradas para el cierre de{" "}
+ {selectedDateLabel}.
+
+
+
+ ) : (
+
+
+
+
+ Hora
+ Canal de venta
+
+ Unidades
+
+ Total
+
+
+
+ {data.recentSales.map(sale => (
+
+
+ {formatClosingTime(sale.createdAt)}
+
+
+
+ {salesOrderTypeLabels[sale.orderType]}
+
+
+
+ {sale.items}
+
+
+ {formatPrice(sale.total, data.currency)}
+
+
+ ))}
+
+
+
+ )}
+
+
+ )
+}
diff --git a/src/components/sales/sales-dashboard-period-filter.tsx b/src/components/sales/sales-dashboard-period-filter.tsx
new file mode 100644
index 00000000..b49df5ba
--- /dev/null
+++ b/src/components/sales/sales-dashboard-period-filter.tsx
@@ -0,0 +1,82 @@
+"use client"
+
+import { parseAsStringEnum, useQueryState } from "nuqs"
+
+import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
+import {
+ defaultSalesDashboardPeriod,
+ isSalesDashboardPeriod,
+ salesDashboardPeriodOptions,
+ salesDashboardPeriodValues
+} from "@/lib/sales-dashboard-period"
+import { cn } from "@/lib/utils"
+
+const salesDashboardPeriodQueryState = parseAsStringEnum([
+ ...salesDashboardPeriodValues
+])
+ .withDefault(defaultSalesDashboardPeriod)
+ .withOptions({
+ shallow: false,
+ scroll: false
+ })
+
+export function SalesDashboardPeriodFilter({
+ className,
+ inline = false,
+ label = "Rango de tiempo"
+}: {
+ className?: string
+ inline?: boolean
+ label?: string
+}) {
+ const [period, setPeriod] = useQueryState(
+ "period",
+ salesDashboardPeriodQueryState
+ )
+
+ return (
+
+
+ {label}
+
+
{
+ if (isSalesDashboardPeriod(value)) {
+ void setPeriod(value)
+ }
+ }}
+ className="bg-muted grid w-full grid-cols-2 gap-1 rounded-lg p-[3px]
+ sm:inline-flex sm:w-auto sm:flex-nowrap"
+ variant="default"
+ size="sm"
+ >
+ {salesDashboardPeriodOptions.map(option => (
+
+ {option.label}
+
+ ))}
+
+
+ )
+}
diff --git a/src/components/sales/sales-dashboard.tsx b/src/components/sales/sales-dashboard.tsx
new file mode 100644
index 00000000..97dec256
--- /dev/null
+++ b/src/components/sales/sales-dashboard.tsx
@@ -0,0 +1,288 @@
+import NumberFlow from "@number-flow/react"
+import { Banknote, ShoppingCart, TrendingUp, WalletCards } from "lucide-react"
+
+import { SalesBestSellersPieChart } from "@/components/sales/sales-best-sellers-pie-chart"
+import { SalesRevenueChart } from "@/components/sales/sales-revenue-chart"
+import { Badge } from "@/components/ui/badge"
+import {
+ Empty,
+ EmptyDescription,
+ EmptyHeader,
+ EmptyMedia,
+ EmptyTitle
+} from "@/components/ui/empty"
+import {
+ Item,
+ ItemContent,
+ ItemGroup,
+ ItemMedia,
+ ItemTitle
+} from "@/components/ui/item"
+import { Separator } from "@/components/ui/separator"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow
+} from "@/components/ui/table"
+import { formatPrice } from "@/lib/currency"
+import { salesDashboardPeriodRangeLabels } from "@/lib/sales-dashboard-period"
+import {
+ salesOrderTypeBadgeVariants,
+ salesOrderTypeLabels,
+ type SalesDashboardData
+} from "@/lib/types/sales"
+import { cn } from "@/lib/utils"
+
+function formatDateTime(value: string) {
+ return new Intl.DateTimeFormat("es-MX", {
+ dateStyle: "medium",
+ timeStyle: "short"
+ }).format(new Date(value))
+}
+
+type SalesDashboardKpiItem = {
+ title: string
+ value: number
+ icon: typeof Banknote
+ format?: Intl.NumberFormatOptions
+ locales: string
+ suffix?: string
+}
+
+function getKpiItems(data: SalesDashboardData) {
+ const locales = data.currency === "MXN" ? "es-MX" : "en-US"
+
+ return [
+ {
+ title: "Ingresos de hoy",
+ value: data.todayRevenue,
+ icon: Banknote,
+ locales,
+ format: {
+ style: "currency",
+ currency: data.currency,
+ currencyDisplay: "symbol",
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 2
+ },
+ suffix: ` ${data.currency}`
+ },
+ {
+ title: "Órdenes de hoy",
+ value: data.todayOrders,
+ icon: ShoppingCart,
+ locales,
+ format: {
+ maximumFractionDigits: 0
+ }
+ },
+ {
+ title: "Ingresos acumulados",
+ value: data.periodRevenue,
+ icon: TrendingUp,
+ locales,
+ format: {
+ style: "currency",
+ currency: data.currency,
+ currencyDisplay: "symbol",
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 2
+ },
+ suffix: ` ${data.currency}`
+ },
+ {
+ title: "Ticket promedio",
+ value: data.periodAverageTicket,
+ icon: WalletCards,
+ locales,
+ format: {
+ style: "currency",
+ currency: data.currency,
+ currencyDisplay: "symbol",
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 2
+ },
+ suffix: ` ${data.currency}`
+ }
+ ] satisfies SalesDashboardKpiItem[]
+}
+
+export function SalesDashboard({ data }: { data: SalesDashboardData }) {
+ const kpiItems = getKpiItems(data)
+
+ return (
+
+
+
+ {kpiItems.map((item, index) => (
+ -
+
+
+ {item.title}
+
+
+
+
+
+
+
+ ))}
+
+
+
+
+
+
+ Ventas por periodo
+
+
+ {salesDashboardPeriodRangeLabels[data.period]}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Ventas recientes
+
+ {data.recentSales.length}
+
+ {/*
*/}
+ {data.recentSales.length === 0 ? (
+
+
+
+
+
+ Aún no hay ventas registradas
+
+ Registra la primera venta para verla aquí.
+
+
+
+ ) : (
+
+
+
+
+ Fecha
+ Canal de venta
+ Unidades
+ Total
+
+
+
+ {data.recentSales.map(sale => (
+
+
+ {formatDateTime(sale.createdAt)}
+
+
+
+ {salesOrderTypeLabels[sale.orderType]}
+
+
+
+ {sale.items}
+
+
+ {formatPrice(sale.total, data.currency)}
+
+
+ ))}
+
+
+
+ )}
+
+
+
+
+
+ {/*
+
+ Productos más vendidos
+
+ {data.bestSellers.length}
+
+
*/}
+ {data.bestSellers.length === 0 ? (
+
+
+
+
+
+ Aún no hay productos vendidos
+
+ Cuando haya ventas, aquí verás el ranking de productos.
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+
+ )
+}
diff --git a/src/components/sales/sales-pro-banner.tsx b/src/components/sales/sales-pro-banner.tsx
new file mode 100644
index 00000000..22725f7c
--- /dev/null
+++ b/src/components/sales/sales-pro-banner.tsx
@@ -0,0 +1,36 @@
+"use client"
+
+import { Crown } from "lucide-react"
+import Link from "next/link"
+
+import {
+ Banner,
+ BannerAction,
+ BannerClose,
+ BannerIcon,
+ BannerTitle
+} from "@/components/kibo-ui/banner"
+
+export function SalesProBanner() {
+ return (
+
+
+
+ Actualiza a Pro para acceder a todas las funciones de ventas.
+
+
+
+ Actualizar a Pro
+
+
+
+
+ )
+}
diff --git a/src/components/sales/sales-revenue-chart.tsx b/src/components/sales/sales-revenue-chart.tsx
new file mode 100644
index 00000000..b030dc82
--- /dev/null
+++ b/src/components/sales/sales-revenue-chart.tsx
@@ -0,0 +1,114 @@
+"use client"
+
+import { BarChart3 } from "lucide-react"
+
+import { Bar } from "@/components/charts/bar"
+import { BarChart } from "@/components/charts/bar-chart"
+import { BarXAxis } from "@/components/charts/bar-x-axis"
+import { chartCssVars, useYScale } from "@/components/charts/chart-context"
+import { Grid } from "@/components/charts/grid"
+import { ChartTooltip } from "@/components/charts/tooltip/chart-tooltip"
+import {
+ Empty,
+ EmptyDescription,
+ EmptyHeader,
+ EmptyMedia,
+ EmptyTitle
+} from "@/components/ui/empty"
+import { formatPrice, type Currency } from "@/lib/currency"
+import type { SalesDashboardPeriod } from "@/lib/sales-dashboard-period"
+import type { SalesChartBucket } from "@/lib/types/sales"
+
+function SalesRevenueYAxis({ currency }: { currency: Currency }) {
+ const yScale = useYScale()
+
+ const ticks = yScale.ticks?.(4) ?? []
+
+ if (ticks.length === 0) {
+ return null
+ }
+
+ return (
+
+ {ticks.map(tick => (
+
+ {formatPrice(tick, currency)}
+
+ ))}
+
+ )
+}
+
+export function SalesRevenueChart({
+ chart,
+ currency,
+ period
+}: {
+ chart: SalesChartBucket[]
+ currency: Currency
+ period: SalesDashboardPeriod
+}) {
+ const hasSales = chart.some(bucket => bucket.revenue > 0)
+
+ if (!hasSales) {
+ return (
+
+
+
+
+
+ Sin ventas en este periodo
+
+ Cambia el filtro o registra nuevas ventas para ver la tendencia.
+
+
+
+ )
+ }
+
+ return (
+
+
+
+
+
+ {
+ const label = String(point.label ?? "")
+ const revenue = typeof point.revenue === "number" ? point.revenue : 0
+ const orders = typeof point.orders === "number" ? point.orders : 0
+
+ return (
+
+
{label}
+
+ {formatPrice(revenue, currency)}
+ {orders} ventas
+
+
+ )
+ }}
+ />
+
+ )
+}
diff --git a/src/components/ui/calendar.tsx b/src/components/ui/calendar.tsx
index 5955c32f..e0afc01b 100644
--- a/src/components/ui/calendar.tsx
+++ b/src/components/ui/calendar.tsx
@@ -1,68 +1,261 @@
"use client"
import * as React from "react"
-import { DayPicker } from "react-day-picker"
+import {
+ DayPicker,
+ getDefaultClassNames,
+ type DayButton,
+ type Locale
+} from "react-day-picker"
+import {
+ ChevronDownIcon,
+ ChevronLeftIcon,
+ ChevronRightIcon
+} from "lucide-react"
-// import { ChevronLeft, ChevronRight } from "lucide-react"
-
-import { buttonVariants } from "@/components/ui/button"
+import { Button, buttonVariants } from "@/components/ui/button"
import { cn } from "@/lib/utils"
-export type CalendarProps = React.ComponentProps
-
function Calendar({
className,
classNames,
showOutsideDays = true,
+ captionLayout = "label",
+ buttonVariant = "ghost",
+ locale,
+ formatters,
+ components,
...props
-}: CalendarProps) {
+}: React.ComponentProps & {
+ buttonVariant?: React.ComponentProps["variant"]
+}) {
+ const defaultClassNames = getDefaultClassNames()
+
return (
svg]:rotate-180`,
+ String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
+ className
+ )}
+ captionLayout={captionLayout}
+ locale={locale}
+ formatters={{
+ formatMonthDropdown: date =>
+ date.toLocaleString(locale?.code, { month: "short" }),
+ ...formatters
+ }}
classNames={{
- months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
- month: "space-y-4",
- caption: "flex justify-center pt-1 relative items-center",
- caption_label: "text-sm font-medium",
- nav: "space-x-1 flex items-center",
- nav_button: cn(
- buttonVariants({ variant: "outline" }),
- "h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
- ),
- nav_button_previous: "absolute left-1",
- nav_button_next: "absolute right-1",
- table: "w-full border-collapse space-y-1",
- head_row: "flex",
- head_cell:
- "text-gray-500 rounded-md w-9 font-normal text-[0.8rem] dark:text-gray-400",
- row: "flex w-full mt-2",
- cell: "text-center text-sm p-0 relative [&:has([aria-selected])]:bg-gray-100 first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20 dark:[&:has([aria-selected])]:bg-gray-800",
+ root: cn("w-fit", defaultClassNames.root),
+ months: cn(
+ "relative flex flex-col gap-4 md:flex-row",
+ defaultClassNames.months
+ ),
+ month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
+ nav: cn(
+ `absolute inset-x-0 top-0 flex w-full items-center justify-between
+ gap-1`,
+ defaultClassNames.nav
+ ),
+ button_previous: cn(
+ buttonVariants({ variant: buttonVariant }),
+ "size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
+ defaultClassNames.button_previous
+ ),
+ button_next: cn(
+ buttonVariants({ variant: buttonVariant }),
+ "size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
+ defaultClassNames.button_next
+ ),
+ month_caption: cn(
+ `flex h-(--cell-size) w-full items-center justify-center
+ px-(--cell-size)`,
+ defaultClassNames.month_caption
+ ),
+ dropdowns: cn(
+ `flex h-(--cell-size) w-full items-center justify-center gap-1.5
+ text-sm font-medium`,
+ defaultClassNames.dropdowns
+ ),
+ dropdown_root: cn(
+ "relative rounded-(--cell-radius)",
+ defaultClassNames.dropdown_root
+ ),
+ dropdown: cn(
+ "absolute inset-0 bg-popover opacity-0",
+ defaultClassNames.dropdown
+ ),
+ caption_label: cn(
+ "font-medium select-none",
+ captionLayout === "label"
+ ? "text-sm"
+ : `flex items-center gap-1 rounded-(--cell-radius) text-sm
+ [&>svg]:size-3.5 [&>svg]:text-muted-foreground`,
+ defaultClassNames.caption_label
+ ),
+ month_grid: cn("w-full border-collapse", defaultClassNames.month_grid),
+ weekdays: cn("flex", defaultClassNames.weekdays),
+ weekday: cn(
+ `flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal
+ text-muted-foreground select-none`,
+ defaultClassNames.weekday
+ ),
+ week: cn("mt-2 flex w-full", defaultClassNames.week),
+ week_number_header: cn(
+ "w-(--cell-size) select-none",
+ defaultClassNames.week_number_header
+ ),
+ week_number: cn(
+ "text-[0.8rem] text-muted-foreground select-none",
+ defaultClassNames.week_number
+ ),
day: cn(
- buttonVariants({ variant: "ghost" }),
- "h-9 w-9 p-0 font-normal aria-selected:opacity-100"
- ),
- day_selected:
- "bg-gray-900 text-gray-50 hover:bg-gray-900 hover:text-gray-50 focus:bg-gray-900 focus:text-gray-50 dark:bg-gray-50 dark:text-gray-900 dark:hover:bg-gray-50 dark:hover:text-gray-900 dark:focus:bg-gray-50 dark:focus:text-gray-900",
- day_today:
- "bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-gray-50",
- day_outside: "text-gray-500 opacity-50 dark:text-gray-400",
- day_disabled: "text-gray-500 opacity-50 dark:text-gray-400",
- day_range_middle:
- "aria-selected:bg-gray-100 aria-selected:text-gray-900 dark:aria-selected:bg-gray-800 dark:aria-selected:text-gray-50",
- day_hidden: "invisible",
+ `group/day relative aspect-square h-full w-full
+ rounded-(--cell-radius) p-0 text-center select-none
+ [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)`,
+ props.showWeekNumber
+ ? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)"
+ : "[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)",
+ defaultClassNames.day
+ ),
+ range_start: cn(
+ `relative isolate z-0 rounded-l-(--cell-radius) bg-muted
+ after:absolute after:inset-y-0 after:right-0 after:w-4
+ after:bg-muted`,
+ defaultClassNames.range_start
+ ),
+ range_middle: cn("rounded-none", defaultClassNames.range_middle),
+ range_end: cn(
+ `relative isolate z-0 rounded-r-(--cell-radius) bg-muted
+ after:absolute after:inset-y-0 after:left-0 after:w-4
+ after:bg-muted`,
+ defaultClassNames.range_end
+ ),
+ today: cn(
+ `rounded-(--cell-radius) bg-muted text-foreground
+ data-[selected=true]:rounded-none`,
+ defaultClassNames.today
+ ),
+ outside: cn(
+ "text-muted-foreground aria-selected:text-muted-foreground",
+ defaultClassNames.outside
+ ),
+ disabled: cn(
+ "text-muted-foreground opacity-50",
+ defaultClassNames.disabled
+ ),
+ hidden: cn("invisible", defaultClassNames.hidden),
...classNames
}}
- // components={{
- // // skipcq: JS-0356
- // IconLeft: ({ ...props }) => ,
- // // skipcq: JS-0356
- // IconRight: ({ ...props }) =>
- // }}
+ components={{
+ Root: ({ className, rootRef, ...props }) => (
+
+ ),
+ Chevron: ({ className, orientation, ...props }) => {
+ if (orientation === "left") {
+ return (
+
+ )
+ }
+
+ if (orientation === "right") {
+ return (
+
+ )
+ }
+
+ return (
+
+ )
+ },
+ DayButton: props => ,
+ WeekNumber: ({ children, ...props }) => (
+
+
+ {children}
+
+
+ ),
+ ...components
+ }}
+ {...props}
+ />
+ )
+}
+
+function CalendarDayButton({
+ className,
+ day,
+ modifiers,
+ locale,
+ ...props
+}: React.ComponentProps & { locale?: Partial }) {
+ const defaultClassNames = getDefaultClassNames()
+
+ const ref = React.useRef(null)
+
+ React.useEffect(() => {
+ if (modifiers.focused) ref.current?.focus()
+ }, [modifiers.focused])
+
+ return (
+ span]:text-xs
+ [&>span]:opacity-70`,
+ defaultClassNames.day,
+ className
+ )}
{...props}
/>
)
}
-Calendar.displayName = "Calendar"
-export { Calendar }
+export { Calendar, CalendarDayButton }
diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx
index c6f5a946..432975c7 100644
--- a/src/components/ui/card.tsx
+++ b/src/components/ui/card.tsx
@@ -9,7 +9,8 @@ const Card = React.forwardRef<
) {
+export type InputProps = React.ComponentProps<"input">
+
+function Input({ className, type, ...props }: InputProps) {
return (
,
+ "size"
+> {
+ size?: NumberInputSize
+}
+
+export const NumberInput = (props: NumberInputProps) => {
+ const { size = "md", className, ...rest } = props
+
+ return (
+
+ )
+}
+
+export const NumberInputGroup = (
+ props: React.ComponentProps
+) => {
+ const { className, ...rest } = props
+
+ return (
+
+ )
+}
+
+export const NumberInputDecrement = (
+ props: React.ComponentProps
+) => {
+ const { className, ...rest } = props
+
+ return (
+
+
+
+
+
+ )
+}
+
+export const NumberInputIncrement = (
+ props: React.ComponentProps
+) => {
+ const { className, ...rest } = props
+
+ return (
+
+
+
+
+
+ )
+}
+
+export const NumberInputInput = (props: React.ComponentProps) => {
+ const { className, ...rest } = props
+
+ return (
+
+
+
+ )
+}
+
+export const NumberInputScrubber = (
+ props: React.ComponentProps
+) => {
+ const { className, children, ...rest } = props
+
+ return (
+
+
+ {children}
+
+
+ )
+}
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
index 4960ef99..1532ffad 100644
--- a/src/lib/auth.ts
+++ b/src/lib/auth.ts
@@ -1,4 +1,7 @@
-import { stripe } from "@better-auth/stripe"
+import {
+ stripe,
+ type Subscription as StripeSubscription
+} from "@better-auth/stripe"
import { prismaAdapter } from "better-auth/adapters/prisma"
import { createAuthMiddleware } from "better-auth/api"
import { betterAuth } from "better-auth/minimal"
@@ -54,10 +57,31 @@ function extractEmailFromContext(ctx: Record | undefined) {
return found ? String(found).trim().toLowerCase() : null
}
-// skipcq: JS-0339
-const stripeClient = new Stripe(process.env.STRIPE_SECRET_KEY!, {
- apiVersion: "2026-04-22.dahlia"
-})
+const stripeSecretKey = process.env.STRIPE_SECRET_KEY
+const stripeWebhookSecret = process.env.STRIPE_WEBHOOK_SECRET
+const stripeProMonthlyPriceId = process.env.NEXT_PUBLIC_STRIPE_PRICE_PRO_MONTHLY
+const stripeProYearlyPriceId = process.env.NEXT_PUBLIC_STRIPE_PRICE_PRO_YEARLY
+
+const stripeClient = stripeSecretKey
+ ? new Stripe(stripeSecretKey, {
+ apiVersion: "2026-04-22.dahlia"
+ })
+ : null
+
+const stripeBillingConfig =
+ stripeClient &&
+ stripeWebhookSecret &&
+ stripeProMonthlyPriceId &&
+ stripeProYearlyPriceId
+ ? {
+ stripeClient,
+ stripeWebhookSecret,
+ stripeProMonthlyPriceId,
+ stripeProYearlyPriceId
+ }
+ : null
+
+export const isStripeBillingConfigured = stripeBillingConfig !== null
export const auth = betterAuth({
// Adjust trusted origins for your deployment
@@ -236,57 +260,94 @@ export const auth = betterAuth({
})
}
}),
- // Stripe billing integration via Better Auth plugin (server-side)
- stripe({
- // Pass an initialized Stripe client (recommended by the plugin docs)
- stripeClient,
- // Webhook signing secret for verifying Stripe webhook payloads
- // skipcq: JS-0339
- stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
- // Create a Stripe customer automatically when users sign up
- createCustomerOnSignUp: true,
- subscription: {
- enabled: true,
- plans: [
- {
- name: "BASIC",
- // skipcq: JS-0339
- priceId: process.env.NEXT_PUBLIC_STRIPE_PRICE_BASIC!,
- limits: {
- menus: appConfig.menuLimit,
- products: appConfig.itemLimit
- }
- },
- {
- name: "PRO",
- // skipcq: JS-0339
- priceId: process.env.NEXT_PUBLIC_STRIPE_PRICE_PRO_MONTHLY!,
- // skipcq: JS-0339
- annualDiscountPriceId:
- process.env.NEXT_PUBLIC_STRIPE_PRICE_PRO_YEARLY!,
- limits: {
- menus: 100,
- products: 1000
- },
- freeTrial: {
- days: 30
- }
- }
- ],
- authorizeReference: async ({ user, referenceId }) => {
- // Ensure the user is authorized to manage the organization
- const member = await prisma.member.findFirst({
- where: {
- userId: user.id,
- organizationId: referenceId
+ ...(stripeBillingConfig
+ ? [
+ stripe({
+ // Pass an initialized Stripe client (recommended by the plugin docs)
+ stripeClient: stripeBillingConfig.stripeClient,
+ // Webhook signing secret for verifying Stripe webhook payloads
+ stripeWebhookSecret: stripeBillingConfig.stripeWebhookSecret,
+ // Create a Stripe customer automatically when users sign up
+ createCustomerOnSignUp: true,
+ subscription: {
+ enabled: true,
+ plans: [
+ {
+ name: "BASIC",
+ limits: {
+ menus: appConfig.menuLimit,
+ products: appConfig.itemLimit
+ }
+ },
+ {
+ name: "PRO",
+ priceId: stripeBillingConfig.stripeProMonthlyPriceId,
+ annualDiscountPriceId:
+ stripeBillingConfig.stripeProYearlyPriceId,
+ limits: {
+ menus: 100,
+ products: 1000
+ },
+ freeTrial: {
+ days: 30
+ }
+ }
+ ],
+ authorizeReference: async ({ user, referenceId }) => {
+ // Ensure the user is authorized to manage the organization
+ const member = await prisma.member.findFirst({
+ where: {
+ userId: user.id,
+ organizationId: referenceId
+ }
+ })
+
+ return member?.role === "owner" || member?.role === "admin"
+ }
}
})
-
- return member?.role === "owner" || member?.role === "admin"
- }
- }
- })
+ ]
+ : [])
]
})
+type StripeBillingPortalResponse = {
+ url: string
+}
+
+type StripeBillingApi = {
+ listActiveSubscriptions: (options: {
+ query: { referenceId: string }
+ headers: HeadersInit
+ }) => Promise
+ createBillingPortal: (options: {
+ body: { referenceId: string; returnUrl: string }
+ headers: HeadersInit
+ }) => Promise
+}
+
+export function getStripeBillingApi(): StripeBillingApi | null {
+ if (!isStripeBillingConfigured) {
+ return null
+ }
+
+ const api = auth.api as Record
+ const listActiveSubscriptions = api.listActiveSubscriptions
+ const createBillingPortal = api.createBillingPortal
+
+ if (
+ typeof listActiveSubscriptions !== "function" ||
+ typeof createBillingPortal !== "function"
+ ) {
+ return null
+ }
+
+ return {
+ listActiveSubscriptions:
+ listActiveSubscriptions as StripeBillingApi["listActiveSubscriptions"],
+ createBillingPortal:
+ createBillingPortal as StripeBillingApi["createBillingPortal"]
+ }
+}
+
export type AuthMember = typeof auth.$Infer.Member
diff --git a/src/lib/sales-closing-date.ts b/src/lib/sales-closing-date.ts
new file mode 100644
index 00000000..bfbe4191
--- /dev/null
+++ b/src/lib/sales-closing-date.ts
@@ -0,0 +1,86 @@
+const salesClosingDateFormatter = new Intl.DateTimeFormat("es-MX", {
+ day: "numeric",
+ month: "short",
+ year: "numeric"
+})
+
+const salesClosingDateLongFormatter = new Intl.DateTimeFormat("es-MX", {
+ dateStyle: "long"
+})
+
+const salesClosingDateValuePattern = /^\d{4}-\d{2}-\d{2}$/
+
+function padDateSegment(value: number) {
+ return String(value).padStart(2, "0")
+}
+
+export function parseSalesClosingDateValue(value: string | null | undefined) {
+ if (!value || !salesClosingDateValuePattern.test(value)) {
+ return null
+ }
+
+ const [yearValue, monthValue, dayValue] = value.split("-")
+ const year = Number(yearValue)
+ const month = Number(monthValue)
+ const day = Number(dayValue)
+
+ if (Number.isNaN(year) || Number.isNaN(month) || Number.isNaN(day)) {
+ return null
+ }
+
+ const parsedDate = new Date(year, month - 1, day)
+
+ if (
+ parsedDate.getFullYear() !== year ||
+ parsedDate.getMonth() !== month - 1 ||
+ parsedDate.getDate() !== day
+ ) {
+ return null
+ }
+
+ return parsedDate
+}
+
+export function formatSalesClosingDateValue(date: Date) {
+ return [
+ date.getFullYear(),
+ padDateSegment(date.getMonth() + 1),
+ padDateSegment(date.getDate())
+ ].join("-")
+}
+
+export function getSalesClosingDateValue(date = new Date()) {
+ return formatSalesClosingDateValue(date)
+}
+
+export function formatSalesClosingDateLabel(value: string) {
+ const date = parseSalesClosingDateValue(value)
+
+ return date ? salesClosingDateFormatter.format(date) : ""
+}
+
+export function formatSalesClosingDateLongLabel(value: string) {
+ const date = parseSalesClosingDateValue(value)
+
+ return date ? salesClosingDateLongFormatter.format(date) : ""
+}
+
+export function resolveSalesClosingDateValue(
+ value: string | null | undefined,
+ fallbackValue = getSalesClosingDateValue()
+) {
+ const parsedDate = parseSalesClosingDateValue(value)
+
+ if (!parsedDate) {
+ return fallbackValue
+ }
+
+ const todayStart = new Date()
+ todayStart.setHours(0, 0, 0, 0)
+
+ if (parsedDate > todayStart) {
+ return fallbackValue
+ }
+
+ return formatSalesClosingDateValue(parsedDate)
+}
diff --git a/src/lib/sales-dashboard-period.ts b/src/lib/sales-dashboard-period.ts
new file mode 100644
index 00000000..6ab3f030
--- /dev/null
+++ b/src/lib/sales-dashboard-period.ts
@@ -0,0 +1,40 @@
+export const salesDashboardPeriodValues = ["7d", "1m", "3m", "1y"] as const
+
+export type SalesDashboardPeriod = (typeof salesDashboardPeriodValues)[number]
+
+export const defaultSalesDashboardPeriod =
+ "1m" as const satisfies SalesDashboardPeriod
+
+export const salesDashboardPeriodLabels = {
+ "7d": "7 días",
+ "1m": "1 mes",
+ "3m": "3 meses",
+ "1y": "1 año"
+} as const satisfies Record
+
+export const salesDashboardPeriodRangeLabels = {
+ "7d": "Últimos 7 días",
+ "1m": "Último mes",
+ "3m": "Últimos 3 meses",
+ "1y": "Último año"
+} as const satisfies Record
+
+export const salesDashboardPeriodSentenceLabels = {
+ "7d": "los últimos 7 días",
+ "1m": "el último mes",
+ "3m": "los últimos 3 meses",
+ "1y": "el último año"
+} as const satisfies Record
+
+export const salesDashboardPeriodOptions = salesDashboardPeriodValues.map(
+ value => ({
+ value,
+ label: salesDashboardPeriodLabels[value]
+ })
+)
+
+export function isSalesDashboardPeriod(
+ value: string
+): value is SalesDashboardPeriod {
+ return value in salesDashboardPeriodLabels
+}
diff --git a/src/lib/types/sales.ts b/src/lib/types/sales.ts
new file mode 100644
index 00000000..0ea0b50f
--- /dev/null
+++ b/src/lib/types/sales.ts
@@ -0,0 +1,143 @@
+import { z } from "zod/v4"
+
+import type { Currency } from "@/lib/currency"
+import type { SalesDashboardPeriod } from "@/lib/sales-dashboard-period"
+
+export const salesOrderTypeValues = ["DINE_IN", "TAKEOUT", "DELIVERY"] as const
+
+export const salesOrderTypeSchema = z.enum(salesOrderTypeValues)
+
+export type SalesOrderType = z.infer
+
+export const salesOrderTypeLabels = {
+ DINE_IN: "Para aquí",
+ TAKEOUT: "Para llevar",
+ DELIVERY: "Entrega"
+} as const satisfies Record
+
+export const salesOrderTypeBadgeVariants = {
+ DINE_IN: "blue",
+ TAKEOUT: "indigo",
+ DELIVERY: "yellow"
+} as const satisfies Record