feat(dashboard): interactive sankey highlighting and persistent filters

- Highlight full paths through a clicked node or link in the flow Sankey,
  dimming unrelated nodes/links instead of removing them
- Disable VChart built-in emphasis to avoid crash, use custom highlight sets
- Initialize models filter dialog from currently applied filters so manual
  time ranges are not overridden by preferences; auto-pick granularity by range
- Lift user charts time range/granularity/limit to dashboard as controlled state
This commit is contained in:
CaIon
2026-06-20 22:09:03 +08:00
parent 061948011c
commit 8ad83bf62f
15 changed files with 1550 additions and 152 deletions
@@ -16,9 +16,17 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { Fragment, useMemo, useState } from 'react'
import {
Fragment,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { useQuery } from '@tanstack/react-query'
import { VChart } from '@visactor/react-vchart'
import type { EventParamsDefinition, IVChart } from '@visactor/vchart'
import {
Activity,
ChevronRight,
@@ -56,6 +64,8 @@ import {
buildDashboardFlowData,
buildFlowSankeySpec,
buildQueryParams,
flowNodeFilterFromSankeyDatum,
flowSankeyDatumValue,
getDefaultDays,
getFlowStages,
} from '@/features/dashboard/lib'
@@ -66,7 +76,9 @@ import {
} from '@/features/dashboard/lib/flow-selection'
import type {
DashboardFilters,
FlowLinkSelection,
FlowMetric,
FlowNodeFilter,
FlowNodeKind,
FlowOverflowMode,
FlowRole,
@@ -78,6 +90,7 @@ import { useChartTheme } from '@/lib/use-chart-theme'
import { cn } from '@/lib/utils'
import { VCHART_OPTION } from '@/lib/vchart'
import { useAuthStore } from '@/stores/auth-store'
import { FlowNodeFilterControl } from './flow-node-filter'
interface FlowChartsProps {
filters?: DashboardFilters
@@ -89,6 +102,12 @@ const FLOW_METRIC_OPTIONS = [
{ value: 'requests', labelKey: 'By requests', icon: Activity },
] as const
const FLOW_METRIC_LABEL_KEYS: Record<FlowMetric, string> = {
quota: 'Quota',
tokens: 'Tokens',
requests: 'Requests',
}
const FLOW_TOP_LIMIT_OPTIONS = [10, 20, 50, 100] as const
const DEFAULT_FLOW_TOP_NODE_LIMIT = 50
@@ -131,6 +150,15 @@ const FLOW_STAGE_META: Record<
},
}
const FLOW_STAGE_LABEL_KEYS: Record<FlowNodeKind, string> = {
user: FLOW_STAGE_META.user.labelKey,
node: FLOW_STAGE_META.node.labelKey,
token: FLOW_STAGE_META.token.labelKey,
group: FLOW_STAGE_META.group.labelKey,
model: FLOW_STAGE_META.model.labelKey,
channel: FLOW_STAGE_META.channel.labelKey,
}
const FLOW_OTHER_NODE_LABEL_KEYS: Record<FlowNodeKind, string> = {
user: 'Other users',
node: 'Other nodes',
@@ -140,18 +168,113 @@ const FLOW_OTHER_NODE_LABEL_KEYS: Record<FlowNodeKind, string> = {
channel: 'Other channels',
}
type FlowChartPointerEvent = EventParamsDefinition['pointerdown']
function chartRecordValue(
value: unknown
): Record<string, unknown> | undefined {
return value && typeof value === 'object'
? (value as Record<string, unknown>)
: undefined
}
function looksLikeFlowDatum(value: unknown): boolean {
const record = chartRecordValue(value)
if (!record) return false
return (
(record.key !== undefined && record.kind !== undefined) ||
(record.source !== undefined && record.target !== undefined)
)
}
function chartGraphicDatum(value: unknown): unknown {
const record = chartRecordValue(value)
const context = chartRecordValue(record?.context)
const data = context?.data
if (Array.isArray(data)) return data[0]
return data
}
function flowChartEventDatum(event: FlowChartPointerEvent): unknown {
const record = chartRecordValue(event)
if (!record) return undefined
if (record.datum !== undefined && record.datum !== null) return record.datum
const itemRecord = chartRecordValue(record.item)
if (itemRecord?.datum !== undefined && itemRecord.datum !== null) {
return itemRecord.datum
}
const graphicDatum = chartGraphicDatum(record.item)
if (graphicDatum !== undefined && graphicDatum !== null) return graphicDatum
const itemData = itemRecord?.data
if (Array.isArray(itemData)) return itemData[0]
if (itemData !== undefined && itemData !== null) return itemData
return looksLikeFlowDatum(record) ? record : undefined
}
function flowNodeFilterKey(filter: FlowNodeFilter): string {
return `${filter.kind}\u0000${filter.id}`
}
function isSameFlowNodeFilter(
a: FlowNodeFilter | undefined,
b: FlowNodeFilter
): boolean {
return Boolean(a && a.kind === b.kind && a.id === b.id)
}
function toggleSelectedValue(values: string[], value: string): string[] {
return values.includes(value)
? values.filter((item) => item !== value)
: [...values, value]
}
function toggleSelectedNodeFilter(
filters: FlowNodeFilter[],
filter: FlowNodeFilter
): FlowNodeFilter[] {
const key = flowNodeFilterKey(filter)
const hasFilter = filters.some((item) => flowNodeFilterKey(item) === key)
return hasFilter
? filters.filter((item) => flowNodeFilterKey(item) !== key)
: [...filters, filter]
}
function formatFlowMetricNumber(value: number): string {
return Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }).format(
value
)
}
export function FlowCharts(props: FlowChartsProps) {
const { t } = useTranslation()
const { resolvedTheme, themeReady } = useChartTheme()
const chartInstanceRef = useRef<IVChart | null>(null)
const user = useAuthStore((state) => state.auth.user)
const isRoot = Boolean(user?.role && user.role >= ROLE.SUPER_ADMIN)
const isAdmin = Boolean(user?.role && user.role >= ROLE.ADMIN)
const flowRole: FlowRole = isRoot ? 'root' : isAdmin ? 'admin' : 'user'
let flowRole: FlowRole = 'user'
if (isRoot) {
flowRole = 'root'
} else if (isAdmin) {
flowRole = 'admin'
}
const [metric, setMetric] = useState<FlowMetric>('quota')
const [topNodeLimit, setTopNodeLimit] = useState(DEFAULT_FLOW_TOP_NODE_LIMIT)
const [overflowMode, setOverflowMode] =
useState<FlowOverflowMode>('aggregate')
const [selectedUsers, setSelectedUsers] = useState<string[]>([])
const [selectedNodes, setSelectedNodes] = useState<FlowNodeFilter[]>([])
const [activeFlowNode, setActiveFlowNode] = useState<
FlowNodeFilter | undefined
>()
const [activeFlowLink, setActiveFlowLink] = useState<
FlowLinkSelection | undefined
>()
const [hiddenStages, setHiddenStages] = useState<FlowNodeKind[]>([])
const stages = useMemo(() => getFlowStages(flowRole), [flowRole])
@@ -159,6 +282,19 @@ export function FlowCharts(props: FlowChartsProps) {
() => stages.filter((stage) => !hiddenStages.includes(stage)),
[stages, hiddenStages]
)
useEffect(() => {
const visible = new Set(visibleStages)
setSelectedNodes((prev) => {
const next = prev.filter((filter) => visible.has(filter.kind))
return next.length === prev.length ? prev : next
})
setActiveFlowNode((prev) =>
prev && visible.has(prev.kind) ? prev : undefined
)
// The graph reshapes when columns are toggled, so any highlighted edge may
// no longer exist. Drop the link selection rather than leave it dangling.
setActiveFlowLink(undefined)
}, [visibleStages])
const toggleStage = (stage: FlowNodeKind) => {
setHiddenStages((prev) => {
const hidden = new Set(prev)
@@ -209,6 +345,9 @@ export function FlowCharts(props: FlowChartsProps) {
buildDashboardFlowData(isLoading ? [] : (flowRows ?? []), metric, {
role: flowRole,
selectedUsers,
selectedNodes,
activeNode: activeFlowNode,
activeLink: activeFlowLink,
visibleStages,
topNodeLimit,
overflowMode,
@@ -221,6 +360,9 @@ export function FlowCharts(props: FlowChartsProps) {
isLoading,
metric,
overflowMode,
activeFlowNode,
activeFlowLink,
selectedNodes,
selectedUsers,
topNodeLimit,
visibleStages,
@@ -235,6 +377,75 @@ export function FlowCharts(props: FlowChartsProps) {
})),
[flowData.filterOptions.users]
)
const nodeFilterStages = useMemo(
() => visibleStages.filter((stage) => stage !== 'user'),
[visibleStages]
)
const nodeFilterOptions = useMemo(
() =>
flowData.filterOptions.nodes.filter((option) => option.kind !== 'user'),
[flowData.filterOptions.nodes]
)
const metricLabel = t(FLOW_METRIC_LABEL_KEYS[metric])
const formatNodeMetricValue = useCallback(
(value: number) =>
metric === 'quota' ? formatQuota(value) : formatFlowMetricNumber(value),
[metric]
)
// Explicit filters (the chips/dropdown control) narrow the rows that feed the
// chart. They are intentionally independent from the click-to-highlight state
// below so selecting a filter never dims a node, it removes unrelated rows.
const toggleFlowNodeFilter = useCallback((filter: FlowNodeFilter) => {
if (filter.kind === 'user') {
setSelectedUsers((prev) => toggleSelectedValue(prev, filter.id))
return
}
setSelectedNodes((prev) => toggleSelectedNodeFilter(prev, filter))
}, [])
const removeFlowNodeFilter = useCallback((filter: FlowNodeFilter) => {
if (filter.kind === 'user') {
setSelectedUsers((prev) => prev.filter((item) => item !== filter.id))
return
}
const key = flowNodeFilterKey(filter)
setSelectedNodes((prev) =>
prev.filter((item) => flowNodeFilterKey(item) !== key)
)
}, [])
const clearFlowNodeFilters = useCallback(() => {
setSelectedNodes([])
}, [])
// Clicking a node only drives the highlight: keep every node/link on screen
// but emphasize the full paths through the clicked node and dim the rest.
// Clicking the active node again, or clicking empty space, clears it.
const handleChartPointerDown = useCallback((event: FlowChartPointerEvent) => {
const datum = flowChartEventDatum(event)
const filter = flowNodeFilterFromSankeyDatum(datum)
if (filter) {
setActiveFlowLink(undefined)
setActiveFlowNode((prev) =>
isSameFlowNodeFilter(prev, filter) ? undefined : filter
)
return
}
const source = flowSankeyDatumValue(datum, 'source')
const target = flowSankeyDatumValue(datum, 'target')
if (typeof source === 'string' && typeof target === 'string') {
setActiveFlowNode(undefined)
setActiveFlowLink((prev) =>
prev && prev.source === source && prev.target === target
? undefined
: { source, target }
)
return
}
setActiveFlowNode(undefined)
setActiveFlowLink(undefined)
chartInstanceRef.current?.clearState('selected')
chartInstanceRef.current?.clearState('blur')
}, [])
const chartTitle = t('Flow')
const flowSpec = useMemo(
() =>
@@ -252,6 +463,9 @@ export function FlowCharts(props: FlowChartsProps) {
topNodeLimit,
overflowMode,
flowRole,
activeFlowNode ? flowNodeFilterKey(activeFlowNode) : '',
activeFlowLink ? `${activeFlowLink.source}\u0000${activeFlowLink.target}` : '',
selectedNodes.map(flowNodeFilterKey).join(','),
selectedUsers.join(','),
visibleStages.join(','),
flowRows?.length ?? 0,
@@ -267,6 +481,46 @@ export function FlowCharts(props: FlowChartsProps) {
flowError instanceof Error
? flowError.message
: t('Please try again later.')
let chartContent = (
<VChart
key={`flow-${chartKey}`}
spec={{
...flowSpec,
theme: chartTheme,
background: 'transparent',
}}
option={VCHART_OPTION}
onReady={(instance: IVChart) => {
chartInstanceRef.current = instance
}}
onPointerDown={handleChartPointerDown}
/>
)
if (displayState === 'loading') {
chartContent = <Skeleton className='h-full w-full' />
} else if (displayState === 'error') {
chartContent = (
<div className='flex h-full items-center justify-center p-4'>
<Alert variant='destructive' className='max-w-md'>
<CircleAlert />
<AlertTitle>{t('Failed to load')}</AlertTitle>
<AlertDescription>{flowErrorMessage}</AlertDescription>
</Alert>
</div>
)
} else if (displayState === 'empty') {
chartContent = (
<Empty className='h-full border-0 py-12'>
<EmptyHeader>
<EmptyMedia variant='icon'>
<Route />
</EmptyMedia>
<EmptyTitle>{t('No flow data available')}</EmptyTitle>
<EmptyDescription>{t('No data available')}</EmptyDescription>
</EmptyHeader>
</Empty>
)
}
return (
<div className='flex flex-col gap-3'>
@@ -366,6 +620,18 @@ export function FlowCharts(props: FlowChartsProps) {
</TabsList>
</Tabs>
</div>
<FlowNodeFilterControl
stages={nodeFilterStages}
stageLabels={FLOW_STAGE_LABEL_KEYS}
metricLabel={metricLabel}
formatMetricValue={formatNodeMetricValue}
options={nodeFilterOptions}
selectedNodes={selectedNodes}
onToggleNode={toggleFlowNodeFilter}
onRemoveNode={removeFlowNodeFilter}
onClearNodes={clearFlowNodeFilters}
/>
</div>
<div className='flex min-w-0 items-center gap-2 xl:justify-end'>
@@ -447,37 +713,7 @@ export function FlowCharts(props: FlowChartsProps) {
</TooltipProvider>
</div>
<div className='h-[560px] p-1.5 sm:h-[680px] sm:p-2 2xl:h-[760px]'>
{displayState === 'loading' ? (
<Skeleton className='h-full w-full' />
) : displayState === 'error' ? (
<div className='flex h-full items-center justify-center p-4'>
<Alert variant='destructive' className='max-w-md'>
<CircleAlert />
<AlertTitle>{t('Failed to load')}</AlertTitle>
<AlertDescription>{flowErrorMessage}</AlertDescription>
</Alert>
</div>
) : displayState === 'empty' ? (
<Empty className='h-full border-0 py-12'>
<EmptyHeader>
<EmptyMedia variant='icon'>
<Route />
</EmptyMedia>
<EmptyTitle>{t('No flow data available')}</EmptyTitle>
<EmptyDescription>{t('No data available')}</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
<VChart
key={`flow-${chartKey}`}
spec={{
...flowSpec,
theme: chartTheme,
background: 'transparent',
}}
option={VCHART_OPTION}
/>
)}
{chartContent}
</div>
</div>
</div>
@@ -0,0 +1,235 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useMemo } from 'react'
import { Filter, X } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from '@/components/ui/command'
import {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
} from '@/components/ui/popover'
import type {
FlowNodeFilter,
FlowNodeFilterOption,
FlowNodeKind,
} from '@/features/dashboard/types'
interface FlowNodeFilterControlProps {
stages: FlowNodeKind[]
stageLabels: Record<FlowNodeKind, string>
metricLabel: string
formatMetricValue: (value: number) => string
options: FlowNodeFilterOption[]
selectedNodes: FlowNodeFilter[]
onToggleNode: (filter: FlowNodeFilter) => void
onRemoveNode: (filter: FlowNodeFilter) => void
onClearNodes: () => void
}
function flowNodeFilterKey(filter: FlowNodeFilter): string {
return `${filter.kind}\u0000${filter.id}`
}
export function FlowNodeFilterControl(props: FlowNodeFilterControlProps) {
const { t } = useTranslation()
const selectedKeys = useMemo(
() => new Set(props.selectedNodes.map(flowNodeFilterKey)),
[props.selectedNodes]
)
const optionLabels = useMemo(() => {
const labels = new Map<string, FlowNodeFilterOption>()
for (const option of props.options) {
labels.set(
flowNodeFilterKey({ kind: option.kind, id: option.value }),
option
)
}
return labels
}, [props.options])
const optionsByStage = useMemo(
() =>
props.stages
.map((stage) => ({
stage,
options: props.options.filter((option) => option.kind === stage),
}))
.filter((group) => group.options.length > 0),
[props.options, props.stages]
)
const selectedOptions = props.selectedNodes.map((filter) => {
const option = optionLabels.get(flowNodeFilterKey(filter))
return {
...filter,
label: option?.label ?? filter.id,
}
})
const selectedCount = selectedOptions.length
return (
<div className='flex min-w-0 flex-col gap-1.5'>
<span className='text-muted-foreground text-xs font-medium'>
{t('Node filters')}
</span>
<div className='flex min-w-0 flex-wrap items-center gap-1.5'>
<Popover>
<PopoverTrigger
render={
<Button
type='button'
variant='outline'
size='sm'
aria-label={t('Filter by node')}
/>
}
>
<Filter data-icon='inline-start' aria-hidden='true' />
{selectedCount > 0 ? t('Selected nodes') : t('All nodes')}
{selectedCount > 0 && (
<Badge variant='secondary' className='rounded-sm px-1'>
{selectedCount}
</Badge>
)}
</PopoverTrigger>
<PopoverContent
className='w-[min(28rem,calc(100vw-2rem))] p-0'
align='start'
>
<PopoverHeader className='px-3 pt-3'>
<PopoverTitle>{t('Node filters')}</PopoverTitle>
<PopoverDescription>
{t('Value metric')}: {props.metricLabel}
</PopoverDescription>
</PopoverHeader>
<Command>
<CommandInput placeholder={t('Filter by node')} />
<CommandList>
<CommandEmpty>{t('No nodes')}</CommandEmpty>
{optionsByStage.map((group) => {
const stageLabel = t(props.stageLabels[group.stage])
return (
<CommandGroup key={group.stage} heading={stageLabel}>
{group.options.map((option) => {
const key = flowNodeFilterKey({
kind: option.kind,
id: option.value,
})
const metricValueLabel = props.formatMetricValue(
option.valueRaw
)
return (
<CommandItem
key={key}
value={`${stageLabel} ${option.label} ${props.metricLabel} ${metricValueLabel}`}
data-checked={selectedKeys.has(key)}
onSelect={() =>
props.onToggleNode({
kind: option.kind,
id: option.value,
})
}
>
<span
className='size-2.5 shrink-0 rounded-full'
style={{ backgroundColor: option.color }}
aria-hidden='true'
/>
<span className='min-w-0 flex-1 truncate'>
{option.label}
</span>
<span className='text-muted-foreground flex shrink-0 items-center gap-1 text-xs'>
<span>{props.metricLabel}</span>
<span className='font-mono'>
{metricValueLabel}
</span>
</span>
</CommandItem>
)
})}
</CommandGroup>
)
})}
{selectedCount > 0 && (
<>
<CommandSeparator />
<CommandGroup>
<CommandItem
onSelect={props.onClearNodes}
className='justify-center text-center'
>
{t('Clear node filters')}
</CommandItem>
</CommandGroup>
</>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
{selectedOptions.map((option) => (
<Badge
key={flowNodeFilterKey(option)}
variant='secondary'
className='max-w-[14rem] rounded-sm pr-1'
>
<span className='truncate'>
{t(props.stageLabels[option.kind])}: {option.label}
</span>
<button
type='button'
className='hover:bg-muted-foreground/15 flex size-4 shrink-0 items-center justify-center rounded-sm'
aria-label={t('Remove node filter')}
onClick={() =>
props.onRemoveNode({ kind: option.kind, id: option.id })
}
>
<X aria-hidden='true' />
</button>
</Badge>
))}
{selectedCount > 1 && (
<Button
type='button'
variant='ghost'
size='xs'
onClick={props.onClearNodes}
>
{t('Clear')}
</Button>
)}
</div>
</div>
)
}
@@ -51,12 +51,36 @@ import type {
interface ModelsFilterProps {
preferences: DashboardChartPreferences
// The filters currently applied to the dashboard. The dialog edits a copy of
// these so reopening it never discards a manually picked range.
currentFilters: DashboardFilters
onFilterChange: (filters: DashboardFilters) => void
onReset: () => void
titleKey?: string
descriptionKey?: string
}
// Quick-range presets imply a sensible granularity (matching the app's
// range<->granularity pairing), so picking "7 Days" requests daily buckets
// instead of leaving the granularity on its previous value (e.g. hourly).
function granularityForRangeDays(days: number): TimeGranularity {
if (days <= 1) return 'hour'
if (days >= 29) return 'week'
return 'day'
}
// Highlights the matching quick-range button when the applied range spans an
// exact preset; custom ranges leave every quick button unselected.
function detectQuickRangeDays(
filters: DashboardFilters | undefined
): number | null {
const start = filters?.start_timestamp
const end = filters?.end_timestamp
if (!start || !end) return null
const days = Math.round((end.getTime() - start.getTime()) / 86_400_000)
return TIME_RANGE_PRESETS.some((preset) => preset.days === days) ? days : null
}
/**
* Section divider component for better visual organization
*/
@@ -78,20 +102,22 @@ export function ModelsFilter(props: ModelsFilterProps) {
const isAdmin = user?.role && user.role >= 10
const [open, setOpen] = useState(false)
const [filters, setFilters] = useState<DashboardFilters>(() =>
buildDefaultDashboardFilters(props.preferences)
const [filters, setFilters] = useState<DashboardFilters>(
() => props.currentFilters ?? buildDefaultDashboardFilters(props.preferences)
)
const [selectedRange, setSelectedRange] = useState<number | null>(
() => props.preferences.defaultTimeRangeDays
const [selectedRange, setSelectedRange] = useState<number | null>(() =>
detectQuickRangeDays(props.currentFilters)
)
const resetFiltersFromPreferences = () => {
setFilters(buildDefaultDashboardFilters(props.preferences))
setSelectedRange(props.preferences.defaultTimeRangeDays)
}
const handleOpenChange = (nextOpen: boolean) => {
if (nextOpen) resetFiltersFromPreferences()
// Sync the editing state from the applied filters every time the dialog
// opens so a previously applied manual range is preserved.
if (nextOpen) {
const applied =
props.currentFilters ?? buildDefaultDashboardFilters(props.preferences)
setFilters(applied)
setSelectedRange(detectQuickRangeDays(applied))
}
setOpen(nextOpen)
}
@@ -133,6 +159,7 @@ export function ModelsFilter(props: ModelsFilterProps) {
...prev,
start_timestamp: start,
end_timestamp: end,
time_granularity: granularityForRangeDays(days),
}))
setSelectedRange(days)
}
@@ -33,11 +33,13 @@ import {
} from '@/features/dashboard/constants'
import {
getDefaultDays,
getSavedGranularity,
saveGranularity,
processUserChartData,
} from '@/features/dashboard/lib'
import type { ProcessedUserChartData } from '@/features/dashboard/types'
import type {
ProcessedUserChartData,
UserChartsFilters,
} from '@/features/dashboard/types'
let themeManagerPromise: Promise<
(typeof import('@visactor/vchart'))['ThemeManager']
@@ -62,7 +64,12 @@ const USER_CHARTS: {
const TOP_USER_LIMIT_OPTIONS = [5, 10, 20, 50]
export function UserCharts() {
interface UserChartsProps {
filters: UserChartsFilters
onFiltersChange: (filters: UserChartsFilters) => void
}
export function UserCharts(props: UserChartsProps) {
const { t } = useTranslation()
const { resolvedTheme } = useTheme()
const [themeReady, setThemeReady] = useState(false)
@@ -70,41 +77,45 @@ export function UserCharts() {
(typeof import('@visactor/vchart'))['ThemeManager'] | null
>(null)
const [timeGranularity, setTimeGranularity] = useState<TimeGranularity>(() =>
getSavedGranularity()
)
const [selectedRange, setSelectedRange] = useState<number>(() =>
getDefaultDays(timeGranularity)
)
const [topUserLimit, setTopUserLimit] = useState(10)
const [timeRange, setTimeRange] = useState(() => {
const days = getDefaultDays(timeGranularity)
const { start, end } = getRollingDateRange(days)
// The selection is owned by the dashboard parent so it persists across
// sub-section switches; the rolling window is derived from the chosen range.
const timeGranularity = props.filters.timeGranularity
const selectedRange = props.filters.selectedRange
const topUserLimit = props.filters.topUserLimit
const onFiltersChange = props.onFiltersChange
const timeRange = useMemo(() => {
const { start, end } = getRollingDateRange(selectedRange)
return {
start_timestamp: Math.floor(start.getTime() / 1000),
end_timestamp: Math.floor(end.getTime() / 1000),
}
})
}, [selectedRange])
const handleRangeChange = useCallback((days: number) => {
setSelectedRange(days)
const { start, end } = getRollingDateRange(days)
setTimeRange({
start_timestamp: Math.floor(start.getTime() / 1000),
end_timestamp: Math.floor(end.getTime() / 1000),
})
}, [])
const handleRangeChange = useCallback(
(days: number) => {
onFiltersChange({ ...props.filters, selectedRange: days })
},
[onFiltersChange, props.filters]
)
const handleGranularityChange = useCallback(
(g: TimeGranularity) => {
setTimeGranularity(g)
saveGranularity(g)
const days = getDefaultDays(g)
if (days !== selectedRange) {
handleRangeChange(days)
}
onFiltersChange({
...props.filters,
timeGranularity: g,
selectedRange: getDefaultDays(g),
})
},
[selectedRange, handleRangeChange]
[onFiltersChange, props.filters]
)
const handleTopUserLimitChange = useCallback(
(limit: number) => {
onFiltersChange({ ...props.filters, topUserLimit: limit })
},
[onFiltersChange, props.filters]
)
useEffect(() => {
@@ -184,7 +195,7 @@ export function UserCharts() {
<Tabs
value={String(topUserLimit)}
onValueChange={(value) => setTopUserLimit(Number(value))}
onValueChange={(value) => handleTopUserLimitChange(Number(value))}
className='shrink-0'
>
<TabsList>