diff --git a/assets/pr-screenshots/usage-chart-review.png b/assets/pr-screenshots/usage-chart-review.png new file mode 100644 index 0000000000..3f2582929a Binary files /dev/null and b/assets/pr-screenshots/usage-chart-review.png differ diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index ef987bae78..9c62d66230 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -352,3 +352,7 @@ is gated correctly without manual classification. Machine enrollment and browser authentication are separate. The pairing panel names the hub and displays an `ocx gui pair --origin` command for the exact origin currently open in your browser. Run that command on the hub, or send it to the hub operator and request a one-time pairing code. Paste that code into the panel; a data API key or admin token is not a pairing code. While browser authentication is pending, the dashboard does not recommend restarting a healthy connected client. Completing pairing refreshes the dashboard data immediately, including a previously cached authentication failure. Session expiry returns to pairing; permission denial keeps its own access-settings guidance. Other failed refreshes may show the last received data with a stale-data notice and retry action. + +### Usage chart keyboard and touch controls + +Usage heatmap days have one Tab entry point. Use Up/Down for adjacent days and Left/Right for adjacent weeks. Weekly bars expose the same day details on keyboard focus, pointer hover, or touch. Day labels include the date, request count, and token count; tooltip overlays stay within the viewport. diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 70b62515d9..dd5ca2633c 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1822,6 +1822,8 @@ export const de: Record = { "usage.dayWed": "Mi", "usage.dayFri": "Fr", "usage.heatmap.tooltipTokens": "{tokens} Tokens", + "usage.chart.dayDetail": "{date}: {requests} Anfragen, {tokens} Token", + "usage.heatmap.keyboardLabel": "Mit Hoch und Runter tageweise, mit Links und Rechts wochenweise navigieren.", "usage.heatmap.tooltipRequests": "{requests} Anfragen", "nav.storage": "Speicher", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index de2b8906f8..6723d74b81 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1004,6 +1004,8 @@ export const en = { "usage.dayWed": "Wed", "usage.dayFri": "Fri", "usage.heatmap.tooltipTokens": "{tokens} tokens", + "usage.chart.dayDetail": "{date}: {requests} requests, {tokens} tokens", + "usage.heatmap.keyboardLabel": "Use Up and Down to move by day; Left and Right to move by week.", "usage.heatmap.tooltipRequests": "{requests} requests", "nav.storage": "Storage", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 5b9a38a588..43d956906f 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -981,6 +981,8 @@ export const fr: Record = { "usage.dayWed": "Mer", "usage.dayFri": "Ven", "usage.heatmap.tooltipTokens": "{tokens} jetons", + "usage.chart.dayDetail": "{date} : {requests} requêtes, {tokens} jetons", + "usage.heatmap.keyboardLabel": "Utilisez Haut et Bas pour changer de jour ; Gauche et Droite pour changer de semaine.", "usage.heatmap.tooltipRequests": "{requests} requêtes", "nav.storage": "Stockage", "storage.title": "Stockage", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index b21d4d59b7..d60148bb60 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -917,6 +917,8 @@ export const ja: Record = { "usage.dayWed": "水", "usage.dayFri": "金", "usage.heatmap.tooltipTokens": "{tokens} トークン", + "usage.chart.dayDetail": "{date}: {requests} リクエスト、{tokens} トークン", + "usage.heatmap.keyboardLabel": "上下キーで日単位、左右キーで週単位に移動します。", "usage.heatmap.tooltipRequests": "{requests} リクエスト", "nav.storage": "ストレージ", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index a6e76eb7b1..be26daf40e 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1861,6 +1861,8 @@ export const ko: Record = { "usage.dayWed": "수", "usage.dayFri": "금", "usage.heatmap.tooltipTokens": "{tokens} 토큰", + "usage.chart.dayDetail": "{date}: 요청 {requests}개, 토큰 {tokens}개", + "usage.heatmap.keyboardLabel": "위아래 화살표로 하루씩, 좌우 화살표로 일주일씩 이동합니다.", "usage.heatmap.tooltipRequests": "{requests} 요청", "nav.storage": "저장소", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index c28f4464c3..9691f8a49f 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -972,6 +972,8 @@ export const ru: Record = { "usage.dayWed": "Ср", "usage.dayFri": "Пт", "usage.heatmap.tooltipTokens": "{tokens} токенов", + "usage.chart.dayDetail": "{date}: {requests} запросов, {tokens} токенов", + "usage.heatmap.keyboardLabel": "Стрелки вверх и вниз перемещают по дням, влево и вправо — по неделям.", "usage.heatmap.tooltipRequests": "{requests} запросов", "nav.storage": "Хранилище", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index c6671c3e28..95336fa32f 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -991,6 +991,8 @@ export const tr: Record = { "usage.dayWed": "Çar", "usage.dayFri": "Cum", "usage.heatmap.tooltipTokens": "{tokens} jeton", + "usage.chart.dayDetail": "{date}: {requests} istek, {tokens} jeton", + "usage.heatmap.keyboardLabel": "Gün gün ilerlemek için Yukarı ve Aşağı, hafta hafta ilerlemek için Sol ve Sağ tuşlarını kullanın.", "usage.heatmap.tooltipRequests": "{requests} istek", "nav.storage": "Depolama", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 9e154fa1b2..19f1e50ce2 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -791,6 +791,8 @@ export const zhTW: Record = { "usage.dayWed": "三", "usage.dayFri": "五", "usage.heatmap.tooltipTokens": "{tokens} Token", + "usage.chart.dayDetail": "{date}:{requests} 個請求,{tokens} 個 Token", + "usage.heatmap.keyboardLabel": "使用上下方向鍵按天移動,使用左右方向鍵按週移動。", "usage.heatmap.tooltipRequests": "{requests} 請求", "nav.storage": "儲存", "storage.title": "儲存", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 018f6e251b..e081d027f3 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1842,6 +1842,8 @@ export const zh: Record = { "usage.dayWed": "三", "usage.dayFri": "五", "usage.heatmap.tooltipTokens": "{tokens} 令牌", + "usage.chart.dayDetail": "{date}:{requests} 个请求,{tokens} 个 Token", + "usage.heatmap.keyboardLabel": "使用上下方向键按天移动,使用左右方向键按周移动。", "usage.heatmap.tooltipRequests": "{requests} 请求", "nav.storage": "存储", diff --git a/gui/src/main.tsx b/gui/src/main.tsx index 024e29626e..2f3c68dd2c 100644 --- a/gui/src/main.tsx +++ b/gui/src/main.tsx @@ -3,6 +3,7 @@ import ReactDOM from "react-dom/client"; import App from "./App"; import { LanguageProvider } from "./i18n/provider"; import "./styles.css"; +import "./styles/usage-chart-accessibility.css"; ReactDOM.createRoot(document.getElementById("root")!).render( diff --git a/gui/src/pages/Usage.tsx b/gui/src/pages/Usage.tsx index 900ab6eac0..1b655f8cc9 100644 --- a/gui/src/pages/Usage.tsx +++ b/gui/src/pages/Usage.tsx @@ -1,4 +1,5 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { createPortal } from "react-dom"; +import { useCallback, useEffect, useId, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react"; import { useI18n, type TFn, type Locale } from "../i18n/shared"; import type { UsageReadMetadata } from "../usage-summary-resource"; import { UsageIncompleteNotice } from "../components/usage-incomplete-notice"; @@ -141,6 +142,52 @@ function lastSevenDays(days: UsageDay[]): UsageDay[] { return out; } +function formatCalendarDate(date: string, locale: Locale): string { + return new Intl.DateTimeFormat(locale, { dateStyle: "medium" }).format(new Date(`${date}T12:00:00`)); +} + +function chartTipPosition(rect: DOMRect): CSSProperties { + const gutter = 8; + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const maxWidth = Math.min(240, Math.max(0, viewportWidth - gutter * 2)); + const left = Math.max(gutter, Math.min(rect.left + rect.width / 2 - maxWidth / 2, viewportWidth - gutter - maxWidth)); + const above = rect.top - gutter > viewportHeight - rect.bottom - gutter; + const vertical = above + ? (() => { + const bottom = Math.max(gutter, Math.min(viewportHeight - gutter, viewportHeight - rect.top + gutter)); + return { bottom, maxHeight: Math.max(0, viewportHeight - bottom - gutter) }; + })() + : (() => { + const top = Math.max(gutter, Math.min(viewportHeight - gutter, rect.bottom + gutter)); + return { top, maxHeight: Math.max(0, viewportHeight - top - gutter) }; + })(); + return { left, maxWidth, ...vertical }; +} + +function UsageChartOverlay({ + anchor, + className, + children, +}: { + anchor: DOMRect; + className: string; + children: ReactNode; +}) { + return createPortal( +
{children}
, + document.body, + ); +} + +function dayDetail(day: Pick, locale: Locale, t: TFn): string { + return t("usage.chart.dayDetail", { + date: formatCalendarDate(day.date, locale), + requests: day.requests, + tokens: formatTokens(day.totalTokens, locale), + }); +} + function quantileBuckets(values: number[]): number[] { const positive = values.filter(v => v > 0).sort((a, b) => a - b); if (positive.length === 0) return [0, 0, 0, 0]; @@ -360,20 +407,33 @@ function UsageSummaryCards({ } function WeekDayBars({ weekBars, locale, t }: { weekBars: UsageDay[]; locale: Locale; t: TFn }) { - const [hoverDay, setHoverDay] = useState(null); + const [active, setActive] = useState<{ date: string; anchor: DOMRect } | null>(null); const max = Math.max(1, ...weekBars.map(day => day.totalTokens)); + const activeDay = weekBars.find(day => day.date === active?.date); + const show = (day: UsageDay, element: HTMLElement) => { + setActive({ date: day.date, anchor: element.getBoundingClientRect() }); + }; return ( -
+
{weekBars.map(day => { const percentage = Math.round((day.totalTokens / max) * 100); - const label = day.date.slice(5); + const label = new Intl.DateTimeFormat(locale, { weekday: "short" }).format(new Date(`${day.date}T12:00:00`)); return ( -
setHoverDay(day.date)} - onMouseLeave={() => setHoverDay(current => (current === day.date ? null : current))} + aria-label={dayDetail(day, locale, t)} + onFocus={event => show(day, event.currentTarget)} + onBlur={() => setActive(current => current?.date === day.date ? null : current)} + onPointerEnter={event => show(day, event.currentTarget)} + onPointerDown={event => show(day, event.currentTarget)} + onPointerLeave={event => { + if (event.pointerType !== "touch" && document.activeElement !== event.currentTarget) { + setActive(current => current?.date === day.date ? null : current); + } + }} >
- {hoverDay === day.date && day.totalTokens > 0 && ( -
-
{day.date}
- {day.models.slice(0, 8).map(model => ( -
- - {modelLabel(model.model)} - {formatTokens(model.totalTokens, locale)} -
- ))} -
- )} {formatTokens(day.totalTokens, locale)} {label} -
+ ); })} + {active && activeDay && ( + +
{formatCalendarDate(activeDay.date, locale)}
+
+ {t("usage.heatmap.tooltipRequests", { requests: activeDay.requests })} + {t("usage.heatmap.tooltipTokens", { tokens: formatTokens(activeDay.totalTokens, locale) })} +
+ {activeDay.models.slice(0, 8).map(model => ( +
+ + {modelLabel(model.model)} + {formatTokens(model.totalTokens, locale)} +
+ ))} +
+ )}
); } @@ -427,7 +491,34 @@ function UsageHeatmapPanel({ t: TFn; }) { const heatmapRef = useRef(null); - const [hoverCell, setHoverCell] = useState<{ weekIndex: number; dayIndex: number; x: number; y: number } | null>(null); + const cells = useMemo(() => heatmap.weeks.flat().filter(cell => cell.date), [heatmap]); + const [selectedDate, setSelectedDate] = useState(() => cells.at(-1)?.date ?? ""); + const [tip, setTip] = useState<{ date: string; anchor: DOMRect } | null>(null); + const hintId = useId(); + const rovingDate = cells.some(cell => cell.date === selectedDate) ? selectedDate : (cells.at(-1)?.date ?? ""); + + const selectCell = (cell: HeatmapCell, element: HTMLElement) => { + setSelectedDate(cell.date); + setTip({ date: cell.date, anchor: element.getBoundingClientRect() }); + }; + + const onCellKeyDown = (event: React.KeyboardEvent, cell: HeatmapCell) => { + const index = cells.findIndex(candidate => candidate.date === cell.date); + const offset = event.key === "ArrowUp" ? -1 + : event.key === "ArrowDown" ? 1 + : event.key === "ArrowLeft" ? -7 + : event.key === "ArrowRight" ? 7 + : 0; + if (!offset || index < 0) return; + event.preventDefault(); + const next = cells[Math.max(0, Math.min(cells.length - 1, index + offset))]!; + setSelectedDate(next.date); + const element = heatmapRef.current?.querySelector(`[data-date="${next.date}"]`); + if (element) { + element.focus(); + setTip({ date: next.date, anchor: element.getBoundingClientRect() }); + } + }; useEffect(() => { const element = heatmapRef.current; @@ -445,7 +536,7 @@ function UsageHeatmapPanel({ {range === "7d" ? ( ) : ( -
+
{heatmap.months.map(month => ( @@ -456,36 +547,54 @@ function UsageHeatmapPanel({
{t("usage.dayMon")}{t("usage.dayWed")}{t("usage.dayFri")}
-
+
{heatmap.weeks.map((week, weekIndex) => (
- {week.map((cell, dayIndex) => ( -
cell.date ? ( +
))}
- {hoverCell && (() => { - const cell = heatmap.weeks[hoverCell.weekIndex]?.[hoverCell.dayIndex]; + {t("usage.heatmap.keyboardLabel")} + + {cells.find(cell => cell.date === rovingDate) ? dayDetail(cells.find(cell => cell.date === rovingDate)!, locale, t) : ""} + + {tip && (() => { + const cell = cells.find(candidate => candidate.date === tip.date); if (!cell?.date) return null; return ( -
-
{cell.date}
+ +
{formatCalendarDate(cell.date, locale)}
{t("usage.heatmap.tooltipTokens", { tokens: formatTokens(cell.totalTokens, locale) })}
{t("usage.heatmap.tooltipRequests", { requests: cell.requests })}
-
+ ); })()}
diff --git a/gui/src/styles.css b/gui/src/styles.css index 352d1c53a2..28c9277c1e 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -2592,7 +2592,7 @@ button.prov-account-row.active { cursor: default; } .heatmap-cell-4 { background: var(--green); } .heatmap-legend { display: inline-flex; align-items: center; gap: 4px; font-size: var(--text-label); align-self: flex-end; position: sticky; right: 0; } .heatmap-legend .heatmap-cell { width: 10px; height: 10px; } -.heatmap-tip { position: fixed; z-index: 10; transform: translate(-50%, -100%) translateY(-8px); pointer-events: none; +.heatmap-tip { position: fixed; z-index: var(--z-popover); pointer-events: none; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 6px 10px; box-shadow: var(--shadow-sm); white-space: nowrap; font-size: var(--text-label); } .heatmap-tip-date { font-weight: var(--weight-semibold); color: var(--text); margin-bottom: 2px; } @@ -2603,7 +2603,7 @@ button.prov-account-row.active { cursor: default; } /* 7d view: per-day request bar chart (replaces the year heatmap on the 7d toggle). */ .daybars { display: grid; grid-template-columns: repeat(7, 1fr); gap: 10px; align-items: end; height: 180px; padding-top: 8px; } -.daybar { position: relative; display: flex; flex-direction: column; align-items: center; gap: 6px; height: 100%; justify-content: flex-end; } +.daybar { appearance: none; padding: 0; border: 0; background: transparent; color: inherit; font: inherit; cursor: pointer; position: relative; display: flex; flex-direction: column; align-items: center; gap: 6px; height: 100%; justify-content: flex-end; } .daybar-track { width: 100%; max-width: 48px; flex: 1; display: flex; align-items: flex-end; background: var(--border); border-radius: var(--radius-xs); overflow: hidden; } /* Full-height stack scaled on Y — avoids layout thrash from animating height. */ .daybar-stack { @@ -2622,9 +2622,9 @@ button.prov-account-row.active { cursor: default; } .daybar-count { font-size: var(--text-label); font-weight: var(--weight-semibold); color: var(--text); } .daybar-label { font-size: var(--text-caption); white-space: nowrap; } .daybar:hover .daybar-track { outline: 1px solid var(--border); } -.daybar-tip { position: absolute; bottom: calc(100% + 6px); left: 50%; transform: translateX(-50%); z-index: 5; +.daybar-tip { position: fixed; z-index: var(--z-popover); background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 8px 10px; - min-width: 160px; box-shadow: var(--shadow-sm); pointer-events: none; } + min-width: min(160px, calc(100vw - 16px)); box-shadow: var(--shadow-sm); pointer-events: none; } .daybar-tip-date { font-size: var(--text-label); font-weight: var(--weight-semibold); color: var(--text); margin-bottom: 6px; white-space: nowrap; } .daybar-tip-row { display: flex; align-items: center; gap: 8px; font-size: var(--text-label); line-height: var(--leading-relaxed); } .daybar-tip-swatch { width: 10px; height: 10px; border-radius: var(--radius-2xs); flex-shrink: 0; } diff --git a/gui/src/styles/usage-chart-accessibility.css b/gui/src/styles/usage-chart-accessibility.css new file mode 100644 index 0000000000..caa86736e3 --- /dev/null +++ b/gui/src/styles/usage-chart-accessibility.css @@ -0,0 +1,4 @@ +/* Usage chart controls retain chart styling while exposing keyboard focus. */ +.heatmap-grid button.heatmap-cell { appearance: none; border: 0; padding: 0; cursor: pointer; } +.heatmap-grid button.heatmap-cell:focus-visible, .daybar:focus-visible { outline: 2px solid var(--accent-ring); outline-offset: 2px; } +.chart-overlay { box-sizing: border-box; overflow: hidden; white-space: normal; overflow-wrap: anywhere; } diff --git a/gui/tests/usage-chart-interactions.test.tsx b/gui/tests/usage-chart-interactions.test.tsx new file mode 100644 index 0000000000..02f7392fe1 --- /dev/null +++ b/gui/tests/usage-chart-interactions.test.tsx @@ -0,0 +1,187 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import { clearClientResourceStoresForTests } from "../src/client-resource"; +import { LanguageProvider } from "../src/i18n/provider"; +import Usage from "../src/pages/Usage"; + +const globals = [ + "document", "window", "navigator", "localStorage", "sessionStorage", "fetch", + "ResizeObserver", "IS_REACT_ACT_ENVIRONMENT", +] as const; + +let previous: Record<(typeof globals)[number], PropertyDescriptor | undefined>; +let win: Window; +let root: Root | null = null; + +function isoDay(offset = 0): string { + const day = new Date(); + day.setHours(0, 0, 0, 0); + day.setDate(day.getDate() + offset); + return `${day.getFullYear()}-${String(day.getMonth() + 1).padStart(2, "0")}-${String(day.getDate()).padStart(2, "0")}`; +} + +beforeEach(() => { + previous = Object.fromEntries(globals.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)])) as typeof previous; + win = new Window({ url: "http://localhost/#dashboard" }); + class TestResizeObserver { + observe() {} + disconnect() {} + } + Object.defineProperties(globalThis, { + document: { configurable: true, value: win.document }, + window: { configurable: true, value: win }, + navigator: { configurable: true, value: win.navigator }, + localStorage: { configurable: true, value: win.localStorage }, + sessionStorage: { configurable: true, value: win.sessionStorage }, + ResizeObserver: { configurable: true, value: TestResizeObserver }, + IS_REACT_ACT_ENVIRONMENT: { configurable: true, value: true }, + }); + clearClientResourceStoresForTests(); +}); + +afterEach(async () => { + if (root) await act(async () => root?.unmount()); + root = null; + clearClientResourceStoresForTests(); + win.close(); + for (const key of globals) { + const descriptor = previous[key]; + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else Reflect.deleteProperty(globalThis, key); + } +}); + +async function waitFor(predicate: () => boolean): Promise { + const deadline = Date.now() + 1_000; + while (!predicate()) { + if (Date.now() >= deadline) throw new Error(`timed out: ${document.body.innerHTML}`); + await act(async () => { await new Promise(resolve => win.setTimeout(resolve, 10)); }); + } +} + +async function mount(node: React.ReactNode): Promise { + const container = document.createElement("div"); + document.body.append(container); + const { createRoot } = await import("react-dom/client"); + root = createRoot(container); + await act(async () => { root!.render({node}); }); + return container; +} + +function usagePayload(models: Array<{ provider: string; model: string; requests: number; totalTokens: number }> = []) { + const yesterday = isoDay(-1); + const today = isoDay(); + return { + range: "all", + surface: "all", + since: null, + generatedAt: Date.now(), + summary: { + requests: 7, measuredRequests: 7, reportedRequests: 7, unreportedRequests: 0, + unsupportedRequests: 0, estimatedRequests: 0, inputTokens: 70, outputTokens: 30, + cachedInputTokens: 0, reasoningOutputTokens: 0, totalTokens: 100, coverageRatio: 1, + }, + days: [ + { date: yesterday, requests: 2, measuredRequests: 2, reportedRequests: 2, totalTokens: 25, models: [] }, + { date: today, requests: 5, measuredRequests: 5, reportedRequests: 5, totalTokens: 75, models }, + ], + models: [], providers: [], historyTruncated: false, truncatedPrefixBytes: 0, + entriesTruncated: false, entriesDropped: 0, + }; +} + +test("Usage heatmap exposes one roving entry and day/week keyboard movement", async () => { + globalThis.fetch = (async () => Response.json(usagePayload())) as typeof fetch; + const container = await mount(); + await waitFor(() => container.querySelector(".heatmap-grid") !== null); + + expect(container.querySelector(".heatmap-grid")?.getAttribute("role")).toBe("group"); + expect(container.querySelectorAll(".heatmap-grid [role='gridcell']")).toHaveLength(0); + expect(container.querySelectorAll(".heatmap-grid [tabindex='0']")).toHaveLength(1); + const initial = container.querySelector(".heatmap-grid [tabindex='0']")!; + expect(initial.tagName).toBe("BUTTON"); + const initialDate = initial.dataset.date!; + await act(async () => { initial.focus(); }); + const heatmapTip = document.querySelector(".heatmap-tip"); + expect(heatmapTip?.textContent).toContain("requests"); + expect(heatmapTip?.parentNode === document.body).toBe(true); + expect(container.contains(heatmapTip)).toBe(false); + + await act(async () => { + initial.dispatchEvent(new win.KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true, cancelable: true })); + await new Promise(resolve => win.setTimeout(resolve, 0)); + }); + const previousDay = container.querySelector(".heatmap-grid [tabindex='0']")!; + expect(previousDay.dataset.date).not.toBe(initialDate); + expect(document.activeElement).toBe(previousDay); + + await act(async () => { + previousDay.dispatchEvent(new win.KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true, cancelable: true })); + await new Promise(resolve => win.setTimeout(resolve, 0)); + }); + expect(container.querySelector(".heatmap-grid [tabindex='0']")?.dataset.date).not.toBe(previousDay.dataset.date); +}); + +test("seven-day bars expose the same detail on focus and touch", async () => { + globalThis.fetch = (async () => Response.json(usagePayload())) as typeof fetch; + const container = await mount(); + await waitFor(() => container.querySelector(".heatmap-grid") !== null); + const sevenDay = Array.from(container.querySelectorAll(".usage-segmented-btn")) + .find(button => button.textContent === "7d")!; + await act(async () => { sevenDay.click(); }); + await waitFor(() => container.querySelectorAll(".daybar").length === 7); + + const bars = container.querySelectorAll(".daybar"); + expect(bars).toHaveLength(7); + expect(Array.from(bars).every(bar => bar.tabIndex === 0)).toBe(true); + + const today = bars[6]!; + await act(async () => { today.focus(); }); + const focused = document.querySelector(".daybar-tip")?.textContent; + expect(focused).toContain("5 requests"); + expect(focused).toContain("75 tokens"); + + await act(async () => { today.blur(); }); + await act(async () => { + today.dispatchEvent(new win.PointerEvent("pointerdown", { bubbles: true, pointerType: "touch" })); + }); + expect(document.querySelector(".daybar-tip")?.textContent).toBe(focused); +}); + +test("Usage tooltip portals stay inside viewport gutters at the lower-right edge", async () => { + Object.defineProperties(win, { + innerWidth: { configurable: true, value: 320 }, + innerHeight: { configurable: true, value: 240 }, + }); + const models = Array.from({ length: 8 }, (_, index) => ({ + provider: "openai", + model: `model-${index}`, + requests: 1, + totalTokens: index + 1, + })); + globalThis.fetch = (async () => Response.json(usagePayload(models))) as typeof fetch; + const container = await mount(); + await waitFor(() => container.querySelector(".heatmap-grid") !== null); + const sevenDay = Array.from(container.querySelectorAll(".usage-segmented-btn")) + .find(button => button.textContent === "7d")!; + await act(async () => { sevenDay.click(); }); + await waitFor(() => container.querySelectorAll(".daybar").length === 7); + + const today = container.querySelectorAll(".daybar")[6]!; + Object.defineProperty(today, "getBoundingClientRect", { + configurable: true, + value: () => ({ top: 220, right: 320, bottom: 240, left: 300, width: 20, height: 20, x: 300, y: 220, toJSON() {} }), + }); + await act(async () => { today.focus(); }); + + const tooltip = document.querySelector(".daybar-tip")!; + expect(tooltip.parentNode === document.body).toBe(true); + expect(container.contains(tooltip)).toBe(false); + expect(tooltip.querySelectorAll(".daybar-tip-row")).toHaveLength(9); + expect(parseFloat(tooltip.style.left)).toBeGreaterThanOrEqual(8); + expect(parseFloat(tooltip.style.left) + parseFloat(tooltip.style.maxWidth)).toBeLessThanOrEqual(312); + expect(parseFloat(tooltip.style.bottom)).toBeGreaterThanOrEqual(8); + expect(parseFloat(tooltip.style.maxHeight) + parseFloat(tooltip.style.bottom)).toBeLessThanOrEqual(232); +}); diff --git a/gui/tests/usage-custom-range.test.tsx b/gui/tests/usage-custom-range.test.tsx index 78a3e9b165..3ef6373ccf 100644 --- a/gui/tests/usage-custom-range.test.tsx +++ b/gui/tests/usage-custom-range.test.tsx @@ -221,9 +221,9 @@ test("America/Santiago midnight DST retains final-day activity and tooltip", asy await act(async () => gate.resolve(Response.json(data))); const active = container.querySelector('.heatmap-grid .heatmap-cell:not(.heatmap-cell-0)'); expect(active).not.toBeNull(); - await act(async () => active!.dispatchEvent(new testWindow.MouseEvent("mouseover", { bubbles: true }))); - expect(container.querySelector(".heatmap-tip-date")?.textContent).toBe("2026-09-07"); - expect(container.querySelector(".heatmap-tip")?.textContent).toContain("700"); + await act(async () => active!.dispatchEvent(new testWindow.PointerEvent("pointerover", { bubbles: true }))); + expect(document.querySelector(".heatmap-tip-date")?.textContent).toBe("Sep 7, 2026"); + expect(document.querySelector(".heatmap-tip")?.textContent).toContain("700"); if (process.env.OCX_USAGE_SANTIAGO_CHILD === "1") console.log("OCX_SANTIAGO_CASE_COMPLETED"); }, process.env.OCX_USAGE_SANTIAGO_CHILD === "1" ? 10000 : 15000); @@ -254,8 +254,8 @@ test("Apply submits inclusive bounds once; Clear restores the held preset withou // A one-day historical window must not produce a year grid anchored to today's date. expect(container.querySelectorAll(".heatmap-grid .heatmap-cell")).toHaveLength(7); const activeCell = container.querySelector(".heatmap-grid .heatmap-cell-1")!; - await act(async () => { activeCell.dispatchEvent(new testWindow.MouseEvent("mouseover", { bubbles: true })); }); - expect(container.querySelector('[role="tooltip"]')?.textContent).toContain("2020-09-15"); + await act(async () => { activeCell.dispatchEvent(new testWindow.PointerEvent("pointerover", { bubbles: true })); }); + expect(document.querySelector('[role="tooltip"]')?.textContent).toContain("Sep 15, 2020"); await enter("2020-09-16T10:20", "2020-09-16T10:21"); expect(interval()).toBe(appliedInterval); expect(requests).toHaveLength(2);