diff --git a/src/shared/components/Tooltip.tsx b/src/shared/components/Tooltip.tsx index ca594e28ad..30b0478b07 100644 --- a/src/shared/components/Tooltip.tsx +++ b/src/shared/components/Tooltip.tsx @@ -24,9 +24,10 @@ import { createPortal } from "react-dom"; interface TooltipProps { children: ReactNode; - content?: string; + content?: ReactNode; position?: "top" | "bottom" | "left" | "right"; className?: string; + tooltipClassName?: string; delayMs?: number; /** * Issue #2352: Render the tooltip in a React portal so it escapes the @@ -52,6 +53,7 @@ export default function Tooltip({ content, position = "top", className = "", + tooltipClassName = "", delayMs = 200, usePortal = true, multiline = false, @@ -60,7 +62,7 @@ export default function Tooltip({ const tooltipId = useId(); const timeoutRef = useRef | null>(null); const wrapperRef = useRef(null); - const tooltipRef = useRef(null); + const tooltipRef = useRef(null); const show = useCallback(() => { clearTimeout(timeoutRef.current); @@ -90,39 +92,41 @@ export default function Tooltip({ if (!wrap || !tt) return; const rect = wrap.getBoundingClientRect(); const tRect = tt.getBoundingClientRect(); - const scrollY = window.scrollY; - const scrollX = window.scrollX; let top = 0; let left = 0; switch (position) { case "bottom": - top = rect.bottom + scrollY + 8; - left = rect.left + scrollX + rect.width / 2 - tRect.width / 2; + top = rect.bottom + 8; + left = rect.left + rect.width / 2 - tRect.width / 2; break; case "left": - top = rect.top + scrollY + rect.height / 2 - tRect.height / 2; - left = rect.left + scrollX - tRect.width - 8; + top = rect.top + rect.height / 2 - tRect.height / 2; + left = rect.left - tRect.width - 8; break; case "right": - top = rect.top + scrollY + rect.height / 2 - tRect.height / 2; - left = rect.right + scrollX + 8; + top = rect.top + rect.height / 2 - tRect.height / 2; + left = rect.right + 8; break; case "top": default: - top = rect.top + scrollY - tRect.height - 8; - left = rect.left + scrollX + rect.width / 2 - tRect.width / 2; + top = rect.top - tRect.height - 8; + left = rect.left + rect.width / 2 - tRect.width / 2; break; } // Clamp horizontally inside the viewport so a trigger near the right // edge does not produce a tooltip that bleeds off the screen. const margin = 8; - const maxLeft = window.innerWidth + scrollX - tRect.width - margin; - const minLeft = scrollX + margin; + const maxLeft = window.innerWidth - tRect.width - margin; + const minLeft = margin; if (left > maxLeft) left = maxLeft; if (left < minLeft) left = minLeft; + + // Lock position immediately without any slide/flying animation from (0,0). + tt.style.transition = "none"; tt.style.top = `${top}px`; tt.style.left = `${left}px`; tt.style.visibility = "visible"; + tt.style.opacity = "1"; }, [visible, usePortal, position, content]); const positionClasses = { @@ -149,14 +153,13 @@ export default function Tooltip({ ); const widthClass = multiline ? "max-w-xs whitespace-normal break-words" : "whitespace-nowrap"; - const baseTooltipClass = - "z-50 px-2.5 py-1.5 text-xs font-medium text-white bg-gray-900/95 rounded-md shadow-lg pointer-events-none animate-in fade-in duration-150 motion-reduce:transition-none motion-reduce:animate-none border border-white/10"; + const baseTooltipClass = `z-50 px-3 py-2 text-xs font-medium text-white bg-[#10141e]/95 rounded-lg shadow-xl pointer-events-none transition-opacity duration-150 motion-reduce:transition-none border border-white/10 backdrop-blur-sm ${tooltipClassName}`; const portalEnabled = usePortal && typeof window !== "undefined"; const tooltipEl = visible && content ? ( - + ) : null; return ( diff --git a/src/shared/components/UsageAnalytics.tsx b/src/shared/components/UsageAnalytics.tsx index 0bdd230117..45f9701740 100644 --- a/src/shared/components/UsageAnalytics.tsx +++ b/src/shared/components/UsageAnalytics.tsx @@ -275,24 +275,32 @@ export default function UsageAnalytics() { icon="generating_tokens" label={t("totalTokens")} value={fmt(s.totalTokens)} + tooltip={fmtFull(s.totalTokens)} subValue={`${fmtFull(s.totalRequests)} ${t("chartRequests")}`} /> @@ -321,12 +329,16 @@ export default function UsageAnalytics() { icon: "speed", label: t("perfAvgTokens"), value: fmt(avgTokensPerReq), + tooltip: `tokens : ${fmtFull(avgTokensPerReq)} tokens`, color: "text-cyan-500", }, { icon: "request_quote", label: t("perfCostReq"), value: fmtCost(costPerReq), + tooltip: costPerReq + ? `cost : $${Number(costPerReq).toLocaleString(undefined, { minimumFractionDigits: 4, maximumFractionDigits: 6 })}` + : undefined, color: "text-orange-500", }, { @@ -339,6 +351,7 @@ export default function UsageAnalytics() { icon: "bolt", label: t("perfFastReq"), value: fmt(s.fastRequests || 0), + tooltip: `requests : ${fmtFull(s.fastRequests || 0)} requests`, color: "text-sky-500", }, ], diff --git a/src/shared/components/analytics/ModelTable.tsx b/src/shared/components/analytics/ModelTable.tsx index 16850428d7..149c3c0dee 100644 --- a/src/shared/components/analytics/ModelTable.tsx +++ b/src/shared/components/analytics/ModelTable.tsx @@ -128,13 +128,22 @@ export function ModelTable({ byModel, summary }: ModelTableProps) { {fmtFull(m.requests)} - + {fmt(m.promptTokens)} - + {fmt(m.completionTokens)} - + {fmt(m.totalTokens)} diff --git a/src/shared/components/analytics/RequestCountTable.tsx b/src/shared/components/analytics/RequestCountTable.tsx index 71419a7d75..7067529347 100644 --- a/src/shared/components/analytics/RequestCountTable.tsx +++ b/src/shared/components/analytics/RequestCountTable.tsx @@ -92,7 +92,10 @@ export default function RequestCountTable({ {fmtFull(row.requests)} - + {fmt(row.totalTokens)} diff --git a/src/shared/components/analytics/charts.tsx b/src/shared/components/analytics/charts.tsx index 2342fe346b..176315befb 100644 --- a/src/shared/components/analytics/charts.tsx +++ b/src/shared/components/analytics/charts.tsx @@ -3,7 +3,7 @@ import { useState, useMemo, useCallback, useRef, useEffect } from "react"; import { useLocale, useTranslations } from "next-intl"; import Card from "../Card"; -import { getModelColor } from "@/shared/constants/colors"; +import Tooltip from "../Tooltip"; import { PROVIDER_COLORS } from "./chartColors"; import { fmtCompact as fmt, @@ -49,23 +49,63 @@ export function StatCard({ label, value, subValue, + tooltip, color = "text-text-main", }: { icon: any; label: any; value: any; subValue?: any; + tooltip?: string; color?: string; }) { + const isCost = String(label).toLowerCase().includes("cost"); + const tooltipText = tooltip + ? String(tooltip).includes(":") + ? tooltip + : isCost + ? `cost : ${String(tooltip).startsWith("$") ? tooltip : `$${tooltip}`}` + : `tokens : ${tooltip} tokens` + : null; + + const valueElement = ( + + {value} + + ); + return (
{icon} {label}
- - {value} - + {tooltipText ? ( + +
{label}
+
+ {tooltipText} +
+ + } + className="w-fit max-w-full" + delayMs={150} + > + {valueElement} +
+ ) : ( + valueElement + )} {subValue && {subValue}}
); @@ -75,7 +115,7 @@ export function StatCard({ export type CompactStatSection = { title: string; - items: Array<{ icon: string; label: string; value: any; color?: string }>; + items: Array<{ icon: string; label: string; value: any; tooltip?: string; color?: string }>; /** On mobile use 1 column instead of 2 — useful when values can be long (model names, etc.) */ wideValues?: boolean; }; @@ -99,28 +139,56 @@ export function CompactStatGrid({ sections }: { sections: CompactStatSection[] } : "grid grid-cols-2 md:grid-cols-4 gap-x-5 gap-y-2" } > - {section.items.map((stat, i) => ( -
-
- - {stat.icon} - - - {stat.label} - -
+ {section.items.map((stat, i) => { + const statValueEl = ( {stat.value} -
- ))} + ); + + return ( +
+
+ + {stat.icon} + + + {stat.label} + +
+ {stat.tooltip ? ( + +
{stat.label}
+
+ {String(stat.tooltip).includes(":") + ? stat.tooltip + : `${stat.label.toLowerCase().includes("cost") ? "cost" : "tokens"} : ${stat.tooltip}`} +
+
+ } + className={section.wideValues ? "truncate min-w-0" : "shrink-0"} + delayMs={150} + > + {statValueEl} + + ) : ( + statValueEl + )} + + ); + })} ))} @@ -421,13 +489,22 @@ export function ApiKeyTable({ byApiKey }) { {fmtFull(row.requests)} - + {fmt(row.promptTokens)} - + {fmt(row.completionTokens)} - + {fmt(row.totalTokens)} @@ -499,9 +576,26 @@ export function MostActiveDay7d({ activityMap }) { {data.weekday} - - {t("datedTokenCount", { date: data.label, tokens: fmt(data.tokens) })} - + +
{data.weekday}
+
+ tokens : {fmtFull(data.tokens)} tokens +
+ + } + className="w-fit max-w-full" + delayMs={150} + > + + {t("datedTokenCount", { date: data.label, tokens: fmt(data.tokens) })} + +
) : ( @@ -562,7 +656,7 @@ export function WeeklySquares7d({ activityMap }) { {t("chartWeekly")}
- {days.map((d, i) => ( + {days.map((d) => (
{fmtFull(p.requests)} - + {fmt(p.promptTokens)} - + {fmt(p.completionTokens)} - + {fmt(p.totalTokens)} diff --git a/tests/unit/ui/analytics-token-hover-tooltip.test.tsx b/tests/unit/ui/analytics-token-hover-tooltip.test.tsx new file mode 100644 index 0000000000..bf0877404b --- /dev/null +++ b/tests/unit/ui/analytics-token-hover-tooltip.test.tsx @@ -0,0 +1,296 @@ +// @vitest-environment jsdom +import React from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + StatCard, + CompactStatGrid, + ApiKeyTable, + ProviderTable, + MostActiveDay7d, +} from "@/shared/components/analytics/charts"; +import { ModelTable } from "@/shared/components/analytics/ModelTable"; +import RequestCountTable from "@/shared/components/analytics/RequestCountTable"; +import { fmtFull } from "@/shared/utils/formatting"; + +vi.mock("next-intl", () => ({ + useLocale: () => "en", + useTranslations: () => { + const t = (key: string, values?: Record) => { + if (values) return `${key}:${JSON.stringify(values)}`; + return key; + }; + t.has = () => false; + return t; + }, +})); + +describe("Analytics Token Hover Tooltips", () => { + let container: HTMLElement; + + beforeEach(() => { + (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + document.body.innerHTML = ""; + }); + + it("StatCard renders rich custom tooltip when provided, else falls back to value title", async () => { + vi.useFakeTimers(); + const root = createRoot(container); + await act(async () => { + root.render( +
+ + +
+ ); + }); + + const values = container.querySelectorAll(".text-2xl.font-bold"); + expect(values).toHaveLength(2); + expect(values[0]?.getAttribute("data-tooltip")).toBe(fmtFull(1532481200)); + expect(values[0]?.textContent).toBe("1.5B"); + expect(values[1]?.getAttribute("title")).toBe("$1180.27"); + expect(values[1]?.textContent).toBe("$1180.27"); + + // Trigger hover on the first StatCard value + const trigger = container.querySelector(".w-fit.max-w-full"); + expect(trigger).toBeDefined(); + + await act(async () => { + trigger?.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + await vi.advanceTimersByTimeAsync(250); + }); + + // Check portal tooltip content in document.body + const tooltipPortal = document.body.querySelector('[role="tooltip"]'); + expect(tooltipPortal).not.toBeNull(); + expect(tooltipPortal?.textContent).toContain("Total Tokens"); + expect(tooltipPortal?.textContent).toContain(`tokens : ${fmtFull(1532481200)} tokens`); + vi.useRealTimers(); + }); + + it("CompactStatGrid renders rich custom tooltip when provided", async () => { + vi.useFakeTimers(); + const root = createRoot(container); + await act(async () => { + root.render( + + ); + }); + + const statElements = container.querySelectorAll(".text-sm.font-bold.text-right"); + expect(statElements).toHaveLength(2); + expect(statElements[0]?.getAttribute("data-tooltip")).toBe( + `tokens : ${fmtFull(131482)} tokens` + ); + expect(statElements[1]?.getAttribute("title")).toBe("0"); + + // Trigger hover on the first stat item + const trigger = statElements[0]?.closest(".relative.inline-flex"); + expect(trigger).toBeDefined(); + + await act(async () => { + trigger?.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + await vi.advanceTimersByTimeAsync(250); + }); + + const tooltipPortal = document.body.querySelector('[role="tooltip"]'); + expect(tooltipPortal).not.toBeNull(); + expect(tooltipPortal?.textContent).toContain("Avg Tokens/Req"); + expect(tooltipPortal?.textContent).toContain(`tokens : ${fmtFull(131482)} tokens`); + vi.useRealTimers(); + }); + + it("ApiKeyTable renders full token counts as title hover tooltips", async () => { + const root = createRoot(container); + const mockData = [ + { + apiKeyId: "key-12345678", + apiKeyName: "Default Key", + requests: 10, + promptTokens: 1532000000, + completionTokens: 6900000, + totalTokens: 1538900000, + cost: 12.34, + }, + ]; + + await act(async () => { + root.render(); + }); + + const rows = container.querySelectorAll("tbody tr"); + expect(rows).toHaveLength(1); + + const cells = rows[0]?.querySelectorAll("td"); + // promptTokens + expect(cells?.[2]?.getAttribute("title")).toBe(fmtFull(1532000000)); + expect(cells?.[2]?.textContent?.trim()).toBe("1.5B"); + // completionTokens + expect(cells?.[3]?.getAttribute("title")).toBe(fmtFull(6900000)); + expect(cells?.[3]?.textContent?.trim()).toBe("6.9M"); + // totalTokens + expect(cells?.[4]?.getAttribute("title")).toBe(fmtFull(1538900000)); + expect(cells?.[4]?.textContent?.trim()).toBe("1.5B"); + }); + + it("ProviderTable renders full token counts as title hover tooltips", async () => { + const root = createRoot(container); + const mockData = [ + { + provider: "anthropic", + requests: 15, + promptTokens: 2000000, + completionTokens: 500000, + totalTokens: 2500000, + cost: 5.5, + }, + ]; + + await act(async () => { + root.render(); + }); + + const rows = container.querySelectorAll("tbody tr"); + expect(rows).toHaveLength(1); + + const cells = rows[0]?.querySelectorAll("td"); + // promptTokens + expect(cells?.[2]?.getAttribute("title")).toBe(fmtFull(2000000)); + expect(cells?.[2]?.textContent?.trim()).toBe("2.0M"); + // completionTokens + expect(cells?.[3]?.getAttribute("title")).toBe(fmtFull(500000)); + expect(cells?.[3]?.textContent?.trim()).toBe("500.0K"); + // totalTokens + expect(cells?.[4]?.getAttribute("title")).toBe(fmtFull(2500000)); + expect(cells?.[4]?.textContent?.trim()).toBe("2.5M"); + }); + + it("ModelTable renders full token counts as title hover tooltips", async () => { + const root = createRoot(container); + const mockData = [ + { + model: "claude-3-7-sonnet", + requests: 20, + promptTokens: 10000000, + completionTokens: 2000000, + totalTokens: 12000000, + cost: 10.0, + }, + ]; + + await act(async () => { + root.render(); + }); + + const rows = container.querySelectorAll("tbody tr"); + expect(rows).toHaveLength(1); + + const cells = rows[0]?.querySelectorAll("td"); + // promptTokens + expect(cells?.[2]?.getAttribute("title")).toBe(fmtFull(10000000)); + expect(cells?.[2]?.textContent?.trim()).toBe("10.0M"); + // completionTokens + expect(cells?.[3]?.getAttribute("title")).toBe(fmtFull(2000000)); + expect(cells?.[3]?.textContent?.trim()).toBe("2.0M"); + // totalTokens + expect(cells?.[4]?.getAttribute("title")).toBe(fmtFull(12000000)); + expect(cells?.[4]?.textContent?.trim()).toBe("12.0M"); + }); + + it("RequestCountTable renders full totalTokens count as title hover tooltip", async () => { + const root = createRoot(container); + const mockData = [ + { + date: "2026-09-03", + provider: "openai", + requests: 5, + promptTokens: 1000, + completionTokens: 500, + totalTokens: 1500000, + }, + ]; + + await act(async () => { + root.render( + {}} + dateLabel="Date" + providerLabel="Provider" + requestsLabel="Requests" + totalLabel="Total" + /> + ); + }); + + const rows = container.querySelectorAll("tbody tr"); + expect(rows).toHaveLength(1); + + const cells = rows[0]?.querySelectorAll("td"); + // totalTokens cell (index 3) + expect(cells?.[3]?.getAttribute("title")).toBe(fmtFull(1500000)); + expect(cells?.[3]?.textContent?.trim()).toBe("1.5M"); + }); + + it("MostActiveDay7d renders rich custom tooltip on hover", async () => { + vi.useFakeTimers(); + const root = createRoot(container); + // Use today's date formatted as YYYY-MM-DD + const today = new Date(); + const todayKey = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`; + const mockActivityMap: Record = { + [todayKey]: 309080259, + }; + + await act(async () => { + root.render(); + }); + + const trigger = container.querySelector(".relative.inline-flex.w-fit"); + expect(trigger).toBeDefined(); + + await act(async () => { + trigger?.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + await vi.advanceTimersByTimeAsync(250); + }); + + const tooltipPortal = document.body.querySelector('[role="tooltip"]'); + expect(tooltipPortal).not.toBeNull(); + expect(tooltipPortal?.textContent).toContain(`tokens : ${fmtFull(309080259)} tokens`); + vi.useRealTimers(); + }); +});