diff --git a/apps/web/src/components/dashboard-builder/widgets/widget-shell.tsx b/apps/web/src/components/dashboard-builder/widgets/widget-shell.tsx index 5ef8254e..1de7e98a 100644 --- a/apps/web/src/components/dashboard-builder/widgets/widget-shell.tsx +++ b/apps/web/src/components/dashboard-builder/widgets/widget-shell.tsx @@ -1,5 +1,7 @@ import { useMemo, useState, type ReactNode } from "react" +import { useHotkeys } from "@tanstack/react-hotkeys" import { cn } from "@maple/ui/utils" +import { isEditableTarget, isOverlayOpen } from "@/lib/keyboard" import { ChartLegendSlotContext, type ChartLegendItem } from "@maple/ui/components/ui/chart" import { GripDotsIcon, @@ -81,8 +83,46 @@ export function WidgetShell({ const [legendItems, setLegendItems] = useState([]) const legendSlot = useMemo(() => ({ setItems: setLegendItems }), []) + // "F" opens the expand modal for the chart the user is currently pointing at + // or has keyboard focus within. Pointer hover and DOM focus are tracked + // independently so the shortcut works for both mouse and keyboard users and + // neither input clobbers the other; the hotkey fires while either is active. + const [hovered, setHovered] = useState(false) + const [focused, setFocused] = useState(false) + + useHotkeys( + [ + { + hotkey: "F", + callback: (e) => { + if (isEditableTarget(e.target) || isEditableTarget(document.activeElement)) return + if (isOverlayOpen()) return + if (canExpand) setExpanded(true) + }, + }, + ], + { enabled: (hovered || focused) && canExpand, stopPropagation: false }, + ) + return ( - + setHovered(true)} + onMouseLeave={() => setHovered(false)} + onFocus={() => setFocused(true)} + onBlur={(e) => { + // Only blur when focus leaves the card entirely, not when it moves + // between children (where relatedTarget stays inside). + if (!e.currentTarget.contains(e.relatedTarget)) setFocused(false) + }} + >
{isEditable && ( diff --git a/apps/web/src/hooks/use-time-range-keyboard.ts b/apps/web/src/hooks/use-time-range-keyboard.ts new file mode 100644 index 00000000..3dd1c5a1 --- /dev/null +++ b/apps/web/src/hooks/use-time-range-keyboard.ts @@ -0,0 +1,153 @@ +import { useHotkeys, type UseHotkeyDefinition } from "@tanstack/react-hotkeys" +import { useRef } from "react" +import { isEditableTarget, isOverlayOpen } from "@/lib/keyboard" +import { formatForTinybird } from "@/lib/time-utils" +import { normalizeTimestampInput } from "@/lib/timezone-format" + +/** Minimum window width zoom-in can shrink to (1 minute). */ +const MIN_WINDOW_MS = 60 * 1000 +/** Earliest start zoom-out / left-pan may reach (~2 years before now). */ +const MAX_LOOKBACK_MS = 2 * 365 * 24 * 60 * 60 * 1000 + +/** + * Step multipliers. The base fraction is the slice of the current window + * width applied per keypress; Shift makes the action "significantly stronger", + * Control/Meta "significantly smaller". Pan and zoom each have their own base. + */ +const PAN_FRACTION = { base: 0.2, shift: 1.0, fine: 0.04 } as const +const ZOOM_FRACTION = { base: 0.2, shift: 0.5, fine: 0.04 } as const + +type Modifier = "base" | "shift" | "fine" +type ArrowKey = "ArrowLeft" | "ArrowRight" | "ArrowUp" | "ArrowDown" + +function parseMs(warehouse: string): number { + return new Date(normalizeTimestampInput(warehouse)).getTime() +} + +interface ResolvedRange { + startMs: number + endMs: number +} + +/** + * Clamp a window into the allowed band `[now − MAX_LOOKBACK_MS, now]`, + * preserving its width when it fits. If the window is wider than the whole band + * (reachable when a custom range spans more than the lookback), it collapses to + * exactly the full band rather than letting one edge escape past `now` or before + * the floor. Done as a single pass so the two bounds can't leak past each other + * the way two independent `if` clamps could. + */ +function clampToBand(startMs: number, endMs: number): ResolvedRange { + const now = Date.now() + const earliest = now - MAX_LOOKBACK_MS + const width = endMs - startMs + if (width >= MAX_LOOKBACK_MS) return { startMs: earliest, endMs: now } + if (endMs > now) return { startMs: now - width, endMs: now } + if (startMs < earliest) return { startMs: earliest, endMs: earliest + width } + return { startMs, endMs } +} + +/** + * Pan/zoom the [start, end] window for one keypress, clamping so: + * - `end` never exceeds now, + * - `start` never precedes now − MAX_LOOKBACK_MS, + * - the width never falls below MIN_WINDOW_MS. + * Returns `null` when the action is a no-op (already clamped). + */ +function applyKey(key: ArrowKey, mod: Modifier, { startMs, endMs }: ResolvedRange): ResolvedRange | null { + const width = Math.max(MIN_WINDOW_MS, endMs - startMs) + + let nextStart: number + let nextEnd: number + + if (key === "ArrowLeft" || key === "ArrowRight") { + const delta = width * PAN_FRACTION[mod] * (key === "ArrowLeft" ? -1 : 1) + nextStart = startMs + delta + nextEnd = endMs + delta + } else { + const center = (startMs + endMs) / 2 + const fraction = ZOOM_FRACTION[mod] + // Up = zoom in (shrink), Down = zoom out (grow). + const nextWidth = + key === "ArrowUp" ? Math.max(MIN_WINDOW_MS, width * (1 - fraction)) : width * (1 + fraction) + const half = nextWidth / 2 + nextStart = center - half + nextEnd = center + half + } + + const clamped = clampToBand(nextStart, nextEnd) + nextStart = Math.round(clamped.startMs) + nextEnd = Math.round(clamped.endMs) + if (nextEnd - nextStart < MIN_WINDOW_MS) return null + if (nextStart === startMs && nextEnd === endMs) return null + return { startMs: nextStart, endMs: nextEnd } +} + +export interface UseTimeRangeKeyboardControls { + /** Resolved absolute start, warehouse format "YYYY-MM-DD HH:mm:ss" (UTC). */ + start: string + /** Resolved absolute end, warehouse format "YYYY-MM-DD HH:mm:ss" (UTC). */ + end: string + /** When false, the hotkeys stay registered but don't fire. */ + enabled?: boolean + /** Receives the new absolute window in warehouse format. */ + onChange: (range: { startTime: string; endTime: string }) => void +} + +/** + * Arrow-key pan/zoom for a page's selected time window. Operates on the + * resolved absolute range and always writes an absolute range back via + * `onChange`: + * - Left / Right → pan into the past / future (Right clamps at now). + * - Up / Down → zoom in / out around the window center (clamped). + * - Shift → much larger step; Ctrl/Meta → much finer step. + * + * Registered through TanStack Hotkeys. Because the matcher requires exact + * modifier state, each arrow key is registered three times — plain, `Shift+`, + * and `Ctrl+`/`Meta+` — mapping to the base / strong / fine step. The handler + * bails while an editable element is focused or any overlay (dialog, menu, + * listbox) owns the keyboard, so it never hijacks menu/select navigation. + */ +export function useTimeRangeKeyboardControls({ + start, + end, + enabled = true, + onChange, +}: UseTimeRangeKeyboardControls): void { + // Read the latest range/handler from a ref so the callbacks stay stable + // across range changes (TanStack Hotkeys syncs callbacks each render anyway, + // but this keeps the closure reading current values). + const stateRef = useRef({ start, end, onChange }) + stateRef.current = { start, end, onChange } + + const run = (key: ArrowKey, mod: Modifier, event: KeyboardEvent) => { + // Defer to editable fields and keyboard-owning overlays (menu / listbox / + // dialog) so their own arrow navigation keeps working. + if (isEditableTarget(event.target) || isEditableTarget(document.activeElement)) return + if (isOverlayOpen()) return + + const { start: s, end: en, onChange: emit } = stateRef.current + const startMs = parseMs(s) + const endMs = parseMs(en) + if (Number.isNaN(startMs) || Number.isNaN(endMs) || endMs <= startMs) return + + const next = applyKey(key, mod, { startMs, endMs }) + if (!next) return + emit({ + startTime: formatForTinybird(new Date(next.startMs)), + endTime: formatForTinybird(new Date(next.endMs)), + }) + } + + const arrows: ArrowKey[] = ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"] + const definitions: UseHotkeyDefinition[] = arrows.flatMap((key) => [ + { hotkey: { key }, callback: (e) => run(key, "base", e) }, + { hotkey: { key, shift: true }, callback: (e) => run(key, "shift", e) }, + // Ctrl and Meta both map to the fine step; register both since the + // matcher requires exact modifier state. + { hotkey: { key, ctrl: true }, callback: (e) => run(key, "fine", e) }, + { hotkey: { key, meta: true }, callback: (e) => run(key, "fine", e) }, + ]) + + useHotkeys(definitions, { enabled, ignoreInputs: true, stopPropagation: false }) +} diff --git a/apps/web/src/lib/keyboard.ts b/apps/web/src/lib/keyboard.ts index c1cf81c4..71505b00 100644 --- a/apps/web/src/lib/keyboard.ts +++ b/apps/web/src/lib/keyboard.ts @@ -13,3 +13,19 @@ export function isDialogOpen(): boolean { // Base UI marks open popups with a bare `data-open` attribute (not Radix's `data-state="open"`). return document.querySelector('[role="dialog"][data-open], [role="alertdialog"][data-open]') !== null } + +/** + * True when ANY keyboard-owning overlay is open — a dialog, a dropdown menu, or + * a listbox (select / combobox). Page-level capture-phase shortcuts (arrow-key + * pan/zoom, the "F" expand key) must defer to these so the overlay's own + * arrow/typeahead navigation keeps working. Broader than `isDialogOpen` because + * Base UI menus render as `role="menu"` and selects/comboboxes as + * `role="listbox"`, neither of which is a dialog. + */ +export function isOverlayOpen(): boolean { + return ( + document.querySelector( + '[role="dialog"][data-open], [role="alertdialog"][data-open], [role="menu"][data-open], [role="listbox"][data-open]', + ) !== null + ) +} diff --git a/apps/web/src/routes/dashboards/$dashboardId.tsx b/apps/web/src/routes/dashboards/$dashboardId.tsx index 2f166505..99b58ea7 100644 --- a/apps/web/src/routes/dashboards/$dashboardId.tsx +++ b/apps/web/src/routes/dashboards/$dashboardId.tsx @@ -19,6 +19,7 @@ import { import { PageRefreshProvider } from "@/components/time-range-picker/page-refresh-context" import type { WidgetMode } from "@/components/dashboard-builder/types" import { useDashboardStore } from "@/hooks/use-dashboard-store" +import { useTimeRangeKeyboardControls } from "@/hooks/use-time-range-keyboard" import { DashboardHistoryPanel, PreviewedCanvas } from "@/components/dashboard-builder/history" import { historyPanelOpenAtom, previewedVersionAtom } from "@/atoms/dashboard-history-atoms" import { useDashboardVersions } from "@/components/dashboard-builder/history/use-dashboard-history" @@ -39,9 +40,20 @@ export const Route = effectRoute(createFileRoute("/dashboards/$dashboardId"))({ function DashboardRefreshBridge({ children }: { children: ReactNode }) { const { - state: { timeRange }, + state: { timeRange, resolvedTimeRange }, + actions: { setTimeRange }, } = useDashboardTimeRange() const timePreset = timeRange.type === "relative" ? timeRange.value : undefined + + // Arrow-key pan/zoom over the resolved absolute window. Drag-to-zoom writes + // the range the same way (absolute), so this round-trips identically. + useTimeRangeKeyboardControls({ + start: resolvedTimeRange?.startTime ?? "", + end: resolvedTimeRange?.endTime ?? "", + enabled: resolvedTimeRange != null, + onChange: ({ startTime, endTime }) => setTimeRange({ type: "absolute", startTime, endTime }), + }) + return {children} } diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index a633fc8b..689ef135 100644 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -26,6 +26,7 @@ import type { CustomChartTimeSeriesResponse } from "@/api/warehouse/custom-chart import type { ServiceDetailTimeSeriesPoint, ServicesFacetsResponse } from "@/api/warehouse/services" import { disabledResultAtom } from "@/lib/services/atoms/disabled-result-atom" import { applyTimeRangeSearch } from "@/components/time-range-picker/search" +import { useTimeRangeKeyboardControls } from "@/hooks/use-time-range-keyboard" import { zoomRangeToWarehouse } from "@/lib/time-utils" import { isClerkAuthEnabled } from "@/lib/services/common/auth-mode" @@ -165,6 +166,15 @@ function DashboardContent({ [navigate], ) + // Arrow-key pan/zoom over the resolved absolute window (Left/Right pan, + // Up/Down zoom, Shift/Ctrl modifiers). Writes an absolute range with + // `replace` so rapid presses don't stack history entries. + useTimeRangeKeyboardControls({ + start: effectiveStartTime, + end: effectiveEndTime, + onChange: ({ startTime, endTime }) => handleTimeChange({ startTime, endTime }, { replace: true }), + }) + const handleEnvironmentChange = (value: string | null) => { navigate({ search: (prev: Record) => ({ diff --git a/apps/web/src/routes/services/$serviceName.tsx b/apps/web/src/routes/services/$serviceName.tsx index 8f511f2b..0df08e18 100644 --- a/apps/web/src/routes/services/$serviceName.tsx +++ b/apps/web/src/routes/services/$serviceName.tsx @@ -6,6 +6,7 @@ import { Schema } from "effect" import { DashboardLayout } from "@/components/layout/dashboard-layout" import { useEffectiveTimeRange } from "@/hooks/use-effective-time-range" +import { useTimeRangeKeyboardControls } from "@/hooks/use-time-range-keyboard" import { useRetainedRefreshableResultValue } from "@/hooks/use-retained-refreshable-result-value" import { MetricsGrid } from "@/components/dashboard/metrics-grid" import type { @@ -142,6 +143,16 @@ function ServiceDetailContent() { [navigate], ) + // Arrow-key pan/zoom over the resolved absolute window. Writes an absolute + // range (replace, so rapid presses don't stack history entries); only active + // on the Overview tab where the charts live. + useTimeRangeKeyboardControls({ + start: effectiveStartTime, + end: effectiveEndTime, + enabled: (search.tab ?? "overview") === "overview", + onChange: ({ startTime, endTime }) => handleTimeChange({ startTime, endTime }, { replace: true }), + }) + const activeTab: ServiceDetailTabValue = search.tab ?? "overview" const handleTabChange = (value: unknown) => { const next = value === "dependencies" ? "dependencies" : "overview"