From 3f48caf3d8a5622e21cebad123eeae5968a10b61 Mon Sep 17 00:00:00 2001 From: tyl3r-ch Date: Wed, 15 Apr 2026 17:43:53 +0200 Subject: [PATCH 1/8] v6.2.2: Orchestrate dashboard motion --- src/components/cards/MonthMetrics.tsx | 167 ++-- src/components/cards/TodayMetrics.tsx | 159 ++-- src/components/charts/ChartCard.tsx | 87 ++- src/components/charts/CorrelationAnalysis.tsx | 8 +- src/components/charts/CostByModel.tsx | 7 +- src/components/charts/CostByModelOverTime.tsx | 11 +- src/components/charts/CostByWeekday.tsx | 7 +- src/components/charts/CostOverTime.tsx | 16 +- src/components/charts/CumulativeCost.tsx | 16 +- .../charts/DistributionAnalysis.tsx | 6 +- src/components/charts/ModelMix.tsx | 7 +- .../charts/RequestCacheHitRateByModel.tsx | 34 +- src/components/charts/RequestsOverTime.tsx | 32 +- src/components/charts/TokenEfficiency.tsx | 14 +- src/components/charts/TokenTypes.tsx | 7 +- src/components/charts/TokensOverTime.tsx | 49 +- src/components/charts/chart-theme.ts | 85 ++- .../dashboard/DashboardSections.tsx | 720 ++++++++++-------- src/components/dashboard/dashboard-motion.tsx | 167 ++++ .../features/animations/AnimatedBarFill.tsx | 56 ++ .../features/cache-roi/CacheROI.tsx | 5 +- .../features/forecast/CostForecast.tsx | 20 +- .../features/limits/ProviderLimitsSection.tsx | 143 ++-- .../request-quality/RequestQuality.tsx | 56 +- .../features/risk/ConcentrationRisk.tsx | 9 +- tests/frontend/dashboard-motion.test.tsx | 87 +++ 26 files changed, 1195 insertions(+), 780 deletions(-) create mode 100644 src/components/dashboard/dashboard-motion.tsx create mode 100644 src/components/features/animations/AnimatedBarFill.tsx create mode 100644 tests/frontend/dashboard-motion.test.tsx diff --git a/src/components/cards/MonthMetrics.tsx b/src/components/cards/MonthMetrics.tsx index 6a1e03a..04f1690 100644 --- a/src/components/cards/MonthMetrics.tsx +++ b/src/components/cards/MonthMetrics.tsx @@ -13,7 +13,6 @@ import { import { MetricCard } from './MetricCard' import { FormattedValue } from '@/components/ui/formatted-value' import { SectionHeader } from '@/components/ui/section-header' -import { FadeIn } from '@/components/features/animations/FadeIn' import { SECTION_HELP } from '@/lib/help-content' import { formatCurrency, formatMonthYear, localMonth } from '@/lib/formatters' import { getCurrentLocale } from '@/lib/i18n' @@ -144,90 +143,88 @@ export function MonthMetrics({ daily, metrics }: MonthMetricsProps) { description={t('metricCards.month.description')} info={SECTION_HELP.currentMonth} /> - -
- } - subtitle={t('metricCards.month.avgPerDay', { - value: formatCurrency(agg.totalCost / agg.activeDays), - })} - icon={} - trend={ - diffToPrev !== null - ? { value: diffToPrev, label: t('metricCards.month.vsPreviousMonth') } - : null - } - /> - } - icon={} - {...(tokensSubtitle ? { subtitle: tokensSubtitle } : {})} - /> - } - /> - } - {...(modelsSubtitle ? { subtitle: modelsSubtitle } : {})} - /> - } - icon={} - {...(costPerMillionSubtitle ? { subtitle: costPerMillionSubtitle } : {})} - /> - } - subtitle={t('metricCards.month.cacheMix', { - input: wholePercentFormatter.format(ioTotal > 0 ? agg.inputTokens / ioTotal : 0), - output: wholePercentFormatter.format(ioTotal > 0 ? agg.outputTokens / ioTotal : 0), - })} - icon={} - /> - 0 ? ( - - ) : ( - t('common.notAvailable') - ) - } - subtitle={ - agg.requestCount > 0 - ? t('metricCards.month.requestsSubtitle', { - value: (agg.requestCount / agg.activeDays).toFixed(1), - cost: formatCurrency(agg.totalCost / agg.requestCount), - }) - : t('metricCards.month.requestCountersMissing') - } - icon={} - /> - } - icon={} - {...(thinkingSubtitle ? { subtitle: thinkingSubtitle } : {})} - /> -
-
+
+ } + subtitle={t('metricCards.month.avgPerDay', { + value: formatCurrency(agg.totalCost / agg.activeDays), + })} + icon={} + trend={ + diffToPrev !== null + ? { value: diffToPrev, label: t('metricCards.month.vsPreviousMonth') } + : null + } + /> + } + icon={} + {...(tokensSubtitle ? { subtitle: tokensSubtitle } : {})} + /> + } + /> + } + {...(modelsSubtitle ? { subtitle: modelsSubtitle } : {})} + /> + } + icon={} + {...(costPerMillionSubtitle ? { subtitle: costPerMillionSubtitle } : {})} + /> + } + subtitle={t('metricCards.month.cacheMix', { + input: wholePercentFormatter.format(ioTotal > 0 ? agg.inputTokens / ioTotal : 0), + output: wholePercentFormatter.format(ioTotal > 0 ? agg.outputTokens / ioTotal : 0), + })} + icon={} + /> + 0 ? ( + + ) : ( + t('common.notAvailable') + ) + } + subtitle={ + agg.requestCount > 0 + ? t('metricCards.month.requestsSubtitle', { + value: (agg.requestCount / agg.activeDays).toFixed(1), + cost: formatCurrency(agg.totalCost / agg.requestCount), + }) + : t('metricCards.month.requestCountersMissing') + } + icon={} + /> + } + icon={} + {...(thinkingSubtitle ? { subtitle: thinkingSubtitle } : {})} + /> +
) } diff --git a/src/components/cards/TodayMetrics.tsx b/src/components/cards/TodayMetrics.tsx index 7f674f2..5b90f78 100644 --- a/src/components/cards/TodayMetrics.tsx +++ b/src/components/cards/TodayMetrics.tsx @@ -11,7 +11,6 @@ import { useTranslation } from 'react-i18next' import { MetricCard } from './MetricCard' import { FormattedValue } from '@/components/ui/formatted-value' import { SectionHeader } from '@/components/ui/section-header' -import { FadeIn } from '@/components/features/animations/FadeIn' import { SECTION_HELP } from '@/lib/help-content' import { formatCurrency, formatPercent, formatDate, formatTokens } from '@/lib/formatters' import { normalizeModelName } from '@/lib/model-utils' @@ -83,90 +82,86 @@ export function TodayMetrics({ today, metrics }: TodayMetricsProps) { description={t('metricCards.today.description')} info={SECTION_HELP.today} /> - -
- } - icon={} - trend={ - diffToAvg !== null - ? { value: diffToAvg, label: t('metricCards.today.vsAverageShort') } - : null - } - {...(costSubtitle ? { subtitle: costSubtitle } : {})} - /> - + } + icon={} + trend={ + diffToAvg !== null + ? { value: diffToAvg, label: t('metricCards.today.vsAverageShort') } + : null + } + {...(costSubtitle ? { subtitle: costSubtitle } : {})} + /> + 0 ? today.totalTokens / today.requestCount : 0, + ), + })} + /> + } + icon={} + {...(tokensSubtitle ? { subtitle: tokensSubtitle } : {})} + /> + } + {...(modelSubtitle ? { subtitle: modelSubtitle } : {})} + /> + 0 ? today.totalCost / (today.totalTokens / 1_000_000) : 0} + type="currency" + /> + } + icon={} + {...(costPerMillionSubtitle ? { subtitle: costPerMillionSubtitle } : {})} + /> + } + subtitle={t('metricCards.today.cacheShare', { + value: formatPercent((today.cacheReadTokens / (today.totalTokens || 1)) * 100), + })} + icon={} + /> + 0 ? ( 0 ? today.totalTokens / today.requestCount : 0, - ), + value={today.requestCount} + type="number" + label={t('metricCards.today.requestsToday')} + insight={t('metricCards.today.requestsInsight', { + value: formatCurrency(today.totalCost / today.requestCount), })} /> - } - icon={} - {...(tokensSubtitle ? { subtitle: tokensSubtitle } : {})} - /> - } - {...(modelSubtitle ? { subtitle: modelSubtitle } : {})} - /> - 0 ? today.totalCost / (today.totalTokens / 1_000_000) : 0 - } - type="currency" - /> - } - icon={} - {...(costPerMillionSubtitle ? { subtitle: costPerMillionSubtitle } : {})} - /> - } - subtitle={t('metricCards.today.cacheShare', { - value: formatPercent((today.cacheReadTokens / (today.totalTokens || 1)) * 100), - })} - icon={} - /> - 0 ? ( - - ) : ( - t('common.notAvailable') - ) - } - subtitle={requestsSubtitle} - icon={} - /> - } - icon={} - {...(thinkingSubtitle ? { subtitle: thinkingSubtitle } : {})} - /> -
-
+ ) : ( + t('common.notAvailable') + ) + } + subtitle={requestsSubtitle} + icon={} + /> + } + icon={} + {...(thinkingSubtitle ? { subtitle: thinkingSubtitle } : {})} + /> + ) } diff --git a/src/components/charts/ChartCard.tsx b/src/components/charts/ChartCard.tsx index e9f3c7d..c8811c6 100644 --- a/src/components/charts/ChartCard.tsx +++ b/src/components/charts/ChartCard.tsx @@ -8,11 +8,16 @@ import { type ReactNode, } from 'react' import { useTranslation } from 'react-i18next' -import { useInView } from 'framer-motion' +import { motion, useInView } from 'framer-motion' import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card' import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog' import { Maximize2 } from 'lucide-react' import { InfoButton } from '@/components/features/help/InfoButton' +import { + DASHBOARD_MOTION, + useDashboardSectionMotion, +} from '@/components/dashboard/dashboard-motion' +import { CHART_ANIMATION } from './chart-theme' import { cn } from '@/lib/cn' import { buildCsvLine } from '@/lib/csv' import { formatCurrency } from '@/lib/formatters' @@ -48,18 +53,33 @@ export function buildChartCsv(chartData: Record[]): string { ].join('\n') } -const ChartAnimationContext = createContext(false) +interface ChartAnimationState { + active: boolean + delayMs: number +} + +const ChartAnimationContext = createContext({ active: false, delayMs: 0 }) /** Returns whether chart-specific animation should currently run. */ export function useChartAnimationActive() { + return useContext(ChartAnimationContext).active +} + +/** Returns the current chart animation state. */ +export function useChartAnimationState() { return useContext(ChartAnimationContext) } +/** Returns the current chart animation delay in milliseconds. */ +export function useChartAnimationDelay() { + return useContext(ChartAnimationContext).delayMs +} + /** Exposes the current chart animation state to a render prop. */ export function ChartAnimationAware({ children }: { children: (active: boolean) => ReactNode }) { const shouldReduceMotion = useShouldReduceMotion() - const animationActive = useChartAnimationActive() - return <>{children(shouldReduceMotion ? false : animationActive)} + const animationState = useChartAnimationState() + return <>{children(shouldReduceMotion ? false : animationState.active)} } interface ChartRevealProps { @@ -69,20 +89,36 @@ interface ChartRevealProps { /** Wraps chart content in the shared reveal policy for its chart variant. */ export function ChartReveal({ children, variant = 'line' }: ChartRevealProps) { + const shouldReduceMotion = useShouldReduceMotion() + const active = useChartAnimationActive() + const delayMs = useChartAnimationDelay() + const wrapperStyle = { + width: '100%', + height: '100%', + overflow: variant === 'radial' ? 'visible' : 'hidden', + transformOrigin: variant === 'bar' ? 'center bottom' : 'center center', + paddingTop: variant === 'radial' ? 8 : 0, + paddingBottom: variant === 'radial' ? 8 : 0, + boxSizing: 'border-box', + } as const + + if (shouldReduceMotion) { + return
{children}
+ } + return ( -
{children} -
+ ) } @@ -101,10 +137,27 @@ export function ChartCard({ expandedExtra, }: ChartCardProps) { const { t } = useTranslation() + const sectionMotion = useDashboardSectionMotion() const [expanded, setExpanded] = useState(false) const cardRef = useRef(null) const isInView = useInView(cardRef, { once: true, amount: 0.25 }) - const animationActive = isInView || expanded + const animationState = useMemo(() => { + if (expanded) { + return { active: true, delayMs: 0 } + } + + if (sectionMotion) { + return { + active: sectionMotion.sectionVisible, + delayMs: sectionMotion.chartStartDelayMs, + } + } + + return { + active: isInView, + delayMs: DASHBOARD_MOTION.chartStartDelayMs, + } + }, [expanded, isInView, sectionMotion]) const stats = useMemo(() => { if (!chartData || !valueKey) return null @@ -156,7 +209,7 @@ export function ChartCard({ return ( <> - + {header} {renderChildren(false)} @@ -181,7 +234,7 @@ export function ChartCard({ {t('chartCard.expandedDescription')} - +
diff --git a/src/components/charts/CorrelationAnalysis.tsx b/src/components/charts/CorrelationAnalysis.tsx index 8c9fc6c..4162f36 100644 --- a/src/components/charts/CorrelationAnalysis.tsx +++ b/src/components/charts/CorrelationAnalysis.tsx @@ -13,7 +13,7 @@ import { } from 'recharts' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { InfoHeading } from '@/components/features/help/InfoHeading' -import { CHART_COLORS, CHART_MARGIN, CHART_ANIMATION } from './chart-theme' +import { CHART_COLORS, CHART_MARGIN, getScatterAnimationProps } from './chart-theme' import { CHART_HELP } from '@/lib/help-content' import { formatCurrency, @@ -201,9 +201,7 @@ function CorrelationPanel({ fill={color} stroke={color} fillOpacity={0.72} - isAnimationActive={!shouldReduceMotion && animatePoints} - animationBegin={animationBegin} - animationDuration={CHART_ANIMATION.duration} + {...getScatterAnimationProps(!shouldReduceMotion && animatePoints, animationBegin)} /> @@ -302,7 +300,7 @@ export function CorrelationAnalysis({ data }: CorrelationAnalysisProps) { mode="cacheEfficiency" data={cacheVsCostPerRequest} color={CHART_COLORS.cumulative} - animationBegin={CHART_ANIMATION.stagger} + animationBegin={70} xAxisName={t('charts.correlation.cacheRate')} xTickFormatter={(value) => formatPercent(value, 0)} yAxisName={t('charts.correlation.costPerRequestAxis')} diff --git a/src/components/charts/CostByModel.tsx b/src/components/charts/CostByModel.tsx index eb64efb..c107901 100644 --- a/src/components/charts/CostByModel.tsx +++ b/src/components/charts/CostByModel.tsx @@ -2,7 +2,7 @@ import { ResponsiveContainer, PieChart, Pie, Cell, Tooltip, Legend } from 'recha import { useTranslation } from 'react-i18next' import { ChartCard, ChartAnimationAware, ChartReveal } from './ChartCard' import { CustomTooltip } from './CustomTooltip' -import { CHART_ANIMATION } from './chart-theme' +import { getRadialAnimationProps } from './chart-theme' import { getModelColor } from '@/lib/model-utils' import { formatCurrency, formatPercent } from '@/lib/formatters' import { CHART_HELP } from '@/lib/help-content' @@ -84,10 +84,7 @@ export function CostByModel({ data }: CostByModelProps) { paddingAngle={2} dataKey="value" nameKey="name" - isAnimationActive={animate} - animationDuration={CHART_ANIMATION.duration} - animationBegin={CHART_ANIMATION.stagger} - animationEasing={CHART_ANIMATION.easing} + {...getRadialAnimationProps(animate)} label={false} > {data.map((entry) => ( diff --git a/src/components/charts/CostByModelOverTime.tsx b/src/components/charts/CostByModelOverTime.tsx index 55d000b..b6488f5 100644 --- a/src/components/charts/CostByModelOverTime.tsx +++ b/src/components/charts/CostByModelOverTime.tsx @@ -12,7 +12,7 @@ import { useTranslation } from 'react-i18next' import { ChartCard, ChartAnimationAware, ChartReveal } from './ChartCard' import { ChartLegend } from './ChartLegend' import { CustomTooltip } from './CustomTooltip' -import { CHART_COLORS, CHART_MARGIN, CHART_ANIMATION } from './chart-theme' +import { CHART_COLORS, CHART_MARGIN, getLineAnimationProps } from './chart-theme' import { getModelColor } from '@/lib/model-utils' import type { ModelCostChartPoint } from '@/lib/data-transforms' import { coerceNumber, formatCurrency, formatDateAxis } from '@/lib/formatters' @@ -82,9 +82,7 @@ export function CostByModelOverTime({ data, models }: CostByModelOverTimeProps) strokeWidth={2} strokeDasharray="5 4" connectNulls - isAnimationActive={animate} - animationBegin={CHART_ANIMATION.stagger * (index % 5)} - animationDuration={CHART_ANIMATION.slowDuration} + {...getLineAnimationProps(animate, { order: index % 5, role: 'secondary' })} /> ))} @@ -150,10 +148,7 @@ export function CostByModelOverTime({ data, models }: CostByModelOverTimeProps) name={model} dot={false} strokeWidth={1.5} - isAnimationActive={animate} - animationBegin={CHART_ANIMATION.stagger * (index % 5)} - animationDuration={CHART_ANIMATION.duration} - animationEasing={CHART_ANIMATION.easing} + {...getLineAnimationProps(animate, { order: index % 5 })} /> ))} diff --git a/src/components/charts/CostByWeekday.tsx b/src/components/charts/CostByWeekday.tsx index e3485bd..8b21f2b 100644 --- a/src/components/charts/CostByWeekday.tsx +++ b/src/components/charts/CostByWeekday.tsx @@ -12,7 +12,7 @@ import { useState, useId } from 'react' import { useTranslation } from 'react-i18next' import { ChartCard, ChartAnimationAware, ChartReveal } from './ChartCard' import { CustomTooltip } from './CustomTooltip' -import { CHART_COLORS, CHART_MARGIN, CHART_ANIMATION } from './chart-theme' +import { CHART_COLORS, CHART_MARGIN, getBarAnimationProps } from './chart-theme' import { coerceNumber, formatCurrency } from '@/lib/formatters' import { CHART_HELP } from '@/lib/help-content' import type { WeekdayData } from '@/types' @@ -105,10 +105,7 @@ export function CostByWeekday({ data }: CostByWeekdayProps) { dataKey="cost" radius={[4, 4, 0, 0]} name={t('charts.costByWeekday.averageCost')} - isAnimationActive={animate} - animationBegin={CHART_ANIMATION.stagger} - animationDuration={CHART_ANIMATION.duration} - animationEasing={CHART_ANIMATION.easing} + {...getBarAnimationProps(animate)} > {data.map((_, index) => { let fill = `url(#${gid('weekday')})` diff --git a/src/components/charts/CostOverTime.tsx b/src/components/charts/CostOverTime.tsx index dd269cc..3676214 100644 --- a/src/components/charts/CostOverTime.tsx +++ b/src/components/charts/CostOverTime.tsx @@ -14,7 +14,12 @@ import { import { ChartCard, ChartAnimationAware, ChartReveal } from './ChartCard' import { ChartLegend } from './ChartLegend' import { CustomTooltip } from './CustomTooltip' -import { CHART_COLORS, CHART_MARGIN, CHART_ANIMATION } from './chart-theme' +import { + CHART_COLORS, + CHART_MARGIN, + getAreaAnimationProps, + getLineAnimationProps, +} from './chart-theme' import { coerceNumber, formatCurrency, formatDateAxis } from '@/lib/formatters' import { CHART_HELP } from '@/lib/help-content' import type { ChartDataPoint } from '@/types' @@ -126,10 +131,7 @@ export function CostOverTime({ data, onClickDay }: CostOverTimeProps) { fill: 'hsl(var(--background))', }} dot={false} - isAnimationActive={animate} - animationBegin={0} - animationDuration={CHART_ANIMATION.duration} - animationEasing={CHART_ANIMATION.easing} + {...getAreaAnimationProps(animate)} /> diff --git a/src/components/charts/CumulativeCost.tsx b/src/components/charts/CumulativeCost.tsx index 170a9d1..fc05ef0 100644 --- a/src/components/charts/CumulativeCost.tsx +++ b/src/components/charts/CumulativeCost.tsx @@ -12,7 +12,12 @@ import { } from 'recharts' import { ChartCard, ChartAnimationAware, ChartReveal } from './ChartCard' import { CustomTooltip } from './CustomTooltip' -import { CHART_COLORS, CHART_MARGIN, CHART_ANIMATION } from './chart-theme' +import { + CHART_COLORS, + CHART_MARGIN, + getAreaAnimationProps, + getLineAnimationProps, +} from './chart-theme' import { coerceNumber, formatCurrency, formatDateAxis } from '@/lib/formatters' import { computeCurrentMonthForecast } from '@/lib/calculations' import { CHART_HELP } from '@/lib/help-content' @@ -113,10 +118,7 @@ export function CumulativeCost({ data, rawData }: CumulativeCostProps) { fill: 'hsl(var(--background))', }} dot={false} - isAnimationActive={animate} - animationBegin={0} - animationDuration={CHART_ANIMATION.duration} - animationEasing={CHART_ANIMATION.easing} + {...getAreaAnimationProps(animate)} connectNulls={false} /> diff --git a/src/components/charts/DistributionAnalysis.tsx b/src/components/charts/DistributionAnalysis.tsx index 5522a02..ce4ad15 100644 --- a/src/components/charts/DistributionAnalysis.tsx +++ b/src/components/charts/DistributionAnalysis.tsx @@ -12,7 +12,7 @@ import { } from 'recharts' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { InfoHeading } from '@/components/features/help/InfoHeading' -import { CHART_COLORS, CHART_MARGIN, CHART_ANIMATION } from './chart-theme' +import { CHART_COLORS, CHART_MARGIN, getBarAnimationProps } from './chart-theme' import { CHART_HELP } from '@/lib/help-content' import { formatCurrency, formatNumber, formatTokens, periodLabel } from '@/lib/formatters' import { useShouldReduceMotion } from '@/lib/motion' @@ -195,9 +195,7 @@ export function DistributionAnalysis({ data, viewMode = 'daily' }: DistributionA dataKey="count" radius={[6, 6, 0, 0]} fill={`url(#${uid}-distribution-${index})`} - isAnimationActive={!shouldReduceMotion} - animationBegin={CHART_ANIMATION.stagger * index} - animationDuration={CHART_ANIMATION.duration} + {...getBarAnimationProps(!shouldReduceMotion, index)} > {distribution.data.map((_, binIndex) => { const intensity = diff --git a/src/components/charts/ModelMix.tsx b/src/components/charts/ModelMix.tsx index 6c1a216..bb2b9c9 100644 --- a/src/components/charts/ModelMix.tsx +++ b/src/components/charts/ModelMix.tsx @@ -10,7 +10,7 @@ import { Tooltip, } from 'recharts' import { ChartCard, ChartAnimationAware, ChartReveal } from './ChartCard' -import { CHART_COLORS, CHART_MARGIN, CHART_ANIMATION } from './chart-theme' +import { CHART_COLORS, CHART_MARGIN, getAreaAnimationProps } from './chart-theme' import { CHART_HELP } from '@/lib/help-content' import { getModelColor, normalizeModelName } from '@/lib/model-utils' import { coerceNumber, formatDateAxis, formatPercent } from '@/lib/formatters' @@ -141,10 +141,7 @@ export function ModelMix({ data }: ModelMixProps) { strokeOpacity={0.6} fill={`url(#${id})`} name={model} - isAnimationActive={animate} - animationBegin={CHART_ANIMATION.stagger * (index % 5)} - animationDuration={CHART_ANIMATION.duration} - animationEasing={CHART_ANIMATION.easing} + {...getAreaAnimationProps(animate, { order: index % 5, role: 'stacked' })} /> ) })} diff --git a/src/components/charts/RequestCacheHitRateByModel.tsx b/src/components/charts/RequestCacheHitRateByModel.tsx index 36806a7..c2e5c06 100644 --- a/src/components/charts/RequestCacheHitRateByModel.tsx +++ b/src/components/charts/RequestCacheHitRateByModel.tsx @@ -16,7 +16,13 @@ import { } from 'recharts' import { ChartAnimationAware, ChartCard, ChartReveal } from './ChartCard' import { ChartLegend } from './ChartLegend' -import { CHART_ANIMATION, CHART_COLORS, CHART_MARGIN } from './chart-theme' +import { + CHART_COLORS, + CHART_MARGIN, + getAreaAnimationProps, + getBarAnimationProps, + getLineAnimationProps, +} from './chart-theme' import { CustomTooltip } from './CustomTooltip' import { CHART_HELP } from '@/lib/help-content' import { computeCacheHitRateByModel, computeMovingAverage } from '@/lib/calculations' @@ -315,9 +321,7 @@ export function RequestCacheHitRateByModel({ stroke: CHART_COLORS.cost, fill: 'hsl(var(--background))', }} - isAnimationActive={animate} - animationDuration={CHART_ANIMATION.duration} - animationEasing={CHART_ANIMATION.easing} + {...getAreaAnimationProps(animate)} /> {lineSeries.map((series, index) => ( ))} @@ -416,9 +417,7 @@ export function RequestCacheHitRateByModel({ name={t('charts.requestCacheHitRate.totalRate')} radius={[0, 4, 4, 0]} fill={CHART_COLORS.cacheRead} - isAnimationActive={animate} - animationDuration={CHART_ANIMATION.duration} - animationEasing={CHART_ANIMATION.easing} + {...getBarAnimationProps(animate)} > {barData.map((entry) => ( {barData.map((entry) => ( {(summary?.topModels ?? []).map(([model], index) => ( ))} @@ -369,8 +371,7 @@ export function RequestsOverTime({ data, viewMode = 'daily', onClickDay }: Reque stroke: CHART_COLORS.cumulative, fill: 'hsl(var(--background))', }} - isAnimationActive={animate} - animationDuration={CHART_ANIMATION.duration} + {...getAreaAnimationProps(animate)} /> {visibleModels.map((model, index) => ( ))} @@ -421,10 +418,7 @@ export function RequestsOverTime({ data, viewMode = 'daily', onClickDay }: Reque paddingAngle={2} dataKey="value" nameKey="name" - isAnimationActive={animate} - animationDuration={CHART_ANIMATION.duration} - animationBegin={CHART_ANIMATION.stagger} - animationEasing={CHART_ANIMATION.easing} + {...getRadialAnimationProps(animate)} > {donutData.map((entry) => ( diff --git a/src/components/charts/TokenEfficiency.tsx b/src/components/charts/TokenEfficiency.tsx index a8dc871..115e154 100644 --- a/src/components/charts/TokenEfficiency.tsx +++ b/src/components/charts/TokenEfficiency.tsx @@ -13,7 +13,12 @@ import { } from 'recharts' import { ChartCard, ChartAnimationAware, ChartReveal } from './ChartCard' import { CustomTooltip } from './CustomTooltip' -import { CHART_COLORS, CHART_MARGIN, CHART_ANIMATION } from './chart-theme' +import { + CHART_COLORS, + CHART_MARGIN, + getAreaAnimationProps, + getLineAnimationProps, +} from './chart-theme' import { computeMovingAverage } from '@/lib/calculations' import { CHART_HELP } from '@/lib/help-content' import { coerceNumber, formatCurrency, formatDateAxis } from '@/lib/formatters' @@ -104,8 +109,7 @@ export function TokenEfficiency({ data }: TokenEfficiencyProps) { strokeWidth={1.5} name={t('charts.tokenEfficiency.series')} dot={false} - isAnimationActive={animate} - animationDuration={CHART_ANIMATION.duration} + {...getAreaAnimationProps(animate)} /> diff --git a/src/components/charts/TokenTypes.tsx b/src/components/charts/TokenTypes.tsx index 2b7d89d..7000c30 100644 --- a/src/components/charts/TokenTypes.tsx +++ b/src/components/charts/TokenTypes.tsx @@ -2,7 +2,7 @@ import { ResponsiveContainer, PieChart, Pie, Cell, Tooltip, Legend } from 'recha import { useTranslation } from 'react-i18next' import { ChartCard, ChartAnimationAware, ChartReveal } from './ChartCard' import { CustomTooltip } from './CustomTooltip' -import { CHART_COLORS, CHART_ANIMATION } from './chart-theme' +import { CHART_COLORS, getRadialAnimationProps } from './chart-theme' import { formatTokens } from '@/lib/formatters' import { CHART_HELP } from '@/lib/help-content' @@ -76,10 +76,7 @@ export function TokenTypes({ data }: TokenTypesProps) { paddingAngle={2} dataKey="value" nameKey="name" - isAnimationActive={animate} - animationDuration={CHART_ANIMATION.duration} - animationBegin={CHART_ANIMATION.stagger} - animationEasing={CHART_ANIMATION.easing} + {...getRadialAnimationProps(animate)} > {data.map((entry) => ( @@ -236,8 +238,7 @@ export function TokensOverTime({ data, onClickDay }: TokensOverTimeProps) { fill={`url(#${gid('cacheRead')})`} strokeWidth={1.5} name="Cache Read" - isAnimationActive={animate} - animationDuration={CHART_ANIMATION.duration} + {...getAreaAnimationProps(animate)} /> @@ -329,8 +325,7 @@ export function TokensOverTime({ data, onClickDay }: TokensOverTimeProps) { fill={`url(#${gid('output')})`} strokeWidth={1.5} name="Output" - isAnimationActive={animate} - animationDuration={CHART_ANIMATION.duration} + {...getAreaAnimationProps(animate)} /> @@ -418,8 +408,7 @@ export function TokensOverTime({ data, onClickDay }: TokensOverTimeProps) { fill={`url(#${gid('thinking')})`} strokeWidth={1.5} name="Thinking" - isAnimationActive={animate} - animationDuration={CHART_ANIMATION.duration} + {...getAreaAnimationProps(animate)} /> diff --git a/src/components/charts/chart-theme.ts b/src/components/charts/chart-theme.ts index f17ccb1..284b92e 100644 --- a/src/components/charts/chart-theme.ts +++ b/src/components/charts/chart-theme.ts @@ -18,10 +18,89 @@ export const CHART_MARGIN = { top: 5, right: 10, left: 10, bottom: 5 } /** Defines the shared chart animation timings. */ export const CHART_ANIMATION = { - duration: 800, + duration: 760, easing: 'ease-out' as const, - stagger: 140, - slowDuration: 1200, + stagger: 70, + slowDuration: 900, + chartStartDelay: 120, + barDuration: 520, + radialDuration: 700, + revealDuration: 360, +} + +type SeriesRole = 'primary' | 'secondary' | 'stacked' + +/** Builds the shared animation props for line-like series. */ +export function getLineAnimationProps( + active: boolean, + { + order = 0, + role = 'primary', + }: { + order?: number + role?: SeriesRole + } = {}, +) { + const delayOffset = + role === 'secondary' + ? 140 + order * CHART_ANIMATION.stagger + : role === 'stacked' + ? order * CHART_ANIMATION.stagger + : order * CHART_ANIMATION.stagger + + return { + isAnimationActive: active, + animationBegin: CHART_ANIMATION.chartStartDelay + delayOffset, + animationDuration: + role === 'secondary' ? CHART_ANIMATION.slowDuration : CHART_ANIMATION.duration, + animationEasing: CHART_ANIMATION.easing, + } +} + +/** Builds the shared animation props for area-like series. */ +export function getAreaAnimationProps( + active: boolean, + { + order = 0, + role = 'primary', + }: { + order?: number + role?: SeriesRole + } = {}, +) { + return { + ...getLineAnimationProps(active, { order, role }), + } +} + +/** Builds the shared animation props for bar-like series. */ +export function getBarAnimationProps(active: boolean, order = 0) { + return { + isAnimationActive: active, + animationBegin: CHART_ANIMATION.chartStartDelay + order * 90, + animationDuration: CHART_ANIMATION.barDuration, + animationEasing: CHART_ANIMATION.easing, + } +} + +/** Builds the shared animation props for donut/pie/radial series. */ +export function getRadialAnimationProps(active: boolean, order = 0) { + return { + isAnimationActive: active, + animationBegin: CHART_ANIMATION.chartStartDelay + 20 + order * 80, + animationDuration: CHART_ANIMATION.radialDuration, + animationEasing: CHART_ANIMATION.easing, + } +} + +/** Builds the shared animation props for scatter-like series. */ +export function getScatterAnimationProps(active: boolean, delayOffsetMs = 0) { + return { + isAnimationActive: active, + animationBegin: CHART_ANIMATION.chartStartDelay + delayOffsetMs, + animationDuration: CHART_ANIMATION.barDuration, + animationEasing: CHART_ANIMATION.easing, + } } /** Generates a CSS-safe gradient id from an arbitrary name. */ diff --git a/src/components/dashboard/DashboardSections.tsx b/src/components/dashboard/DashboardSections.tsx index 009a728..4143aa4 100644 --- a/src/components/dashboard/DashboardSections.tsx +++ b/src/components/dashboard/DashboardSections.tsx @@ -1,4 +1,4 @@ -import { Fragment, Suspense, lazy } from 'react' +import { Fragment, Suspense, lazy, type ReactNode } from 'react' import { useTranslation } from 'react-i18next' import { PrimaryMetrics } from '../cards/PrimaryMetrics' import { SecondaryMetrics } from '../cards/SecondaryMetrics' @@ -9,11 +9,11 @@ import { CostByModel } from '../charts/CostByModel' import { HeatmapCalendar } from '../features/heatmap/HeatmapCalendar' import { UsageInsights } from '../features/insights/UsageInsights' import { ConcentrationRisk } from '../features/risk/ConcentrationRisk' -import { FadeIn } from '../features/animations/FadeIn' import { SectionHeader } from '../ui/section-header' import { ExpandableCard } from '../ui/expandable-card' import { ChartCardSkeleton } from '../ui/skeleton' import { ErrorBoundary } from '../ui/error-boundary' +import { AnimatedDashboardSection } from './dashboard-motion' import { SECTION_HELP } from '@/lib/help-content' import { cn } from '@/lib/cn' import type { ModelCostChartPoint } from '@/lib/data-transforms' @@ -225,148 +225,201 @@ export function DashboardSections({
) - const renderLazySection = (content: React.ReactNode, className?: string) => ( + const renderLazySection = (content: ReactNode, className?: string) => ( {content} ) + const sectionPlaceholderClassName: Record = { + insights: 'min-h-[260px]', + metrics: 'min-h-[320px]', + today: 'min-h-[320px]', + currentMonth: 'min-h-[360px]', + activity: 'min-h-[360px]', + forecastCache: 'min-h-[420px]', + limits: 'min-h-[480px]', + costAnalysis: 'min-h-[980px]', + tokenAnalysis: 'min-h-[380px]', + requestAnalysis: 'min-h-[760px]', + advancedAnalysis: 'min-h-[760px]', + comparisons: 'min-h-[420px]', + tables: 'min-h-[900px]', + } + + const renderAnimatedSection = ( + sectionId: DashboardSectionId, + children: ReactNode, + { eager = false }: { eager?: boolean } = {}, + ) => { + const sectionAnchorId = + sectionId === 'costAnalysis' + ? 'charts' + : sectionId === 'currentMonth' + ? 'current-month' + : sectionId === 'tokenAnalysis' + ? 'token-analysis' + : sectionId === 'requestAnalysis' + ? 'request-analysis' + : sectionId === 'advancedAnalysis' + ? 'advanced-analysis' + : sectionId + + return ( + + {children} + + ) + } + const renderSection = (sectionId: DashboardSectionId) => { switch (sectionId) { case 'insights': - return sectionVisibility.insights ? ( -
- -
- ) : null - case 'metrics': - return sectionVisibility.metrics ? ( -
- - - - - -
- , + { eager: true }, + ) + : null + case 'metrics': + return sectionVisibility.metrics + ? renderAnimatedSection( + 'metrics', + <> + + entry.totalCost)} + totalCalendarDays={totalCalendarDays} viewMode={viewMode} /> -
-
-
- ) : null +
+ entry.totalCost)} + viewMode={viewMode} + /> +
+ , + { eager: true }, + ) + : null case 'today': - return sectionVisibility.today && todayData ? ( -
- -
- ) : null + return sectionVisibility.today && todayData + ? renderAnimatedSection('today', , { + eager: true, + }) + : null case 'currentMonth': - return sectionVisibility.currentMonth && hasCurrentMonthData ? ( -
- -
- ) : null + return sectionVisibility.currentMonth && hasCurrentMonthData + ? renderAnimatedSection( + 'currentMonth', + , + { + eager: true, + }, + ) + : null case 'activity': - return sectionVisibility.activity ? ( -
- - -
- - - + -
-
-
- ) : null +
+ + + +
+ , + ) + : null case 'forecastCache': - return sectionVisibility.forecastCache ? ( -
- - -
- {renderLazySection( - - - , - 'h-[360px]', - )} - {renderLazySection( - - - , - 'h-[360px]', - )} -
-
-
- ) : null + return sectionVisibility.forecastCache + ? renderAnimatedSection( + 'forecastCache', + <> + +
+ {renderLazySection( + + + , + 'h-[360px]', + )} + {renderLazySection( + + + , + 'h-[360px]', + )} +
+ , + ) + : null case 'limits': - return sectionVisibility.limits ? ( -
- - {renderLazySection( + return sectionVisibility.limits + ? renderAnimatedSection( + 'limits', + renderLazySection( , 'h-[420px]', - )} - -
- ) : null + ), + ) + : null case 'costAnalysis': - return sectionVisibility.costAnalysis ? ( -
- - -
-
- + return sectionVisibility.costAnalysis + ? renderAnimatedSection( + 'costAnalysis', + <> + +
+
+ +
+
- -
- - -
- {renderLazySection( - , - 'h-[320px]', - )} -
-
- -
- {renderLazySection( - , - 'h-[320px]', - )} - {renderLazySection(, 'h-[320px]')} -
-
- -
- {renderLazySection(, 'h-[320px]')} - {renderLazySection(, 'h-[320px]')} -
-
-
- ) : null +
+ {renderLazySection( + , + 'h-[320px]', + )} +
+
+ {renderLazySection( + , + 'h-[320px]', + )} + {renderLazySection(, 'h-[320px]')} +
+
+ {renderLazySection(, 'h-[320px]')} + {renderLazySection(, 'h-[320px]')} +
+ , + ) + : null case 'tokenAnalysis': - return sectionVisibility.tokenAnalysis ? ( -
- - -
- {renderLazySection( - , - 'h-[320px]', - )} - {renderLazySection(, 'h-[320px]')} -
-
-
- ) : null + return sectionVisibility.tokenAnalysis + ? renderAnimatedSection( + 'tokenAnalysis', + <> + +
+ {renderLazySection( + , + 'h-[320px]', + )} + {renderLazySection(, 'h-[320px]')} +
+ , + ) + : null case 'requestAnalysis': - return sectionVisibility.requestAnalysis && metrics.hasRequestData ? ( -
- - - {renderLazySection( - , - 'h-[320px]', - )} - - -
+ return sectionVisibility.requestAnalysis && metrics.hasRequestData + ? renderAnimatedSection( + 'requestAnalysis', + <> + {renderLazySection( - , 'h-[320px]', )} -
-
- -
- {renderLazySection( - , - 'h-[280px]', - )} -
-
-
- ) : null +
+ {renderLazySection( + , + 'h-[320px]', + )} +
+
+ {renderLazySection( + , + 'h-[280px]', + )} +
+ , + ) + : null case 'advancedAnalysis': - return sectionVisibility.advancedAnalysis ? ( -
- - -
- {renderLazySection( - , - 'h-[320px]', - )} - + -
-
- -
- {renderLazySection(, 'h-[320px]')} -
-
-
- ) : null +
+ {renderLazySection( + , + 'h-[320px]', + )} + +
+
+ {renderLazySection(, 'h-[320px]')} +
+ , + ) + : null case 'comparisons': - return sectionVisibility.comparisons ? ( -
- - -
- {renderLazySection( - - - , - 'h-[360px]', - )} - {renderLazySection( - - - , - 'h-[360px]', - )} -
-
-
- ) : null + return sectionVisibility.comparisons + ? renderAnimatedSection( + 'comparisons', + <> + +
+ {renderLazySection( + + + , + 'h-[360px]', + )} + {renderLazySection( + + + , + 'h-[360px]', + )} +
+ , + ) + : null case 'tables': - return sectionVisibility.tables ? ( -
- - - {renderLazySection( - , - 'h-[320px]', - )} - - -
+ return sectionVisibility.tables + ? renderAnimatedSection( + 'tables', + <> + {renderLazySection( - , 'h-[320px]', )} -
-
- -
- {renderLazySection( - , - 'h-[360px]', - )} -
-
-
- ) : null +
+ {renderLazySection( + , + 'h-[320px]', + )} +
+
+ {renderLazySection( + , + 'h-[360px]', + )} +
+ , + ) + : null default: return null } diff --git a/src/components/dashboard/dashboard-motion.tsx b/src/components/dashboard/dashboard-motion.tsx new file mode 100644 index 0000000..b57a582 --- /dev/null +++ b/src/components/dashboard/dashboard-motion.tsx @@ -0,0 +1,167 @@ +import { + createContext, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from 'react' +import { motion } from 'framer-motion' +import { cn } from '@/lib/cn' +import { useShouldReduceMotion } from '@/lib/motion' + +/** Defines the shared dashboard motion timings for section reveal and child chart orchestration. */ +export const DASHBOARD_MOTION = { + sectionPreloadMargin: '0px 0px 18% 0px', + sectionRevealAmount: 0.18, + sectionRevealOffset: 14, + sectionRevealDuration: 0.36, + sectionRevealEase: [0.22, 1, 0.36, 1] as const, + chartStartDelayMs: 120, + meterStartDelayMs: 180, + meterDurationMs: 560, +} + +interface DashboardSectionMotionState { + sectionVisible: boolean + chartStartDelayMs: number + meterStartDelayMs: number + shouldReduceMotion: boolean +} + +const DashboardSectionMotionContext = createContext(null) + +/** Returns the current dashboard section motion state when available. */ +export function useDashboardSectionMotion() { + return useContext(DashboardSectionMotionContext) +} + +interface AnimatedDashboardSectionProps { + id: string + children: ReactNode + className?: string + contentClassName?: string + placeholderClassName?: string + eager?: boolean +} + +/** Gates one dashboard section by viewport visibility and exposes motion timing to descendants. */ +export function AnimatedDashboardSection({ + id, + children, + className, + contentClassName, + placeholderClassName, + eager = false, +}: AnimatedDashboardSectionProps) { + const sectionRef = useRef(null) + const shouldReduceMotion = useShouldReduceMotion() + const [shouldMount, setShouldMount] = useState(eager) + const [sectionVisible, setSectionVisible] = useState(eager) + + useEffect(() => { + if (eager) { + setShouldMount(true) + setSectionVisible(true) + return + } + + const element = sectionRef.current + if (!element || typeof IntersectionObserver === 'undefined') { + setShouldMount(true) + setSectionVisible(true) + return + } + + const preloadObserver = new IntersectionObserver( + (entries) => { + const entry = entries[0] + if (entry?.isIntersecting) { + setShouldMount(true) + preloadObserver.disconnect() + } + }, + { + rootMargin: DASHBOARD_MOTION.sectionPreloadMargin, + threshold: 0, + }, + ) + + const revealObserver = new IntersectionObserver( + (entries) => { + const entry = entries[0] + if (entry?.isIntersecting) { + setShouldMount(true) + setSectionVisible(true) + revealObserver.disconnect() + } + }, + { + threshold: DASHBOARD_MOTION.sectionRevealAmount, + }, + ) + + preloadObserver.observe(element) + revealObserver.observe(element) + + return () => { + preloadObserver.disconnect() + revealObserver.disconnect() + } + }, [eager]) + + const contextValue = useMemo( + () => ({ + sectionVisible, + chartStartDelayMs: DASHBOARD_MOTION.chartStartDelayMs, + meterStartDelayMs: DASHBOARD_MOTION.meterStartDelayMs, + shouldReduceMotion, + }), + [sectionVisible, shouldReduceMotion], + ) + + return ( +
+ {!shouldMount ? ( +
+ ) +} diff --git a/src/components/features/animations/AnimatedBarFill.tsx b/src/components/features/animations/AnimatedBarFill.tsx new file mode 100644 index 0000000..6a52cd1 --- /dev/null +++ b/src/components/features/animations/AnimatedBarFill.tsx @@ -0,0 +1,56 @@ +import { motion, type MotionStyle } from 'framer-motion' +import type { CSSProperties } from 'react' +import { useDashboardSectionMotion } from '@/components/dashboard/dashboard-motion' +import { cn } from '@/lib/cn' +import { useShouldReduceMotion } from '@/lib/motion' + +interface AnimatedBarFillProps { + width: string + className?: string + style?: CSSProperties + active?: boolean + delayMs?: number + durationMs?: number +} + +/** Animates one horizontal dashboard bar fill while respecting reduced motion. */ +export function AnimatedBarFill({ + width, + className, + style, + active, + delayMs, + durationMs, +}: AnimatedBarFillProps) { + const sectionMotion = useDashboardSectionMotion() + const shouldReduceMotion = useShouldReduceMotion() + const isActive = active ?? sectionMotion?.sectionVisible ?? true + const resolvedDelayMs = delayMs ?? sectionMotion?.meterStartDelayMs ?? 180 + const resolvedDurationMs = durationMs ?? 560 + + if (shouldReduceMotion) { + return ( +
+ ) + } + + return ( + + ) +} diff --git a/src/components/features/cache-roi/CacheROI.tsx b/src/components/features/cache-roi/CacheROI.tsx index 7d1939a..bbcfc75 100644 --- a/src/components/features/cache-roi/CacheROI.tsx +++ b/src/components/features/cache-roi/CacheROI.tsx @@ -6,6 +6,7 @@ import { normalizeModelName } from '@/lib/model-utils' import { MODEL_PRICES } from '@/lib/constants' import { Zap } from 'lucide-react' import { FormattedValue } from '@/components/ui/formatted-value' +import { AnimatedBarFill } from '@/components/features/animations/AnimatedBarFill' import { InfoHeading } from '@/components/features/help/InfoHeading' import { CHART_HELP } from '@/lib/help-content' import type { DailyUsage, ViewMode } from '@/types' @@ -157,9 +158,9 @@ export function CacheROI({ data, viewMode = 'daily' }: CacheROIProps) {
{t('cacheRoi.withCache')}
-
diff --git a/src/components/features/limits/ProviderLimitsSection.tsx b/src/components/features/limits/ProviderLimitsSection.tsx index 68fd470..436627d 100644 --- a/src/components/features/limits/ProviderLimitsSection.tsx +++ b/src/components/features/limits/ProviderLimitsSection.tsx @@ -17,9 +17,16 @@ import { import { AlertTriangle, CreditCard, ShieldCheck, TrendingUp } from 'lucide-react' import { Card, CardContent } from '@/components/ui/card' import { SectionHeader } from '@/components/ui/section-header' +import { useDashboardSectionMotion } from '@/components/dashboard/dashboard-motion' +import { AnimatedBarFill } from '@/components/features/animations/AnimatedBarFill' import { ChartAnimationAware, ChartCard, ChartReveal } from '@/components/charts/ChartCard' import { ChartLegend } from '@/components/charts/ChartLegend' -import { CHART_ANIMATION, CHART_COLORS, CHART_MARGIN } from '@/components/charts/chart-theme' +import { + CHART_COLORS, + CHART_MARGIN, + getAreaAnimationProps, + getLineAnimationProps, +} from '@/components/charts/chart-theme' import { buildProviderMonthlyCosts, getLatestMonth } from '@/lib/provider-limits' import i18n from '@/lib/i18n' import { CHART_HELP, SECTION_HELP } from '@/lib/help-content' @@ -96,8 +103,10 @@ export function ProviderLimitsSection({ selectedMonth, }: ProviderLimitsSectionProps) { const { t } = useTranslation() + const sectionMotion = useDashboardSectionMotion() const sectionRef = useRef(null) - const inView = useInView(sectionRef, { once: true, amount: 0.2 }) + const localInView = useInView(sectionRef, { once: true, amount: 0.2 }) + const inView = sectionMotion?.sectionVisible ?? localInView const { rows, @@ -220,7 +229,7 @@ export function ProviderLimitsSection({ if (providers.length === 0) return null return ( -
+
{row.monthlyLimit > 0 ? ( -
{row.hasSubscription ? ( - ) : (
@@ -531,32 +530,26 @@ export function ProviderLimitsSection({ style={{ left: limitPosition, width: `calc(100% - ${limitPosition})` }} /> - {row.overrun > 0 && ( - )} @@ -572,15 +565,12 @@ export function ProviderLimitsSection({
) : ( - )} @@ -724,28 +714,22 @@ export function ProviderLimitsSection({ style={{ left: subPosition, width: `calc(100% - ${subPosition})` }} /> - {row.subscriptionGain > 0 && ( - )} @@ -761,15 +745,12 @@ export function ProviderLimitsSection({
) : ( - )} @@ -902,9 +883,7 @@ export function ProviderLimitsSection({ stroke="rgb(74 222 128)" fill="url(#limits-gain-area)" strokeWidth={2} - isAnimationActive={animate} - animationBegin={0} - animationDuration={CHART_ANIMATION.duration} + {...getAreaAnimationProps(animate)} /> diff --git a/src/components/features/request-quality/RequestQuality.tsx b/src/components/features/request-quality/RequestQuality.tsx index a13c40b..40eb919 100644 --- a/src/components/features/request-quality/RequestQuality.tsx +++ b/src/components/features/request-quality/RequestQuality.tsx @@ -1,8 +1,8 @@ -import { useRef } from 'react' import { useTranslation } from 'react-i18next' -import { useInView } from 'framer-motion' +import { useDashboardSectionMotion } from '@/components/dashboard/dashboard-motion' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { InfoHeading } from '@/components/features/help/InfoHeading' +import { AnimatedBarFill } from '@/components/features/animations/AnimatedBarFill' import { FEATURE_HELP } from '@/lib/help-content' import { formatCurrency, formatNumber, formatPercent, formatTokens } from '@/lib/formatters' import { useShouldReduceMotion } from '@/lib/motion' @@ -16,8 +16,7 @@ interface RequestQualityProps { /** Renders request-efficiency summary cards for the current slice. */ export function RequestQuality({ metrics, viewMode }: RequestQualityProps) { const { t } = useTranslation() - const sectionRef = useRef(null) - const inView = useInView(sectionRef, { once: true, amount: 0.25 }) + const sectionMotion = useDashboardSectionMotion() const shouldReduceMotion = useShouldReduceMotion() const cachePerRequest = metrics.totalRequests > 0 ? metrics.totalCacheRead / metrics.totalRequests : 0 @@ -62,7 +61,7 @@ export function RequestQuality({ metrics, viewMode }: RequestQualityProps) { ] return ( - + @@ -72,29 +71,38 @@ export function RequestQuality({ metrics, viewMode }: RequestQualityProps) {
- {qualityMetrics.map((item) => ( -
-
- {item.label} -
-
{item.value}
-
{item.hint}
-
-
{ + const barActive = + shouldReduceMotion || sectionMotion?.sectionVisible !== undefined + ? shouldReduceMotion || sectionMotion?.sectionVisible + : undefined + + return ( +
+
+ {item.label} +
+
{item.value}
+
{item.hint}
+
+ 0 ? `${Math.max(item.progress * 100, 6)}%` : '0%' - : '0%', - }} - /> + : '0%' + } + {...(barActive !== undefined ? { active: barActive } : {})} + /> +
-
- ))} + ) + })}
diff --git a/src/components/features/risk/ConcentrationRisk.tsx b/src/components/features/risk/ConcentrationRisk.tsx index fabb75e..4fe05e3 100644 --- a/src/components/features/risk/ConcentrationRisk.tsx +++ b/src/components/features/risk/ConcentrationRisk.tsx @@ -1,4 +1,5 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { AnimatedBarFill } from '@/components/features/animations/AnimatedBarFill' import { useTranslation } from 'react-i18next' import { InfoHeading } from '@/components/features/help/InfoHeading' import { FEATURE_HELP } from '@/lib/help-content' @@ -55,9 +56,9 @@ export function ConcentrationRisk({
-
@@ -81,9 +82,9 @@ export function ConcentrationRisk({
-
diff --git a/tests/frontend/dashboard-motion.test.tsx b/tests/frontend/dashboard-motion.test.tsx new file mode 100644 index 0000000..9f1dca0 --- /dev/null +++ b/tests/frontend/dashboard-motion.test.tsx @@ -0,0 +1,87 @@ +// @vitest-environment jsdom + +import { act, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ChartCard, useChartAnimationState } from '@/components/charts/ChartCard' +import { + AnimatedDashboardSection, + useDashboardSectionMotion, +} from '@/components/dashboard/dashboard-motion' +import { initI18n } from '@/lib/i18n' + +class MockIntersectionObserver { + static instances: MockIntersectionObserver[] = [] + + callback: IntersectionObserverCallback + + constructor(callback: IntersectionObserverCallback) { + this.callback = callback + MockIntersectionObserver.instances.push(this) + } + + observe() {} + + unobserve() {} + + disconnect() {} + + trigger(isIntersecting: boolean) { + this.callback( + [ + { + isIntersecting, + target: document.createElement('div'), + } as IntersectionObserverEntry, + ], + this as unknown as IntersectionObserver, + ) + } +} + +function MotionProbe() { + const motion = useDashboardSectionMotion() + const chartMotion = useChartAnimationState() + + return ( + <> +
{String(motion?.sectionVisible)}
+
{String(motion?.chartStartDelayMs)}
+
{String(chartMotion.active)}
+ + ) +} + +describe('AnimatedDashboardSection', () => { + beforeEach(async () => { + MockIntersectionObserver.instances = [] + vi.stubGlobal('IntersectionObserver', MockIntersectionObserver) + await initI18n('en') + }) + + it('preloads before reveal and only activates chart animation once visible', () => { + render( + + + + + , + ) + + expect(screen.queryByTestId('section-visible')).not.toBeInTheDocument() + + act(() => { + MockIntersectionObserver.instances[0]?.trigger(true) + }) + + expect(screen.getByTestId('section-visible')).toHaveTextContent('false') + expect(screen.getByTestId('chart-active')).toHaveTextContent('false') + expect(screen.getByTestId('chart-delay')).toHaveTextContent('120') + + act(() => { + MockIntersectionObserver.instances[1]?.trigger(true) + }) + + expect(screen.getByTestId('section-visible')).toHaveTextContent('true') + expect(screen.getByTestId('chart-active')).toHaveTextContent('true') + }) +}) From 2fe64c1f76f21a81340562407d81399919f5d82d Mon Sep 17 00:00:00 2001 From: tyl3r-ch Date: Wed, 15 Apr 2026 20:45:54 +0200 Subject: [PATCH 2/8] v6.2.2: Polish dashboard motion --- src/components/cards/MonthMetrics.tsx | 177 ++++++------ src/components/cards/PrimaryMetrics.tsx | 261 ++++++++++-------- src/components/cards/SecondaryMetrics.tsx | 101 ++++--- src/components/cards/TodayMetrics.tsx | 167 ++++++----- src/components/charts/ChartCard.tsx | 42 ++- src/components/charts/CorrelationAnalysis.tsx | 27 +- .../charts/DistributionAnalysis.tsx | 23 +- src/components/charts/chart-theme.ts | 14 +- .../dashboard/DashboardSections.tsx | 118 ++++++-- src/components/dashboard/dashboard-motion.tsx | 234 +++++++++++++--- .../features/animations/AnimatedBarFill.tsx | 16 +- .../features/cache-roi/CacheROI.tsx | 5 +- .../features/heatmap/HeatmapCalendar.tsx | 60 +++- .../features/insights/UsageInsights.tsx | 22 +- .../features/limits/ProviderLimitsSection.tsx | 62 +---- src/components/tables/RecentDays.tsx | 9 +- src/components/ui/card.tsx | 20 +- tests/frontend/cache-roi.test.tsx | 2 +- tests/frontend/dashboard-motion.test.tsx | 17 +- 19 files changed, 879 insertions(+), 498 deletions(-) diff --git a/src/components/cards/MonthMetrics.tsx b/src/components/cards/MonthMetrics.tsx index 04f1690..a14fe89 100644 --- a/src/components/cards/MonthMetrics.tsx +++ b/src/components/cards/MonthMetrics.tsx @@ -11,6 +11,7 @@ import { BrainCircuit, } from 'lucide-react' import { MetricCard } from './MetricCard' +import { DashboardMotionItem } from '@/components/dashboard/dashboard-motion' import { FormattedValue } from '@/components/ui/formatted-value' import { SectionHeader } from '@/components/ui/section-header' import { SECTION_HELP } from '@/lib/help-content' @@ -144,86 +145,102 @@ export function MonthMetrics({ daily, metrics }: MonthMetricsProps) { info={SECTION_HELP.currentMonth} />
- } - subtitle={t('metricCards.month.avgPerDay', { - value: formatCurrency(agg.totalCost / agg.activeDays), - })} - icon={} - trend={ - diffToPrev !== null - ? { value: diffToPrev, label: t('metricCards.month.vsPreviousMonth') } - : null - } - /> - } - icon={} - {...(tokensSubtitle ? { subtitle: tokensSubtitle } : {})} - /> - } - /> - } - {...(modelsSubtitle ? { subtitle: modelsSubtitle } : {})} - /> - } - icon={} - {...(costPerMillionSubtitle ? { subtitle: costPerMillionSubtitle } : {})} - /> - } - subtitle={t('metricCards.month.cacheMix', { - input: wholePercentFormatter.format(ioTotal > 0 ? agg.inputTokens / ioTotal : 0), - output: wholePercentFormatter.format(ioTotal > 0 ? agg.outputTokens / ioTotal : 0), - })} - icon={} - /> - 0 ? ( - - ) : ( - t('common.notAvailable') - ) - } - subtitle={ - agg.requestCount > 0 - ? t('metricCards.month.requestsSubtitle', { - value: (agg.requestCount / agg.activeDays).toFixed(1), - cost: formatCurrency(agg.totalCost / agg.requestCount), - }) - : t('metricCards.month.requestCountersMissing') - } - icon={} - /> - } - icon={} - {...(thinkingSubtitle ? { subtitle: thinkingSubtitle } : {})} - /> + + } + subtitle={t('metricCards.month.avgPerDay', { + value: formatCurrency(agg.totalCost / agg.activeDays), + })} + icon={} + trend={ + diffToPrev !== null + ? { value: diffToPrev, label: t('metricCards.month.vsPreviousMonth') } + : null + } + /> + + + } + icon={} + {...(tokensSubtitle ? { subtitle: tokensSubtitle } : {})} + /> + + + } + /> + + + } + {...(modelsSubtitle ? { subtitle: modelsSubtitle } : {})} + /> + + + } + icon={} + {...(costPerMillionSubtitle ? { subtitle: costPerMillionSubtitle } : {})} + /> + + + } + subtitle={t('metricCards.month.cacheMix', { + input: wholePercentFormatter.format(ioTotal > 0 ? agg.inputTokens / ioTotal : 0), + output: wholePercentFormatter.format(ioTotal > 0 ? agg.outputTokens / ioTotal : 0), + })} + icon={} + /> + + + 0 ? ( + + ) : ( + t('common.notAvailable') + ) + } + subtitle={ + agg.requestCount > 0 + ? t('metricCards.month.requestsSubtitle', { + value: (agg.requestCount / agg.activeDays).toFixed(1), + cost: formatCurrency(agg.totalCost / agg.requestCount), + }) + : t('metricCards.month.requestCountersMissing') + } + icon={} + /> + + + } + icon={} + {...(thinkingSubtitle ? { subtitle: thinkingSubtitle } : {})} + /> +
) diff --git a/src/components/cards/PrimaryMetrics.tsx b/src/components/cards/PrimaryMetrics.tsx index a486f90..c454efe 100644 --- a/src/components/cards/PrimaryMetrics.tsx +++ b/src/components/cards/PrimaryMetrics.tsx @@ -9,6 +9,7 @@ import { BrainCircuit, } from 'lucide-react' import { useTranslation } from 'react-i18next' +import { DashboardMotionItem } from '@/components/dashboard/dashboard-motion' import { MetricCard } from './MetricCard' import { FormattedValue } from '@/components/ui/formatted-value' import { formatCurrency, formatPercent, formatTokens, periodUnit } from '@/lib/formatters' @@ -68,130 +69,146 @@ export function PrimaryMetrics({ return (
- - } - subtitle={t('metricCards.primary.totalCostSubtitle', { - average: formatCurrency(metrics.avgDailyCost), - unit: periodUnit(viewMode), - costPerRequest: formatCurrency(metrics.avgCostPerRequest), - })} - icon={} - trend={metrics.weekOverWeekChange !== null ? { value: metrics.weekOverWeekChange } : null} - info={METRIC_HELP.totalCost} - /> - - } - subtitle={ - ioRatio - ? t('metricCards.primary.totalTokensSubtitleWithRatio', { - ratio: ioRatio, - tokensPerRequest: formatTokens(metrics.avgTokensPerRequest), - }) - : t('metricCards.primary.totalTokensSubtitle', { - tokensPerRequest: formatTokens(metrics.avgTokensPerRequest), - }) - } - icon={} - info={METRIC_HELP.totalTokens} - /> - } - info={METRIC_HELP.activeDays} - /> - } - info={METRIC_HELP.topModel} - {...(topModelSubtitle ? { subtitle: topModelSubtitle } : {})} - /> - } - icon={} - info={METRIC_HELP.cacheHitRate} - {...(cacheHitRateSubtitle ? { subtitle: cacheHitRateSubtitle } : {})} - /> - } - icon={} - info={METRIC_HELP.costPerMillion} - /> - + - ) : ( - t('common.notAvailable') - ) - } - subtitle={ - metrics.hasRequestData - ? t('metricCards.primary.requestsSubtitle', { - requests: metrics.avgRequestsPerDay.toFixed(1), + value={metrics.totalCost} + type="currency" + label={t('metricCards.primary.totalCost')} + insight={t('metricCards.primary.avgPerPeriod', { + value: formatCurrency(metrics.avgDailyCost), unit: periodUnit(viewMode), - cost: formatCurrency(metrics.avgCostPerRequest), - volatility: Math.round(metrics.requestVolatility), - }) - : t('metricCards.primary.requestCountersMissing') - } - icon={} - /> - - } - icon={} - {...(thinkingSubtitle ? { subtitle: thinkingSubtitle } : {})} - /> + })} + /> + } + subtitle={t('metricCards.primary.totalCostSubtitle', { + average: formatCurrency(metrics.avgDailyCost), + unit: periodUnit(viewMode), + costPerRequest: formatCurrency(metrics.avgCostPerRequest), + })} + icon={} + trend={metrics.weekOverWeekChange !== null ? { value: metrics.weekOverWeekChange } : null} + info={METRIC_HELP.totalCost} + /> + + + + } + subtitle={ + ioRatio + ? t('metricCards.primary.totalTokensSubtitleWithRatio', { + ratio: ioRatio, + tokensPerRequest: formatTokens(metrics.avgTokensPerRequest), + }) + : t('metricCards.primary.totalTokensSubtitle', { + tokensPerRequest: formatTokens(metrics.avgTokensPerRequest), + }) + } + icon={} + info={METRIC_HELP.totalTokens} + /> + + + } + info={METRIC_HELP.activeDays} + /> + + + } + info={METRIC_HELP.topModel} + {...(topModelSubtitle ? { subtitle: topModelSubtitle } : {})} + /> + + + } + icon={} + info={METRIC_HELP.cacheHitRate} + {...(cacheHitRateSubtitle ? { subtitle: cacheHitRateSubtitle } : {})} + /> + + + } + icon={} + info={METRIC_HELP.costPerMillion} + /> + + + + ) : ( + t('common.notAvailable') + ) + } + subtitle={ + metrics.hasRequestData + ? t('metricCards.primary.requestsSubtitle', { + requests: metrics.avgRequestsPerDay.toFixed(1), + unit: periodUnit(viewMode), + cost: formatCurrency(metrics.avgCostPerRequest), + volatility: Math.round(metrics.requestVolatility), + }) + : t('metricCards.primary.requestCountersMissing') + } + icon={} + /> + + + + } + icon={} + {...(thinkingSubtitle ? { subtitle: thinkingSubtitle } : {})} + /> +
) } diff --git a/src/components/cards/SecondaryMetrics.tsx b/src/components/cards/SecondaryMetrics.tsx index 61eb4df..555f585 100644 --- a/src/components/cards/SecondaryMetrics.tsx +++ b/src/components/cards/SecondaryMetrics.tsx @@ -1,5 +1,6 @@ import { TrendingUp, ChartBar, Sigma, Building2 } from 'lucide-react' import { useTranslation } from 'react-i18next' +import { DashboardMotionItem } from '@/components/dashboard/dashboard-motion' import { MetricCard } from './MetricCard' import { FormattedValue } from '@/components/ui/formatted-value' import { @@ -74,52 +75,60 @@ export function SecondaryMetrics({ return (
- : '–' - } - icon={} - info={METRIC_HELP.mostExpensiveDay} - {...(topDaySubtitle ? { subtitle: topDaySubtitle } : {})} - /> - } - info={t('metricCards.secondary.medianInfo')} - {...(topProviderSubtitle ? { subtitle: topProviderSubtitle } : {})} - /> - - ) : ( - - ) - } - icon={} - info={METRIC_HELP.avgCostPerDay} - {...(peakSubtitle ? { subtitle: peakSubtitle } : {})} - /> - : '–'} - icon={} - info={t('metricCards.secondary.medianInfo')} - {...(medianSubtitle ? { subtitle: medianSubtitle } : {})} - /> + + : '–' + } + icon={} + info={METRIC_HELP.mostExpensiveDay} + {...(topDaySubtitle ? { subtitle: topDaySubtitle } : {})} + /> + + + } + info={t('metricCards.secondary.medianInfo')} + {...(topProviderSubtitle ? { subtitle: topProviderSubtitle } : {})} + /> + + + + ) : ( + + ) + } + icon={} + info={METRIC_HELP.avgCostPerDay} + {...(peakSubtitle ? { subtitle: peakSubtitle } : {})} + /> + + + : '–'} + icon={} + info={t('metricCards.secondary.medianInfo')} + {...(medianSubtitle ? { subtitle: medianSubtitle } : {})} + /> +
) } diff --git a/src/components/cards/TodayMetrics.tsx b/src/components/cards/TodayMetrics.tsx index 5b90f78..d8f9ea0 100644 --- a/src/components/cards/TodayMetrics.tsx +++ b/src/components/cards/TodayMetrics.tsx @@ -8,6 +8,7 @@ import { BrainCircuit, } from 'lucide-react' import { useTranslation } from 'react-i18next' +import { DashboardMotionItem } from '@/components/dashboard/dashboard-motion' import { MetricCard } from './MetricCard' import { FormattedValue } from '@/components/ui/formatted-value' import { SectionHeader } from '@/components/ui/section-header' @@ -83,84 +84,100 @@ export function TodayMetrics({ today, metrics }: TodayMetricsProps) { info={SECTION_HELP.today} />
- } - icon={} - trend={ - diffToAvg !== null - ? { value: diffToAvg, label: t('metricCards.today.vsAverageShort') } - : null - } - {...(costSubtitle ? { subtitle: costSubtitle } : {})} - /> - 0 ? today.totalTokens / today.requestCount : 0, - ), - })} - /> - } - icon={} - {...(tokensSubtitle ? { subtitle: tokensSubtitle } : {})} - /> - } - {...(modelSubtitle ? { subtitle: modelSubtitle } : {})} - /> - 0 ? today.totalCost / (today.totalTokens / 1_000_000) : 0} - type="currency" - /> - } - icon={} - {...(costPerMillionSubtitle ? { subtitle: costPerMillionSubtitle } : {})} - /> - } - subtitle={t('metricCards.today.cacheShare', { - value: formatPercent((today.cacheReadTokens / (today.totalTokens || 1)) * 100), - })} - icon={} - /> - 0 ? ( + + } + icon={} + trend={ + diffToAvg !== null + ? { value: diffToAvg, label: t('metricCards.today.vsAverageShort') } + : null + } + {...(costSubtitle ? { subtitle: costSubtitle } : {})} + /> + + + 0 ? today.totalTokens / today.requestCount : 0, + ), })} /> - ) : ( - t('common.notAvailable') - ) - } - subtitle={requestsSubtitle} - icon={} - /> - } - icon={} - {...(thinkingSubtitle ? { subtitle: thinkingSubtitle } : {})} - /> + } + icon={} + {...(tokensSubtitle ? { subtitle: tokensSubtitle } : {})} + /> + + + } + {...(modelSubtitle ? { subtitle: modelSubtitle } : {})} + /> + + + 0 ? today.totalCost / (today.totalTokens / 1_000_000) : 0 + } + type="currency" + /> + } + icon={} + {...(costPerMillionSubtitle ? { subtitle: costPerMillionSubtitle } : {})} + /> + + + } + subtitle={t('metricCards.today.cacheShare', { + value: formatPercent((today.cacheReadTokens / (today.totalTokens || 1)) * 100), + })} + icon={} + /> + + + 0 ? ( + + ) : ( + t('common.notAvailable') + ) + } + subtitle={requestsSubtitle} + icon={} + /> + + + } + icon={} + {...(thinkingSubtitle ? { subtitle: thinkingSubtitle } : {})} + /> +
) diff --git a/src/components/charts/ChartCard.tsx b/src/components/charts/ChartCard.tsx index c8811c6..19b5ee5 100644 --- a/src/components/charts/ChartCard.tsx +++ b/src/components/charts/ChartCard.tsx @@ -15,6 +15,7 @@ import { Maximize2 } from 'lucide-react' import { InfoButton } from '@/components/features/help/InfoButton' import { DASHBOARD_MOTION, + useDashboardElementMotion, useDashboardSectionMotion, } from '@/components/dashboard/dashboard-motion' import { CHART_ANIMATION } from './chart-theme' @@ -56,9 +57,14 @@ export function buildChartCsv(chartData: Record[]): string { interface ChartAnimationState { active: boolean delayMs: number + runKey: number } -const ChartAnimationContext = createContext({ active: false, delayMs: 0 }) +const ChartAnimationContext = createContext({ + active: false, + delayMs: 0, + runKey: 0, +}) /** Returns whether chart-specific animation should currently run. */ export function useChartAnimationActive() { @@ -75,6 +81,11 @@ export function useChartAnimationDelay() { return useContext(ChartAnimationContext).delayMs } +/** Returns the current chart animation run key. */ +export function useChartAnimationRunKey() { + return useContext(ChartAnimationContext).runKey +} + /** Exposes the current chart animation state to a render prop. */ export function ChartAnimationAware({ children }: { children: (active: boolean) => ReactNode }) { const shouldReduceMotion = useShouldReduceMotion() @@ -91,7 +102,7 @@ interface ChartRevealProps { export function ChartReveal({ children, variant = 'line' }: ChartRevealProps) { const shouldReduceMotion = useShouldReduceMotion() const active = useChartAnimationActive() - const delayMs = useChartAnimationDelay() + const runKey = useChartAnimationRunKey() const wrapperStyle = { width: '100%', height: '100%', @@ -113,11 +124,11 @@ export function ChartReveal({ children, variant = 'line' }: ChartRevealProps) { animate={active ? { opacity: 1, y: 0 } : { opacity: 0, y: 8 }} transition={{ duration: CHART_ANIMATION.revealDuration / 1000, - delay: active ? delayMs / 1000 : 0, + delay: 0, ease: DASHBOARD_MOTION.sectionRevealEase, }} > - {children} +
{children}
) } @@ -141,23 +152,36 @@ export function ChartCard({ const [expanded, setExpanded] = useState(false) const cardRef = useRef(null) const isInView = useInView(cardRef, { once: true, amount: 0.25 }) + const elementMotion = useDashboardElementMotion(cardRef, { + kind: 'chart', + amount: 0.3, + }) const animationState = useMemo(() => { if (expanded) { - return { active: true, delayMs: 0 } + return { active: true, delayMs: 0, runKey: 1 } } if (sectionMotion) { return { - active: sectionMotion.sectionVisible, - delayMs: sectionMotion.chartStartDelayMs, + active: elementMotion.active, + delayMs: elementMotion.delayMs, + runKey: elementMotion.runKey, } } return { active: isInView, delayMs: DASHBOARD_MOTION.chartStartDelayMs, + runKey: isInView ? 1 : 0, } - }, [expanded, isInView, sectionMotion]) + }, [ + elementMotion.active, + elementMotion.delayMs, + elementMotion.runKey, + expanded, + isInView, + sectionMotion, + ]) const stats = useMemo(() => { if (!chartData || !valueKey) return null @@ -234,7 +258,7 @@ export function ChartCard({ {t('chartCard.expandedDescription')} - +
diff --git a/src/components/charts/CorrelationAnalysis.tsx b/src/components/charts/CorrelationAnalysis.tsx index 4162f36..20a6533 100644 --- a/src/components/charts/CorrelationAnalysis.tsx +++ b/src/components/charts/CorrelationAnalysis.tsx @@ -1,6 +1,5 @@ import { useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' -import { useInView } from 'framer-motion' import { ResponsiveContainer, ScatterChart, @@ -12,6 +11,7 @@ import { ZAxis, } from 'recharts' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { useDashboardElementMotion } from '@/components/dashboard/dashboard-motion' import { InfoHeading } from '@/components/features/help/InfoHeading' import { CHART_COLORS, CHART_MARGIN, getScatterAnimationProps } from './chart-theme' import { CHART_HELP } from '@/lib/help-content' @@ -22,7 +22,6 @@ import { formatPercent, formatTokens, } from '@/lib/formatters' -import { useShouldReduceMotion } from '@/lib/motion' import type { DailyUsage } from '@/types' interface CorrelationAnalysisProps { @@ -145,6 +144,7 @@ function CorrelationPanel({ xTickFormatter, yAxisName, footer, + animatePoints, }: { title: string subtitle: string @@ -156,15 +156,12 @@ function CorrelationPanel({ xTickFormatter?: (value: number) => string yAxisName: string footer: string + animatePoints: boolean }) { - const panelRef = useRef(null) - const panelInView = useInView(panelRef, { once: true, amount: 0.45 }) - const shouldReduceMotion = useShouldReduceMotion() - const animatePoints = shouldReduceMotion ? true : panelInView const chartData = animatePoints ? data : [] return ( -
+
@@ -201,7 +198,7 @@ function CorrelationPanel({ fill={color} stroke={color} fillOpacity={0.72} - {...getScatterAnimationProps(!shouldReduceMotion && animatePoints, animationBegin)} + {...getScatterAnimationProps(animatePoints, animationBegin)} /> @@ -214,6 +211,12 @@ function CorrelationPanel({ /** Renders scatter-plot based correlation analysis for the current dataset. */ export function CorrelationAnalysis({ data }: CorrelationAnalysisProps) { const { t } = useTranslation() + const cardRef = useRef(null) + const chartMotion = useDashboardElementMotion(cardRef, { + kind: 'chart', + amount: 0.28, + }) + const animatePoints = !chartMotion.shouldReduceMotion && chartMotion.active const requestVsCost = useMemo( () => data.map((entry) => ({ @@ -256,7 +259,7 @@ export function CorrelationAnalysis({ data }: CorrelationAnalysisProps) { if (data.length < 2) { return ( - + @@ -274,7 +277,7 @@ export function CorrelationAnalysis({ data }: CorrelationAnalysisProps) { } return ( - + @@ -284,22 +287,26 @@ export function CorrelationAnalysis({ data }: CorrelationAnalysisProps) { formatPercent(value, 0)} diff --git a/src/components/charts/DistributionAnalysis.tsx b/src/components/charts/DistributionAnalysis.tsx index ce4ad15..64dcc7a 100644 --- a/src/components/charts/DistributionAnalysis.tsx +++ b/src/components/charts/DistributionAnalysis.tsx @@ -1,4 +1,4 @@ -import { useId, useMemo } from 'react' +import { useId, useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' import { ResponsiveContainer, @@ -11,11 +11,11 @@ import { Cell, } from 'recharts' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { useDashboardElementMotion } from '@/components/dashboard/dashboard-motion' import { InfoHeading } from '@/components/features/help/InfoHeading' import { CHART_COLORS, CHART_MARGIN, getBarAnimationProps } from './chart-theme' import { CHART_HELP } from '@/lib/help-content' import { formatCurrency, formatNumber, formatTokens, periodLabel } from '@/lib/formatters' -import { useShouldReduceMotion } from '@/lib/motion' import type { DailyUsage, ViewMode } from '@/types' interface DistributionAnalysisProps { @@ -94,7 +94,12 @@ function DistributionTooltip({ export function DistributionAnalysis({ data, viewMode = 'daily' }: DistributionAnalysisProps) { const { t } = useTranslation() const uid = useId().replace(/:/g, '') - const shouldReduceMotion = useShouldReduceMotion() + const cardRef = useRef(null) + const chartMotion = useDashboardElementMotion(cardRef, { + kind: 'chart', + amount: 0.28, + }) + const animate = !chartMotion.shouldReduceMotion && chartMotion.active const distributions = useMemo(() => { if (data.length < 2) return [] @@ -123,7 +128,7 @@ export function DistributionAnalysis({ data, viewMode = 'daily' }: DistributionA if (data.length < 2) { return ( - + @@ -141,7 +146,7 @@ export function DistributionAnalysis({ data, viewMode = 'daily' }: DistributionA } return ( - + @@ -161,7 +166,11 @@ export function DistributionAnalysis({ data, viewMode = 'daily' }: DistributionA {distribution.data.length} {t('charts.distribution.buckets')}
- + @@ -195,7 +204,7 @@ export function DistributionAnalysis({ data, viewMode = 'daily' }: DistributionA dataKey="count" radius={[6, 6, 0, 0]} fill={`url(#${uid}-distribution-${index})`} - {...getBarAnimationProps(!shouldReduceMotion, index)} + {...getBarAnimationProps(animate, index)} > {distribution.data.map((_, binIndex) => { const intensity = diff --git a/src/components/charts/chart-theme.ts b/src/components/charts/chart-theme.ts index 284b92e..c44a799 100644 --- a/src/components/charts/chart-theme.ts +++ b/src/components/charts/chart-theme.ts @@ -18,14 +18,14 @@ export const CHART_MARGIN = { top: 5, right: 10, left: 10, bottom: 5 } /** Defines the shared chart animation timings. */ export const CHART_ANIMATION = { - duration: 760, + duration: 860, easing: 'ease-out' as const, - stagger: 70, - slowDuration: 900, - chartStartDelay: 120, - barDuration: 520, - radialDuration: 700, - revealDuration: 360, + stagger: 80, + slowDuration: 1040, + chartStartDelay: 190, + barDuration: 620, + radialDuration: 820, + revealDuration: 520, } type SeriesRole = 'primary' | 'secondary' | 'stacked' diff --git a/src/components/dashboard/DashboardSections.tsx b/src/components/dashboard/DashboardSections.tsx index 4143aa4..68d23b5 100644 --- a/src/components/dashboard/DashboardSections.tsx +++ b/src/components/dashboard/DashboardSections.tsx @@ -1,4 +1,11 @@ -import { Fragment, Suspense, lazy, type ReactNode } from 'react' +import { + Fragment, + Suspense, + lazy, + type ComponentType, + type LazyExoticComponent, + type ReactNode, +} from 'react' import { useTranslation } from 'react-i18next' import { PrimaryMetrics } from '../cards/PrimaryMetrics' import { SecondaryMetrics } from '../cards/SecondaryMetrics' @@ -31,107 +38,121 @@ import type { WeekdayData, } from '@/types' -const CostForecast = lazy(() => +const CostForecast = lazyWithPreload(() => import('../features/forecast/CostForecast').then((module) => ({ default: module.CostForecast, })), ) -const CostByModelOverTime = lazy(() => +const CostByModelOverTime = lazyWithPreload(() => import('../charts/CostByModelOverTime').then((module) => ({ default: module.CostByModelOverTime, })), ) -const CumulativeCost = lazy(() => +const CumulativeCost = lazyWithPreload(() => import('../charts/CumulativeCost').then((module) => ({ default: module.CumulativeCost, })), ) -const CostByWeekday = lazy(() => +const CostByWeekday = lazyWithPreload(() => import('../charts/CostByWeekday').then((module) => ({ default: module.CostByWeekday, })), ) -const TokenEfficiency = lazy(() => +const TokenEfficiency = lazyWithPreload(() => import('../charts/TokenEfficiency').then((module) => ({ default: module.TokenEfficiency, })), ) -const ModelMix = lazy(() => +const ModelMix = lazyWithPreload(() => import('../charts/ModelMix').then((module) => ({ default: module.ModelMix, })), ) -const TokensOverTime = lazy(() => +const TokensOverTime = lazyWithPreload(() => import('../charts/TokensOverTime').then((module) => ({ default: module.TokensOverTime, })), ) -const TokenTypes = lazy(() => +const TokenTypes = lazyWithPreload(() => import('../charts/TokenTypes').then((module) => ({ default: module.TokenTypes, })), ) -const RequestsOverTime = lazy(() => +const RequestsOverTime = lazyWithPreload(() => import('../charts/RequestsOverTime').then((module) => ({ default: module.RequestsOverTime, })), ) -const RequestCacheHitRateByModel = lazy(() => +const RequestCacheHitRateByModel = lazyWithPreload(() => import('../charts/RequestCacheHitRateByModel').then((module) => ({ default: module.RequestCacheHitRateByModel, })), ) -const CacheROI = lazy(() => +const CacheROI = lazyWithPreload(() => import('../features/cache-roi/CacheROI').then((module) => ({ default: module.CacheROI, })), ) -const ProviderLimitsSection = lazy(() => +const ProviderLimitsSection = lazyWithPreload(() => import('../features/limits/ProviderLimitsSection').then((module) => ({ default: module.ProviderLimitsSection, })), ) -const RequestQuality = lazy(() => +const RequestQuality = lazyWithPreload(() => import('../features/request-quality/RequestQuality').then((module) => ({ default: module.RequestQuality, })), ) -const DistributionAnalysis = lazy(() => +const DistributionAnalysis = lazyWithPreload(() => import('../charts/DistributionAnalysis').then((module) => ({ default: module.DistributionAnalysis, })), ) -const CorrelationAnalysis = lazy(() => +const CorrelationAnalysis = lazyWithPreload(() => import('../charts/CorrelationAnalysis').then((module) => ({ default: module.CorrelationAnalysis, })), ) -const PeriodComparison = lazy(() => +const PeriodComparison = lazyWithPreload(() => import('../features/comparison/PeriodComparison').then((module) => ({ default: module.PeriodComparison, })), ) -const AnomalyDetection = lazy(() => +const AnomalyDetection = lazyWithPreload(() => import('../features/anomaly/AnomalyDetection').then((module) => ({ default: module.AnomalyDetection, })), ) -const ModelEfficiency = lazy(() => +const ModelEfficiency = lazyWithPreload(() => import('../tables/ModelEfficiency').then((module) => ({ default: module.ModelEfficiency, })), ) -const ProviderEfficiency = lazy(() => +const ProviderEfficiency = lazyWithPreload(() => import('../tables/ProviderEfficiency').then((module) => ({ default: module.ProviderEfficiency, })), ) -const RecentDays = lazy(() => +const RecentDays = lazyWithPreload(() => import('../tables/RecentDays').then((module) => ({ default: module.RecentDays, })), ) +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type PreloadableLazyComponent> = LazyExoticComponent & { + preload: () => Promise<{ default: T }> +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function lazyWithPreload>( + loader: () => Promise<{ default: T }>, +): PreloadableLazyComponent { + const Component = lazy(loader) as PreloadableLazyComponent + Component.preload = loader + return Component +} + interface DashboardSectionsProps { sectionOrder: DashboardSectionId[] sectionVisibility: Record @@ -250,7 +271,7 @@ export function DashboardSections({ const renderAnimatedSection = ( sectionId: DashboardSectionId, children: ReactNode, - { eager = false }: { eager?: boolean } = {}, + { eager = false, onPreload }: { eager?: boolean; onPreload?: () => void } = {}, ) => { const sectionAnchorId = sectionId === 'costAnalysis' @@ -270,6 +291,7 @@ export function DashboardSections({ id={sectionAnchorId} eager={eager} placeholderClassName={sectionPlaceholderClassName[sectionId]} + onPreload={onPreload} > {children} @@ -413,6 +435,12 @@ export function DashboardSections({ )}
, + { + onPreload: () => { + void CostForecast.preload() + void CacheROI.preload() + }, + }, ) : null case 'limits': @@ -428,6 +456,11 @@ export function DashboardSections({ />, 'h-[420px]', ), + { + onPreload: () => { + void ProviderLimitsSection.preload() + }, + }, ) : null case 'costAnalysis': @@ -465,6 +498,15 @@ export function DashboardSections({ {renderLazySection(, 'h-[320px]')}
, + { + onPreload: () => { + void CostByModelOverTime.preload() + void CumulativeCost.preload() + void CostByWeekday.preload() + void TokenEfficiency.preload() + void ModelMix.preload() + }, + }, ) : null case 'tokenAnalysis': @@ -485,6 +527,12 @@ export function DashboardSections({ {renderLazySection(, 'h-[320px]')}
, + { + onPreload: () => { + void TokensOverTime.preload() + void TokenTypes.preload() + }, + }, ) : null case 'requestAnalysis': @@ -522,6 +570,13 @@ export function DashboardSections({ )}
, + { + onPreload: () => { + void RequestsOverTime.preload() + void RequestCacheHitRateByModel.preload() + void RequestQuality.preload() + }, + }, ) : null case 'advancedAnalysis': @@ -550,6 +605,12 @@ export function DashboardSections({ {renderLazySection(, 'h-[320px]')}
, + { + onPreload: () => { + void DistributionAnalysis.preload() + void CorrelationAnalysis.preload() + }, + }, ) : null case 'comparisons': @@ -607,6 +668,12 @@ export function DashboardSections({ )}
, + { + onPreload: () => { + void PeriodComparison.preload() + void AnomalyDetection.preload() + }, + }, ) : null case 'tables': @@ -648,6 +715,13 @@ export function DashboardSections({ )}
, + { + onPreload: () => { + void ModelEfficiency.preload() + void ProviderEfficiency.preload() + void RecentDays.preload() + }, + }, ) : null default: diff --git a/src/components/dashboard/dashboard-motion.tsx b/src/components/dashboard/dashboard-motion.tsx index b57a582..2a6b57e 100644 --- a/src/components/dashboard/dashboard-motion.tsx +++ b/src/components/dashboard/dashboard-motion.tsx @@ -1,26 +1,33 @@ import { + useCallback, createContext, useContext, useEffect, useMemo, useRef, useState, + type RefObject, type ReactNode, } from 'react' -import { motion } from 'framer-motion' +import { AnimatePresence, motion, useInView } from 'framer-motion' import { cn } from '@/lib/cn' import { useShouldReduceMotion } from '@/lib/motion' /** Defines the shared dashboard motion timings for section reveal and child chart orchestration. */ export const DASHBOARD_MOTION = { - sectionPreloadMargin: '0px 0px 18% 0px', - sectionRevealAmount: 0.18, - sectionRevealOffset: 14, - sectionRevealDuration: 0.36, + sectionPreloadMargin: '0px 0px 30% 0px', + sectionRevealAmount: 0.14, + sectionRevealOffset: 12, + sectionRevealDuration: 0.52, sectionRevealEase: [0.22, 1, 0.36, 1] as const, - chartStartDelayMs: 120, - meterStartDelayMs: 180, - meterDurationMs: 560, + placeholderFadeDuration: 0.28, + itemRevealAmount: 0.24, + itemRevealOffset: 8, + itemRevealDuration: 0.42, + itemStaggerMs: 70, + chartStartDelayMs: 190, + meterStartDelayMs: 250, + meterDurationMs: 640, } interface DashboardSectionMotionState { @@ -37,6 +44,111 @@ export function useDashboardSectionMotion() { return useContext(DashboardSectionMotionContext) } +interface DashboardElementMotionOptions { + amount?: number + kind?: 'chart' | 'meter' | 'item' + order?: number + delayMs?: number +} + +interface DashboardElementMotionState { + active: boolean + runKey: number + delayMs: number + shouldReduceMotion: boolean +} + +/** Tracks one dashboard element and only activates motion once the element itself is visible. */ +export function useDashboardElementMotion( + ref: RefObject, + { + amount = DASHBOARD_MOTION.itemRevealAmount, + kind = 'item', + order = 0, + delayMs, + }: DashboardElementMotionOptions = {}, +): DashboardElementMotionState { + const sectionMotion = useDashboardSectionMotion() + const shouldReduceMotion = useShouldReduceMotion() + const isInView = useInView(ref, { once: true, amount }) + const active = (sectionMotion?.sectionVisible ?? true) && isInView + const [runKey, setRunKey] = useState(0) + const previousActiveRef = useRef(false) + + useEffect(() => { + if (active && !previousActiveRef.current) { + setRunKey((current) => current + 1) + } + previousActiveRef.current = active + }, [active]) + + const baseDelayMs = + delayMs ?? + (kind === 'meter' + ? (sectionMotion?.meterStartDelayMs ?? DASHBOARD_MOTION.meterStartDelayMs) + : (sectionMotion?.chartStartDelayMs ?? DASHBOARD_MOTION.chartStartDelayMs)) + + return { + active: shouldReduceMotion ? true : active, + runKey, + delayMs: baseDelayMs + order * DASHBOARD_MOTION.itemStaggerMs, + shouldReduceMotion, + } +} + +interface DashboardMotionItemProps { + children: ReactNode + className?: string + order?: number + delayMs?: number + amount?: number +} + +/** Reveals one dashboard child element with the shared timing policy. */ +export function DashboardMotionItem({ + children, + className, + order = 0, + delayMs, + amount, +}: DashboardMotionItemProps) { + const itemRef = useRef(null) + const itemMotion = useDashboardElementMotion(itemRef, { + kind: 'item', + order, + ...(delayMs !== undefined ? { delayMs } : {}), + ...(amount !== undefined ? { amount } : {}), + }) + + if (itemMotion.shouldReduceMotion) { + return ( +
+ {children} +
+ ) + } + + return ( + + {children} + + ) +} + interface AnimatedDashboardSectionProps { id: string children: ReactNode @@ -44,6 +156,7 @@ interface AnimatedDashboardSectionProps { contentClassName?: string placeholderClassName?: string eager?: boolean + onPreload?: (() => void) | undefined } /** Gates one dashboard section by viewport visibility and exposes motion timing to descendants. */ @@ -54,22 +167,31 @@ export function AnimatedDashboardSection({ contentClassName, placeholderClassName, eager = false, + onPreload, }: AnimatedDashboardSectionProps) { const sectionRef = useRef(null) + const hasTriggeredPreloadRef = useRef(false) const shouldReduceMotion = useShouldReduceMotion() - const [shouldMount, setShouldMount] = useState(eager) + const [contentPrepared, setContentPrepared] = useState(eager) const [sectionVisible, setSectionVisible] = useState(eager) + const triggerPreload = useCallback(() => { + if (hasTriggeredPreloadRef.current) return + hasTriggeredPreloadRef.current = true + setContentPrepared(true) + onPreload?.() + }, [onPreload]) + useEffect(() => { if (eager) { - setShouldMount(true) + triggerPreload() setSectionVisible(true) return } const element = sectionRef.current if (!element || typeof IntersectionObserver === 'undefined') { - setShouldMount(true) + triggerPreload() setSectionVisible(true) return } @@ -78,7 +200,7 @@ export function AnimatedDashboardSection({ (entries) => { const entry = entries[0] if (entry?.isIntersecting) { - setShouldMount(true) + triggerPreload() preloadObserver.disconnect() } }, @@ -92,7 +214,7 @@ export function AnimatedDashboardSection({ (entries) => { const entry = entries[0] if (entry?.isIntersecting) { - setShouldMount(true) + triggerPreload() setSectionVisible(true) revealObserver.disconnect() } @@ -109,7 +231,10 @@ export function AnimatedDashboardSection({ preloadObserver.disconnect() revealObserver.disconnect() } - }, [eager]) + }, [eager, triggerPreload]) + + const shouldRenderContent = eager || contentPrepared + const showPlaceholder = !sectionVisible const contextValue = useMemo( () => ({ @@ -125,11 +250,11 @@ export function AnimatedDashboardSection({
- {!shouldMount ? ( + {!shouldRenderContent ? (
diff --git a/src/components/features/animations/AnimatedBarFill.tsx b/src/components/features/animations/AnimatedBarFill.tsx index 6a52cd1..8215f27 100644 --- a/src/components/features/animations/AnimatedBarFill.tsx +++ b/src/components/features/animations/AnimatedBarFill.tsx @@ -1,6 +1,7 @@ import { motion, type MotionStyle } from 'framer-motion' +import { useRef } from 'react' import type { CSSProperties } from 'react' -import { useDashboardSectionMotion } from '@/components/dashboard/dashboard-motion' +import { useDashboardElementMotion } from '@/components/dashboard/dashboard-motion' import { cn } from '@/lib/cn' import { useShouldReduceMotion } from '@/lib/motion' @@ -22,15 +23,21 @@ export function AnimatedBarFill({ delayMs, durationMs, }: AnimatedBarFillProps) { - const sectionMotion = useDashboardSectionMotion() + const fillRef = useRef(null) + const elementMotion = useDashboardElementMotion(fillRef, { + kind: 'meter', + amount: 0.2, + ...(delayMs !== undefined ? { delayMs } : {}), + }) const shouldReduceMotion = useShouldReduceMotion() - const isActive = active ?? sectionMotion?.sectionVisible ?? true - const resolvedDelayMs = delayMs ?? sectionMotion?.meterStartDelayMs ?? 180 + const isActive = active ?? elementMotion.active + const resolvedDelayMs = delayMs ?? elementMotion.delayMs const resolvedDurationMs = durationMs ?? 560 if (shouldReduceMotion) { return (
{t('cacheRoi.withoutCache')}
-
+
diff --git a/src/components/features/heatmap/HeatmapCalendar.tsx b/src/components/features/heatmap/HeatmapCalendar.tsx index dda7c97..c05bab0 100644 --- a/src/components/features/heatmap/HeatmapCalendar.tsx +++ b/src/components/features/heatmap/HeatmapCalendar.tsx @@ -6,8 +6,13 @@ import { useState, type KeyboardEvent as ReactKeyboardEvent, } from 'react' +import { motion } from 'framer-motion' import { useTranslation } from 'react-i18next' import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card' +import { + DASHBOARD_MOTION, + useDashboardElementMotion, +} from '@/components/dashboard/dashboard-motion' import { InfoHeading } from '@/components/features/help/InfoHeading' import { CHART_HELP } from '@/lib/help-content' import { @@ -60,6 +65,7 @@ export function HeatmapCalendar({ }: HeatmapCalendarProps) { const { t } = useTranslation() const locale = getCurrentLocale() + const cardRef = useRef(null) const dayButtonRefs = useRef(new Map()) const [tooltip, setTooltip] = useState<{ x: number @@ -68,6 +74,10 @@ export function HeatmapCalendar({ value: number } | null>(null) const overlayRef = useRef(null) + const heatmapMotion = useDashboardElementMotion(cardRef, { + kind: 'chart', + amount: 0.32, + }) const dayLabels = useMemo( () => Array.from({ length: 7 }, (_, index) => index).map((index) => @@ -175,6 +185,9 @@ export function HeatmapCalendar({ }, [config, data, locale]) const todayStr = localToday() + const shouldReduceMotion = heatmapMotion.shouldReduceMotion + const animateCells = !shouldReduceMotion && heatmapMotion.active + const cellAnimationDelayMs = heatmapMotion.delayMs const axisColor = 'hsl(var(--muted-foreground))' const todayOutlineColor = 'hsl(var(--primary))' const [focusedDate, setFocusedDate] = useState(null) @@ -301,7 +314,7 @@ export function HeatmapCalendar({ const svgHeight = 7 * TOTAL + TOP_GUTTER + 8 return ( - + @@ -364,10 +377,47 @@ export function HeatmapCalendar({ date: formattedDate, value: config.formatter(cell.value), }) + const cellMotionProps = shouldReduceMotion + ? {} + : { + initial: { opacity: 0, fillOpacity: 0, scale: 0.96 }, + animate: { + opacity: animateCells ? 1 : 0, + fillOpacity: animateCells ? 1 : 0, + scale: animateCells ? 1 : 0.96, + }, + transition: { + duration: 0.28, + delay: + (animateCells + ? cellAnimationDelayMs + + cell.week * (DASHBOARD_MOTION.itemStaggerMs + 12) + + cell.day * 6 + : 0) / 1000, + ease: [0.22, 1, 0.36, 1] as const, + }, + } + const todayOutlineMotionProps = shouldReduceMotion + ? {} + : { + initial: { opacity: 0 }, + animate: { opacity: animateCells ? 1 : 0 }, + transition: { + duration: 0.2, + delay: + (animateCells + ? cellAnimationDelayMs + + cell.week * (DASHBOARD_MOTION.itemStaggerMs + 12) + + cell.day * 6 + + 90 + : 0) / 1000, + ease: [0.22, 1, 0.36, 1] as const, + }, + } return ( - { if (node) dayButtonRefs.current.set(cell.date, node) else dayButtonRefs.current.delete(cell.date) @@ -410,11 +460,12 @@ export function HeatmapCalendar({ }} onBlur={() => setTooltip(null)} onMouseLeave={() => setTooltip(null)} + {...cellMotionProps} > {accessibleLabel} - + {isToday && ( - )} diff --git a/src/components/features/insights/UsageInsights.tsx b/src/components/features/insights/UsageInsights.tsx index bd58dec..ed5ea4c 100644 --- a/src/components/features/insights/UsageInsights.tsx +++ b/src/components/features/insights/UsageInsights.tsx @@ -1,9 +1,9 @@ import type { ReactNode } from 'react' import { useTranslation } from 'react-i18next' import { Activity, Building2, Layers3, Sparkles, TrendingUp } from 'lucide-react' +import { DashboardMotionItem } from '@/components/dashboard/dashboard-motion' import { Card } from '@/components/ui/card' import { SectionHeader } from '@/components/ui/section-header' -import { FadeIn } from '@/components/features/animations/FadeIn' import { FormattedValue } from '@/components/ui/formatted-value' import { SECTION_HELP } from '@/lib/help-content' import { @@ -93,7 +93,7 @@ export function UsageInsights({ metrics, viewMode, totalCalendarDays }: UsageIns info={SECTION_HELP.insights} />
- + } @@ -122,9 +122,9 @@ export function UsageInsights({ metrics, viewMode, totalCalendarDays }: UsageIns }, ]} /> - + - + } @@ -178,9 +178,9 @@ export function UsageInsights({ metrics, viewMode, totalCalendarDays }: UsageIns }, ]} /> - + - + } @@ -226,9 +226,9 @@ export function UsageInsights({ metrics, viewMode, totalCalendarDays }: UsageIns }, ]} /> - + - + } @@ -263,10 +263,10 @@ export function UsageInsights({ metrics, viewMode, totalCalendarDays }: UsageIns { label: t('insights.peakWindow.signal'), value: peakSignal }, ]} /> - +
- +
@@ -289,7 +289,7 @@ export function UsageInsights({ metrics, viewMode, totalCalendarDays }: UsageIns : t('insights.quickRead.fallback')}
-
+
) } diff --git a/src/components/features/limits/ProviderLimitsSection.tsx b/src/components/features/limits/ProviderLimitsSection.tsx index 436627d..0c16a87 100644 --- a/src/components/features/limits/ProviderLimitsSection.tsx +++ b/src/components/features/limits/ProviderLimitsSection.tsx @@ -1,6 +1,5 @@ -import { useMemo, useRef } from 'react' +import { useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { motion, useInView } from 'framer-motion' import { Area, CartesianGrid, @@ -17,7 +16,7 @@ import { import { AlertTriangle, CreditCard, ShieldCheck, TrendingUp } from 'lucide-react' import { Card, CardContent } from '@/components/ui/card' import { SectionHeader } from '@/components/ui/section-header' -import { useDashboardSectionMotion } from '@/components/dashboard/dashboard-motion' +import { DashboardMotionItem } from '@/components/dashboard/dashboard-motion' import { AnimatedBarFill } from '@/components/features/animations/AnimatedBarFill' import { ChartAnimationAware, ChartCard, ChartReveal } from '@/components/charts/ChartCard' import { ChartLegend } from '@/components/charts/ChartLegend' @@ -103,10 +102,6 @@ export function ProviderLimitsSection({ selectedMonth, }: ProviderLimitsSectionProps) { const { t } = useTranslation() - const sectionMotion = useDashboardSectionMotion() - const sectionRef = useRef(null) - const localInView = useInView(sectionRef, { once: true, amount: 0.2 }) - const inView = sectionMotion?.sectionVisible ?? localInView const { rows, @@ -229,7 +224,7 @@ export function ProviderLimitsSection({ if (providers.length === 0) return null return ( -
+
{atLimitCount > 0 && ( - +
{t('limits.warningBanner', { count: atLimitCount })}
-
+ )}
@@ -278,12 +268,7 @@ export function ProviderLimitsSection({ icon: , }, ].map((item, index) => ( - +
@@ -300,7 +285,7 @@ export function ProviderLimitsSection({
-
+ ))}
@@ -316,12 +301,7 @@ export function ProviderLimitsSection({ const subscriptionProgressWidth = Math.min(subscriptionProgress, 100) return ( - + @@ -446,7 +424,7 @@ export function ProviderLimitsSection({
- + ) })}
@@ -482,11 +460,9 @@ export function ProviderLimitsSection({ : '0%' return ( -
@@ -537,7 +513,6 @@ export function ProviderLimitsSection({ : 'absolute top-5 left-0 h-4 rounded-full bg-sky-400' } width={withinLimitWidth} - active={inView} delayMs={220 + index * 40} durationMs={680} /> @@ -547,7 +522,6 @@ export function ProviderLimitsSection({ className="absolute top-5 h-4 rounded-r-full bg-red-400" style={{ left: limitPosition }} width={overLimitWidth} - active={inView} delayMs={260 + index * 40} durationMs={680} /> @@ -568,7 +542,6 @@ export function ProviderLimitsSection({ @@ -629,7 +602,7 @@ export function ProviderLimitsSection({
- + ) })}
@@ -666,11 +639,9 @@ export function ProviderLimitsSection({ : '0%' return ( -
@@ -717,7 +688,6 @@ export function ProviderLimitsSection({ @@ -727,7 +697,6 @@ export function ProviderLimitsSection({ className="absolute top-5 h-4 rounded-r-full bg-emerald-400" style={{ left: subPosition }} width={overSubscriptionWidth} - active={inView} delayMs={260 + index * 40} durationMs={680} /> @@ -748,7 +717,6 @@ export function ProviderLimitsSection({ @@ -809,7 +777,7 @@ export function ProviderLimitsSection({
- + ) })}
diff --git a/src/components/tables/RecentDays.tsx b/src/components/tables/RecentDays.tsx index 228dd84..c88496d 100644 --- a/src/components/tables/RecentDays.tsx +++ b/src/components/tables/RecentDays.tsx @@ -13,6 +13,7 @@ import { useTranslation } from 'react-i18next' import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { FormattedValue } from '@/components/ui/formatted-value' +import { AnimatedBarFill } from '@/components/features/animations/AnimatedBarFill' import { InfoHeading } from '@/components/features/help/InfoHeading' import { FEATURE_HELP } from '@/lib/help-content' import { @@ -567,9 +568,11 @@ export function RecentDays({ data, onClickDay, viewMode = 'daily' }: RecentDaysP {formatDate(day.date, 'long')} -
0 ? (day.totalCost / maxCost) * 100 : 0}%` }} + 0 ? (day.totalCost / maxCost) * 100 : 0}%`} + delayMs={140} + durationMs={520} /> diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx index fc63819..fcf6469 100644 --- a/src/components/ui/card.tsx +++ b/src/components/ui/card.tsx @@ -1,5 +1,6 @@ import * as React from 'react' import { motion } from 'framer-motion' +import { useDashboardSectionMotion } from '@/components/dashboard/dashboard-motion' import { cn } from '@/lib/cn' import { useShouldReduceMotion } from '@/lib/motion' @@ -7,18 +8,25 @@ type CardProps = React.ComponentPropsWithoutRef const Card = React.forwardRef(({ className, ...props }, ref) => { const shouldReduceMotion = useShouldReduceMotion() + const dashboardSectionMotion = useDashboardSectionMotion() const motionProps = shouldReduceMotion ? { initial: false as const, animate: { opacity: 1, y: 0 }, transition: { duration: 0 }, } - : { - initial: { opacity: 0, y: 14 }, - whileInView: { opacity: 1, y: 0 }, - viewport: { once: true, amount: 0.15 }, - transition: { duration: 0.35, ease: 'easeOut' as const }, - } + : dashboardSectionMotion + ? { + initial: false as const, + animate: { opacity: 1, y: 0 }, + transition: { duration: 0 }, + } + : { + initial: { opacity: 0, y: 14 }, + whileInView: { opacity: 1, y: 0 }, + viewport: { once: true, amount: 0.15 }, + transition: { duration: 0.35, ease: 'easeOut' as const }, + } return ( { expect(savingsValue).toHaveClass('text-rose-700') const withCacheRow = screen.getByText('With cache').parentElement - const withCacheBar = withCacheRow?.querySelector('[style*="width: 100%"]') as HTMLElement | null + const withCacheBar = withCacheRow?.querySelector('.bg-rose-500\\/60') as HTMLElement | null expect(withCacheBar).not.toBeNull() expect(withCacheBar?.className).toContain('bg-rose-500/60') expect(container.querySelector('[style*="width: 120%"]')).toBeNull() diff --git a/tests/frontend/dashboard-motion.test.tsx b/tests/frontend/dashboard-motion.test.tsx index 9f1dca0..4ed6b3f 100644 --- a/tests/frontend/dashboard-motion.test.tsx +++ b/tests/frontend/dashboard-motion.test.tsx @@ -59,8 +59,14 @@ describe('AnimatedDashboardSection', () => { }) it('preloads before reveal and only activates chart animation once visible', () => { + const handlePreload = vi.fn() + render( - + @@ -73,15 +79,22 @@ describe('AnimatedDashboardSection', () => { MockIntersectionObserver.instances[0]?.trigger(true) }) + expect(handlePreload).toHaveBeenCalledTimes(1) expect(screen.getByTestId('section-visible')).toHaveTextContent('false') expect(screen.getByTestId('chart-active')).toHaveTextContent('false') - expect(screen.getByTestId('chart-delay')).toHaveTextContent('120') act(() => { MockIntersectionObserver.instances[1]?.trigger(true) }) expect(screen.getByTestId('section-visible')).toHaveTextContent('true') + expect(screen.getByTestId('chart-active')).toHaveTextContent('false') + + act(() => { + MockIntersectionObserver.instances.slice(2).forEach((observer) => observer.trigger(true)) + }) + expect(screen.getByTestId('chart-active')).toHaveTextContent('true') + expect(screen.getByTestId('chart-delay')).toHaveTextContent('190') }) }) From 27913dfd19b4e73a4892c8d6f8a85aa51ce36f93 Mon Sep 17 00:00:00 2001 From: tyl3r-ch Date: Wed, 15 Apr 2026 21:46:12 +0200 Subject: [PATCH 3/8] v6.2.2: Polish dashboard chart motion --- src/components/charts/ChartLegend.tsx | 20 +- src/components/charts/CostByModel.tsx | 20 +- .../charts/DistributionAnalysis.tsx | 224 ++++---- .../charts/RequestCacheHitRateByModel.tsx | 482 +++++++++--------- src/components/charts/RequestsOverTime.tsx | 22 +- src/components/charts/TokenTypes.tsx | 20 +- src/components/charts/chart-theme.ts | 14 +- .../dashboard/DashboardSections.tsx | 49 +- src/components/dashboard/dashboard-motion.tsx | 60 ++- .../features/animations/AnimatedBarFill.tsx | 10 +- .../features/cache-roi/CacheROI.tsx | 25 +- .../features/limits/ProviderLimitsSection.tsx | 26 +- .../request-quality/RequestQuality.tsx | 56 +- tests/frontend/cache-roi.test.tsx | 75 +++ .../chart-legend-integration.test.tsx | 110 ++++ tests/frontend/cost-over-time.test.tsx | 21 +- tests/frontend/dashboard-motion.test.tsx | 21 +- tests/frontend/distribution-analysis.test.tsx | 125 +++++ tests/frontend/info-heading.test.tsx | 4 +- .../frontend/provider-limits-section.test.tsx | 49 +- .../request-cache-hit-rate-by-model.test.tsx | 109 ++++ tests/frontend/request-quality.test.tsx | 75 ++- tests/frontend/toast.test.tsx | 4 +- tests/integration/server.test.ts | 4 +- 24 files changed, 1124 insertions(+), 501 deletions(-) create mode 100644 tests/frontend/chart-legend-integration.test.tsx create mode 100644 tests/frontend/distribution-analysis.test.tsx create mode 100644 tests/frontend/request-cache-hit-rate-by-model.test.tsx diff --git a/src/components/charts/ChartLegend.tsx b/src/components/charts/ChartLegend.tsx index 1cdd44e..fb32286 100644 --- a/src/components/charts/ChartLegend.tsx +++ b/src/components/charts/ChartLegend.tsx @@ -1,3 +1,6 @@ +import type { ReactNode } from 'react' +import { cn } from '@/lib/cn' + interface ChartLegendEntry { id?: string | number dataKey?: string | number @@ -5,25 +8,32 @@ interface ChartLegendEntry { value?: string | number } +interface ChartLegendProps { + payload?: ChartLegendEntry[] + className?: string + renderLabel?: (entry: ChartLegendEntry) => ReactNode +} + /** Renders a compact responsive legend for Recharts payload items. */ -export function ChartLegend({ payload }: { payload?: ChartLegendEntry[] }) { +export function ChartLegend({ payload, className, renderLabel }: ChartLegendProps) { if (!payload?.length) return null return ( -
-
+
+
{payload.map((entry, index) => { const color = typeof entry.color === 'string' ? entry.color : 'currentColor' const label = String(entry.value ?? '') + const renderedLabel = renderLabel ? renderLabel(entry) : label const key = entry.id ?? entry.dataKey ?? `${label}-${color}-${index}` return ( -
+
- {label} + {renderedLabel}
) })} diff --git a/src/components/charts/CostByModel.tsx b/src/components/charts/CostByModel.tsx index c107901..468f881 100644 --- a/src/components/charts/CostByModel.tsx +++ b/src/components/charts/CostByModel.tsx @@ -1,6 +1,7 @@ import { ResponsiveContainer, PieChart, Pie, Cell, Tooltip, Legend } from 'recharts' import { useTranslation } from 'react-i18next' import { ChartCard, ChartAnimationAware, ChartReveal } from './ChartCard' +import { ChartLegend } from './ChartLegend' import { CustomTooltip } from './CustomTooltip' import { getRadialAnimationProps } from './chart-theme' import { getModelColor } from '@/lib/model-utils' @@ -94,15 +95,16 @@ export function CostByModel({ data }: CostByModelProps) { formatCurrency(v)} />} /> { - const entry = data.find((d) => d.name === value) - return ( - - {value} ({entry ? formatCurrency(entry.value) : ''}) - - ) - }} + content={ + { + const value = String(entry.value ?? '') + const segment = data.find((item) => item.name === value) + return `${value} (${segment ? formatCurrency(segment.value) : ''})` + }} + /> + } /> diff --git a/src/components/charts/DistributionAnalysis.tsx b/src/components/charts/DistributionAnalysis.tsx index 64dcc7a..4be5835 100644 --- a/src/components/charts/DistributionAnalysis.tsx +++ b/src/components/charts/DistributionAnalysis.tsx @@ -1,4 +1,4 @@ -import { useId, useMemo, useRef } from 'react' +import { useId, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { ResponsiveContainer, @@ -10,9 +10,7 @@ import { Tooltip, Cell, } from 'recharts' -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' -import { useDashboardElementMotion } from '@/components/dashboard/dashboard-motion' -import { InfoHeading } from '@/components/features/help/InfoHeading' +import { ChartAnimationAware, ChartCard, ChartReveal, useChartAnimationRunKey } from './ChartCard' import { CHART_COLORS, CHART_MARGIN, getBarAnimationProps } from './chart-theme' import { CHART_HELP } from '@/lib/help-content' import { formatCurrency, formatNumber, formatTokens, periodLabel } from '@/lib/formatters' @@ -30,6 +28,11 @@ interface DistributionBin { count: number } +interface DistributionSeries { + title: string + data: DistributionBin[] +} + function toBins(values: number[], formatter: (value: number) => string): DistributionBin[] { if (values.length === 0) return [] const min = Math.min(...values) @@ -94,14 +97,8 @@ function DistributionTooltip({ export function DistributionAnalysis({ data, viewMode = 'daily' }: DistributionAnalysisProps) { const { t } = useTranslation() const uid = useId().replace(/:/g, '') - const cardRef = useRef(null) - const chartMotion = useDashboardElementMotion(cardRef, { - kind: 'chart', - amount: 0.28, - }) - const animate = !chartMotion.shouldReduceMotion && chartMotion.active - const distributions = useMemo(() => { + const distributions = useMemo(() => { if (data.length < 2) return [] const costs = data.map((entry) => entry.totalCost) @@ -128,102 +125,125 @@ export function DistributionAnalysis({ data, viewMode = 'daily' }: DistributionA if (data.length < 2) { return ( - - - - - {t('charts.distribution.title')} - - - - -
- {t('charts.distribution.requiresData')} -
-
-
+ +
+ {t('charts.distribution.requiresData')} +
+
) } return ( - - - - - {t('charts.distribution.title')} - - - - - {distributions.map((distribution, index) => ( -
-
-
-
- {distribution.title} -
-
- {distribution.data.length} {t('charts.distribution.buckets')} -
-
- - - - - - - - - - 5 ? -16 : 0} - textAnchor={distribution.data.length > 5 ? 'end' : 'middle'} - height={distribution.data.length > 5 ? 48 : 30} - /> - - } - cursor={{ fill: 'hsl(var(--muted))', opacity: 0.15 }} - /> - - {distribution.data.map((_, binIndex) => { - const intensity = - distribution.data.length > 1 ? binIndex / (distribution.data.length - 1) : 0 - const opacity = 0.45 + intensity * 0.35 - return ( - - ) - })} - - - + + + + ) +} + +function DistributionCharts({ + distributions, + uid, +}: { + distributions: DistributionSeries[] + uid: string +}) { + const { t } = useTranslation() + const runKey = useChartAnimationRunKey() + + return ( +
+ {distributions.map((distribution, index) => ( +
+
+
+ {distribution.title} +
+
+ {distribution.data.length} {t('charts.distribution.buckets')}
- ))} - - + + {(animate) => ( + + + + + + + + + + + 5 ? -16 : 0} + textAnchor={distribution.data.length > 5 ? 'end' : 'middle'} + height={distribution.data.length > 5 ? 48 : 30} + /> + + } + cursor={{ fill: 'hsl(var(--muted))', opacity: 0.15 }} + /> + + {distribution.data.map((_, binIndex) => { + const intensity = + distribution.data.length > 1 + ? binIndex / (distribution.data.length - 1) + : 0 + const opacity = 0.45 + intensity * 0.35 + return ( + + ) + })} + + + + + )} + +
+ ))} +
) } diff --git a/src/components/charts/RequestCacheHitRateByModel.tsx b/src/components/charts/RequestCacheHitRateByModel.tsx index c2e5c06..f7cdbc4 100644 --- a/src/components/charts/RequestCacheHitRateByModel.tsx +++ b/src/components/charts/RequestCacheHitRateByModel.tsx @@ -207,258 +207,266 @@ export function RequestCacheHitRateByModel({
) - return ( - []} - valueKey="totalRate" - valueFormatter={formatRate} - expandedExtra={expandedExtra} - > - {(expanded) => ( - <> -
-
-
- {t('charts.requestCacheHitRate.totalRate')} -
-
- {formatRate(summary.total.totalRate)} -
+ const renderCharts = (expanded: boolean) => { + const snapshotBarSize = expanded ? 8 : 6 + + return ( + <> +
+
+
+ {t('charts.requestCacheHitRate.totalRate')}
-
-
- {t('charts.requestCacheHitRate.trailing7Rate')} -
-
- {formatRate(summary.total.trailing7Rate)} -
+
+ {formatRate(summary.total.totalRate)}
-
-
- {t('charts.requestCacheHitRate.topModel')} -
-
{summary.topModel?.model ?? '–'}
+
+
+
+ {t('charts.requestCacheHitRate.trailing7Rate')}
-
-
- {t('charts.requestCacheHitRate.models')} -
-
{summary.models}
+
+ {formatRate(summary.total.trailing7Rate)}
+
+
+ {t('charts.requestCacheHitRate.topModel')} +
+
{summary.topModel?.model ?? '–'}
+
+
+
+ {t('charts.requestCacheHitRate.models')} +
+
{summary.models}
+
+
-
-
-
- {t('charts.requestCacheHitRate.timelineHeading', { unit: periodUnit(viewMode) })} -
- - {(animate) => ( - - - - - - - - - - - - - formatRate(value)} - pinnedEntryNames={[t('charts.requestCacheHitRate.totalRate')]} - showComputedTotal={false} - hideZeroValues - /> - } - cursor={{ fill: 'hsl(var(--muted))', opacity: 0.12 }} - /> - } /> - +
+
+
+ {t('charts.requestCacheHitRate.timelineHeading', { unit: periodUnit(viewMode) })} +
+ + {(animate) => ( + + + + + + + + + + + + + formatRate(value)} + pinnedEntryNames={[t('charts.requestCacheHitRate.totalRate')]} + showComputedTotal={false} + hideZeroValues + /> + } + cursor={{ fill: 'hsl(var(--muted))', opacity: 0.12 }} + /> + } /> + + + {lineSeries.map((series, index) => ( - {lineSeries.map((series, index) => ( - - ))} - - - - )} - -
+ ))} + + + + )} + +
-
-
- {t('charts.requestCacheHitRate.modelBreakdownHeading')} -
- - {(animate) => ( - - +
+ {t('charts.requestCacheHitRate.modelBreakdownHeading')} +
+ + {(animate) => ( + + + - + + + formatRate(value)} + pinnedEntryNames={[ + t('charts.requestCacheHitRate.totalRate'), + t('charts.requestCacheHitRate.trailing7Rate'), + ]} + showComputedTotal={false} + /> + } + cursor={{ fill: 'hsl(var(--muted))', opacity: 0.12 }} + /> + } /> + - - - - formatRate(value)} - pinnedEntryNames={[ - t('charts.requestCacheHitRate.totalRate'), - t('charts.requestCacheHitRate.trailing7Rate'), - ]} - showComputedTotal={false} - /> - } - cursor={{ fill: 'hsl(var(--muted))', opacity: 0.12 }} - /> - } /> - - {barData.map((entry) => ( - - ))} - - - {barData.map((entry) => ( - - ))} - - - - - )} - -
+ {barData.map((entry) => ( + + ))} + + + {barData.map((entry) => ( + + ))} + + +
+
+ )} +
- - )} +
+ + ) + } + + return ( + []} + valueKey="totalRate" + valueFormatter={formatRate} + expandedExtra={expandedExtra} + > + {renderCharts} ) } diff --git a/src/components/charts/RequestsOverTime.tsx b/src/components/charts/RequestsOverTime.tsx index 714b809..7316926 100644 --- a/src/components/charts/RequestsOverTime.tsx +++ b/src/components/charts/RequestsOverTime.tsx @@ -431,18 +431,16 @@ export function RequestsOverTime({ data, viewMode = 'daily', onClickDay }: Reque content={ formatRequests(v)} />} /> { - const entry = donutData.find((d) => d.name === value) - return ( - - {value} ({entry ? formatRequests(entry.value) : ''}) - - ) - }} + content={ + { + const value = String(entry.value ?? '') + const segment = donutData.find((item) => item.name === value) + return `${value} (${segment ? formatRequests(segment.value) : ''})` + }} + /> + } /> diff --git a/src/components/charts/TokenTypes.tsx b/src/components/charts/TokenTypes.tsx index 7000c30..5fb5dc3 100644 --- a/src/components/charts/TokenTypes.tsx +++ b/src/components/charts/TokenTypes.tsx @@ -1,6 +1,7 @@ import { ResponsiveContainer, PieChart, Pie, Cell, Tooltip, Legend } from 'recharts' import { useTranslation } from 'react-i18next' import { ChartCard, ChartAnimationAware, ChartReveal } from './ChartCard' +import { ChartLegend } from './ChartLegend' import { CustomTooltip } from './CustomTooltip' import { CHART_COLORS, getRadialAnimationProps } from './chart-theme' import { formatTokens } from '@/lib/formatters' @@ -88,15 +89,16 @@ export function TokenTypes({ data }: TokenTypesProps) { formatTokens(v)} />} /> { - const entry = data.find((d) => d.name === value) - return ( - - {value} ({entry ? formatTokens(entry.value) : ''}) - - ) - }} + content={ + { + const value = String(entry.value ?? '') + const segment = data.find((item) => item.name === value) + return `${value} (${segment ? formatTokens(segment.value) : ''})` + }} + /> + } /> diff --git a/src/components/charts/chart-theme.ts b/src/components/charts/chart-theme.ts index c44a799..02f3075 100644 --- a/src/components/charts/chart-theme.ts +++ b/src/components/charts/chart-theme.ts @@ -18,14 +18,14 @@ export const CHART_MARGIN = { top: 5, right: 10, left: 10, bottom: 5 } /** Defines the shared chart animation timings. */ export const CHART_ANIMATION = { - duration: 860, + duration: 1290, easing: 'ease-out' as const, - stagger: 80, - slowDuration: 1040, - chartStartDelay: 190, - barDuration: 620, - radialDuration: 820, - revealDuration: 520, + stagger: 120, + slowDuration: 1560, + chartStartDelay: 285, + barDuration: 930, + radialDuration: 1230, + revealDuration: 780, } type SeriesRole = 'primary' | 'secondary' | 'stacked' diff --git a/src/components/dashboard/DashboardSections.tsx b/src/components/dashboard/DashboardSections.tsx index 68d23b5..abc926b 100644 --- a/src/components/dashboard/DashboardSections.tsx +++ b/src/components/dashboard/DashboardSections.tsx @@ -153,6 +153,12 @@ function lazyWithPreload>( return Component } +function preloadComponents( + ...components: Array<{ preload: () => Promise }> +): Promise { + return Promise.all(components.map((component) => component.preload())) +} + interface DashboardSectionsProps { sectionOrder: DashboardSectionId[] sectionVisibility: Record @@ -271,7 +277,10 @@ export function DashboardSections({ const renderAnimatedSection = ( sectionId: DashboardSectionId, children: ReactNode, - { eager = false, onPreload }: { eager?: boolean; onPreload?: () => void } = {}, + { + eager = false, + onPreload, + }: { eager?: boolean; onPreload?: () => void | Promise } = {}, ) => { const sectionAnchorId = sectionId === 'costAnalysis' @@ -437,8 +446,7 @@ export function DashboardSections({ , { onPreload: () => { - void CostForecast.preload() - void CacheROI.preload() + return preloadComponents(CostForecast, CacheROI) }, }, ) @@ -458,7 +466,7 @@ export function DashboardSections({ ), { onPreload: () => { - void ProviderLimitsSection.preload() + return preloadComponents(ProviderLimitsSection) }, }, ) @@ -500,11 +508,13 @@ export function DashboardSections({ , { onPreload: () => { - void CostByModelOverTime.preload() - void CumulativeCost.preload() - void CostByWeekday.preload() - void TokenEfficiency.preload() - void ModelMix.preload() + return preloadComponents( + CostByModelOverTime, + CumulativeCost, + CostByWeekday, + TokenEfficiency, + ModelMix, + ) }, }, ) @@ -529,8 +539,7 @@ export function DashboardSections({ , { onPreload: () => { - void TokensOverTime.preload() - void TokenTypes.preload() + return preloadComponents(TokensOverTime, TokenTypes) }, }, ) @@ -572,9 +581,11 @@ export function DashboardSections({ , { onPreload: () => { - void RequestsOverTime.preload() - void RequestCacheHitRateByModel.preload() - void RequestQuality.preload() + return preloadComponents( + RequestsOverTime, + RequestCacheHitRateByModel, + RequestQuality, + ) }, }, ) @@ -607,8 +618,7 @@ export function DashboardSections({ , { onPreload: () => { - void DistributionAnalysis.preload() - void CorrelationAnalysis.preload() + return preloadComponents(DistributionAnalysis, CorrelationAnalysis) }, }, ) @@ -670,8 +680,7 @@ export function DashboardSections({ , { onPreload: () => { - void PeriodComparison.preload() - void AnomalyDetection.preload() + return preloadComponents(PeriodComparison, AnomalyDetection) }, }, ) @@ -717,9 +726,7 @@ export function DashboardSections({ , { onPreload: () => { - void ModelEfficiency.preload() - void ProviderEfficiency.preload() - void RecentDays.preload() + return preloadComponents(ModelEfficiency, ProviderEfficiency, RecentDays) }, }, ) diff --git a/src/components/dashboard/dashboard-motion.tsx b/src/components/dashboard/dashboard-motion.tsx index 2a6b57e..1883a80 100644 --- a/src/components/dashboard/dashboard-motion.tsx +++ b/src/components/dashboard/dashboard-motion.tsx @@ -15,19 +15,19 @@ import { useShouldReduceMotion } from '@/lib/motion' /** Defines the shared dashboard motion timings for section reveal and child chart orchestration. */ export const DASHBOARD_MOTION = { - sectionPreloadMargin: '0px 0px 30% 0px', + sectionPreloadMargin: '0px 0px 45% 0px', sectionRevealAmount: 0.14, sectionRevealOffset: 12, - sectionRevealDuration: 0.52, + sectionRevealDuration: 0.6, sectionRevealEase: [0.22, 1, 0.36, 1] as const, - placeholderFadeDuration: 0.28, + placeholderFadeDuration: 0.34, itemRevealAmount: 0.24, itemRevealOffset: 8, itemRevealDuration: 0.42, - itemStaggerMs: 70, - chartStartDelayMs: 190, - meterStartDelayMs: 250, - meterDurationMs: 640, + itemStaggerMs: 105, + chartStartDelayMs: 285, + meterStartDelayMs: 375, + meterDurationMs: 960, } interface DashboardSectionMotionState { @@ -156,7 +156,7 @@ interface AnimatedDashboardSectionProps { contentClassName?: string placeholderClassName?: string eager?: boolean - onPreload?: (() => void) | undefined + onPreload?: (() => void | Promise) | undefined } /** Gates one dashboard section by viewport visibility and exposes motion timing to descendants. */ @@ -171,27 +171,52 @@ export function AnimatedDashboardSection({ }: AnimatedDashboardSectionProps) { const sectionRef = useRef(null) const hasTriggeredPreloadRef = useRef(false) + const preloadPromiseRef = useRef | null>(null) + const isMountedRef = useRef(true) const shouldReduceMotion = useShouldReduceMotion() const [contentPrepared, setContentPrepared] = useState(eager) const [sectionVisible, setSectionVisible] = useState(eager) + useEffect(() => { + return () => { + isMountedRef.current = false + } + }, []) + const triggerPreload = useCallback(() => { - if (hasTriggeredPreloadRef.current) return + if (hasTriggeredPreloadRef.current) return preloadPromiseRef.current + hasTriggeredPreloadRef.current = true - setContentPrepared(true) - onPreload?.() + + if (!onPreload) { + setContentPrepared(true) + const preloadTask = Promise.resolve() + preloadPromiseRef.current = preloadTask + return preloadTask + } + + const preloadTask = Promise.resolve(onPreload()) + .catch(() => undefined) + .finally(() => { + if (isMountedRef.current) { + setContentPrepared(true) + } + }) + + preloadPromiseRef.current = preloadTask + return preloadTask }, [onPreload]) useEffect(() => { if (eager) { - triggerPreload() + void triggerPreload() setSectionVisible(true) return } const element = sectionRef.current if (!element || typeof IntersectionObserver === 'undefined') { - triggerPreload() + void triggerPreload() setSectionVisible(true) return } @@ -200,7 +225,7 @@ export function AnimatedDashboardSection({ (entries) => { const entry = entries[0] if (entry?.isIntersecting) { - triggerPreload() + void triggerPreload() preloadObserver.disconnect() } }, @@ -214,7 +239,7 @@ export function AnimatedDashboardSection({ (entries) => { const entry = entries[0] if (entry?.isIntersecting) { - triggerPreload() + void triggerPreload() setSectionVisible(true) revealObserver.disconnect() } @@ -275,7 +300,10 @@ export function AnimatedDashboardSection({
) : ( 0 ? (actualCost / hypotheticalCost) * 100 : 100), ) + const savedWidth = Math.max(0, 100 - barWidth) const withoutCacheTextClass = 'text-rose-700 dark:text-rose-300' const withCacheTextClass = savingsSign < 0 ? 'text-rose-700 dark:text-rose-300' : 'text-emerald-700 dark:text-emerald-300' @@ -149,19 +150,31 @@ export function CacheROI({ data, viewMode = 'daily' }: CacheROIProps) {
{t('cacheRoi.withoutCache')}
- +
{t('cacheRoi.withCache')} -
+
-
+ {hasPositiveSavings && savedWidth > 0 ? ( + + ) : ( +
+ )}
diff --git a/src/components/features/limits/ProviderLimitsSection.tsx b/src/components/features/limits/ProviderLimitsSection.tsx index 0c16a87..95d482f 100644 --- a/src/components/features/limits/ProviderLimitsSection.tsx +++ b/src/components/features/limits/ProviderLimitsSection.tsx @@ -373,8 +373,7 @@ export function ProviderLimitsSection({ : 'h-full' } width={`${riskProgress}%`} - delayMs={220 + index * 40} - durationMs={680} + order={index * 2} {...(row.riskStatus === 'limit' || row.riskStatus === 'warning' ? {} : { style: { backgroundColor: providerStyle.color } })} @@ -412,9 +411,8 @@ export function ProviderLimitsSection({ ? 'h-full bg-emerald-400' : 'h-full bg-amber-300' } - width={`${Math.max(8, subscriptionProgressWidth)}%`} - delayMs={260 + index * 40} - durationMs={680} + width={`${subscriptionProgressWidth}%`} + order={index * 2 + 1} /> ) : (
@@ -513,8 +511,7 @@ export function ProviderLimitsSection({ : 'absolute top-5 left-0 h-4 rounded-full bg-sky-400' } width={withinLimitWidth} - delayMs={220 + index * 40} - durationMs={680} + order={index * 2} /> {row.overrun > 0 && ( @@ -522,8 +519,7 @@ export function ProviderLimitsSection({ className="absolute top-5 h-4 rounded-r-full bg-red-400" style={{ left: limitPosition }} width={overLimitWidth} - delayMs={260 + index * 40} - durationMs={680} + order={index * 2 + 1} /> )} @@ -542,8 +538,7 @@ export function ProviderLimitsSection({ )} @@ -688,8 +683,7 @@ export function ProviderLimitsSection({ {row.subscriptionGain > 0 && ( @@ -697,8 +691,7 @@ export function ProviderLimitsSection({ className="absolute top-5 h-4 rounded-r-full bg-emerald-400" style={{ left: subPosition }} width={overSubscriptionWidth} - delayMs={260 + index * 40} - durationMs={680} + order={index * 2 + 1} /> )} @@ -717,8 +710,7 @@ export function ProviderLimitsSection({ )} diff --git a/src/components/features/request-quality/RequestQuality.tsx b/src/components/features/request-quality/RequestQuality.tsx index 40eb919..29e9529 100644 --- a/src/components/features/request-quality/RequestQuality.tsx +++ b/src/components/features/request-quality/RequestQuality.tsx @@ -1,11 +1,9 @@ import { useTranslation } from 'react-i18next' -import { useDashboardSectionMotion } from '@/components/dashboard/dashboard-motion' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { InfoHeading } from '@/components/features/help/InfoHeading' import { AnimatedBarFill } from '@/components/features/animations/AnimatedBarFill' import { FEATURE_HELP } from '@/lib/help-content' import { formatCurrency, formatNumber, formatPercent, formatTokens } from '@/lib/formatters' -import { useShouldReduceMotion } from '@/lib/motion' import type { DashboardMetrics, ViewMode } from '@/types' interface RequestQualityProps { @@ -16,8 +14,6 @@ interface RequestQualityProps { /** Renders request-efficiency summary cards for the current slice. */ export function RequestQuality({ metrics, viewMode }: RequestQualityProps) { const { t } = useTranslation() - const sectionMotion = useDashboardSectionMotion() - const shouldReduceMotion = useShouldReduceMotion() const cachePerRequest = metrics.totalRequests > 0 ? metrics.totalCacheRead / metrics.totalRequests : 0 const thinkingPerRequest = @@ -71,38 +67,30 @@ export function RequestQuality({ metrics, viewMode }: RequestQualityProps) {
- {qualityMetrics.map((item) => { - const barActive = - shouldReduceMotion || sectionMotion?.sectionVisible !== undefined - ? shouldReduceMotion || sectionMotion?.sectionVisible - : undefined - - return ( -
-
- {item.label} -
-
{item.value}
-
{item.hint}
-
- 0 - ? `${Math.max(item.progress * 100, 6)}%` - : '0%' + {qualityMetrics.map((item) => ( +
+
+ {item.label} +
+
{item.value}
+
{item.hint}
+
+ 0 + ? `${Math.max(item.progress * 100, 6)}%` : '0%' - } - {...(barActive !== undefined ? { active: barActive } : {})} - /> -
+ : '0%' + } + />
- ) - })} +
+ ))}
diff --git a/tests/frontend/cache-roi.test.tsx b/tests/frontend/cache-roi.test.tsx index 3acaf95..805b3b4 100644 --- a/tests/frontend/cache-roi.test.tsx +++ b/tests/frontend/cache-roi.test.tsx @@ -7,6 +7,34 @@ import { TooltipProvider } from '@/components/ui/tooltip' import { initI18n } from '@/lib/i18n' import type { DailyUsage } from '@/types' +vi.mock('@/components/features/animations/AnimatedBarFill', () => ({ + AnimatedBarFill: ({ + width, + className, + order, + delayMs, + durationMs, + style, + }: { + width: string + className?: string + order?: number + delayMs?: number + durationMs?: number + style?: React.CSSProperties + }) => ( +
+ ), +})) + describe('CacheROI', () => { beforeEach(async () => { vi.stubGlobal( @@ -67,4 +95,51 @@ describe('CacheROI', () => { expect(withCacheBar?.className).toContain('bg-rose-500/60') expect(container.querySelector('[style*="width: 120%"]')).toBeNull() }) + + it('animates both the paid and saved comparison segments through AnimatedBarFill', () => { + const data: DailyUsage[] = [ + { + date: '2026-04-07', + inputTokens: 100, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 1_000_000, + thinkingTokens: 0, + totalTokens: 1_000_100, + totalCost: 2, + requestCount: 2, + modelsUsed: ['mystery-model'], + modelBreakdowns: [ + { + modelName: 'mystery-model', + inputTokens: 100, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 1_000_000, + thinkingTokens: 0, + cost: 2, + requestCount: 2, + }, + ], + }, + ] + + render( + + + , + ) + + const fills = screen.getAllByTestId('animated-bar-fill') + expect(fills).toHaveLength(3) + expect(fills[0]).toHaveAttribute('data-width', '100%') + expect(fills[0]).toHaveAttribute('data-order', '0') + const paidWidth = Number.parseFloat(fills[1].getAttribute('data-width') ?? '0') + const savedWidth = Number.parseFloat(fills[2].getAttribute('data-width') ?? '0') + expect(paidWidth).toBeGreaterThan(0) + expect(fills[1]).toHaveAttribute('data-order', '0') + expect(savedWidth).toBeGreaterThan(0) + expect(fills[2]).toHaveAttribute('data-order', '1') + expect(paidWidth + savedWidth).toBeCloseTo(100, 5) + }) }) diff --git a/tests/frontend/chart-legend-integration.test.tsx b/tests/frontend/chart-legend-integration.test.tsx new file mode 100644 index 0000000..a0f80d2 --- /dev/null +++ b/tests/frontend/chart-legend-integration.test.tsx @@ -0,0 +1,110 @@ +// @vitest-environment jsdom + +import { cloneElement, type ReactElement, type ReactNode } from 'react' +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { CostByModel } from '@/components/charts/CostByModel' +import { RequestsOverTime } from '@/components/charts/RequestsOverTime' +import { TokenTypes } from '@/components/charts/TokenTypes' +import { TooltipProvider } from '@/components/ui/tooltip' +import { initI18n } from '@/lib/i18n' + +let lastLegendPayload: Array<{ value: string; color: string }> = [] + +vi.mock('recharts', () => ({ + ResponsiveContainer: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + PieChart: ({ children }: { children: ReactNode }) =>
{children}
, + ComposedChart: ({ children }: { children: ReactNode }) =>
{children}
, + Pie: ({ children, data = [] }: { children: ReactNode; data?: Array<{ name: string }> }) => { + lastLegendPayload = data.map((entry, index) => ({ + value: entry.name, + color: `hsl(${(index + 1) * 40} 70% 50%)`, + })) + return
{children}
+ }, + Legend: ({ content }: { content?: ReactElement }) => + content ?
{cloneElement(content, { payload: lastLegendPayload })}
: null, + Tooltip: () => null, + Cell: () => null, + Area: () => null, + Line: () => null, + XAxis: () => null, + YAxis: () => null, + CartesianGrid: () => null, +})) + +describe('Chart legend integrations', () => { + beforeEach(async () => { + lastLegendPayload = [] + vi.stubGlobal( + 'IntersectionObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ) + await initI18n('en') + }) + + it('wraps CostByModel legend labels instead of relying on horizontal scroll', () => { + const { container } = render( + + + , + ) + + expect(screen.getByText(/GPT-5\.4 \(\$60(?:\.0+)?\)/)).toBeInTheDocument() + expect(screen.getByText(/Claude Sonnet 4\.5 \(\$25(?:\.0+)?\)/)).toBeInTheDocument() + expect(container.querySelector('.overflow-x-auto')).toBeNull() + expect(container.querySelector('.flex-wrap')).not.toBeNull() + }) + + it('wraps TokenTypes legend labels instead of relying on horizontal scroll', () => { + const { container } = render( + + + , + ) + + expect(screen.getByText('Cache Write (1.2k)')).toBeInTheDocument() + expect(screen.getByText('Cache Read (950)')).toBeInTheDocument() + expect(container.querySelector('.overflow-x-auto')).toBeNull() + expect(container.querySelector('.flex-wrap')).not.toBeNull() + }) + + it('wraps RequestsOverTime donut legend labels instead of relying on horizontal scroll', () => { + const { container } = render( + + + , + ) + + expect(screen.getByText('GPT-5.4 (80)')).toBeInTheDocument() + expect(screen.getByText('Claude Sonnet 4.5 (40)')).toBeInTheDocument() + expect(container.querySelector('.overflow-x-auto')).toBeNull() + expect(container.querySelector('.flex-wrap')).not.toBeNull() + }) +}) diff --git a/tests/frontend/cost-over-time.test.tsx b/tests/frontend/cost-over-time.test.tsx index 787e420..5c92e1e 100644 --- a/tests/frontend/cost-over-time.test.tsx +++ b/tests/frontend/cost-over-time.test.tsx @@ -38,16 +38,31 @@ describe('CostOverTime', () => { }) it('renders legend entries in a horizontally readable list', () => { - render( + const { container } = render( , ) expect(screen.getByText('Cost')).toBeInTheDocument() - expect(screen.getByText('7-day avg')).toBeInTheDocument() + expect(screen.getByText('7-day average with a much longer label')).toBeInTheDocument() + expect(container.querySelector('.overflow-x-auto')).toBeNull() + expect(container.querySelector('.flex-wrap')).not.toBeNull() + }) + + it('supports custom legend labels while keeping the wrap layout', () => { + const { container } = render( + `${entry.value} ($42.00)`} + />, + ) + + expect(screen.getByText('GPT-5.4 ($42.00)')).toBeInTheDocument() + expect(container.querySelector('.overflow-x-auto')).toBeNull() + expect(container.querySelector('.flex-wrap')).not.toBeNull() }) }) diff --git a/tests/frontend/dashboard-motion.test.tsx b/tests/frontend/dashboard-motion.test.tsx index 4ed6b3f..7f5687e 100644 --- a/tests/frontend/dashboard-motion.test.tsx +++ b/tests/frontend/dashboard-motion.test.tsx @@ -58,8 +58,12 @@ describe('AnimatedDashboardSection', () => { await initI18n('en') }) - it('preloads before reveal and only activates chart animation once visible', () => { - const handlePreload = vi.fn() + it('preloads before reveal and only activates chart animation once visible', async () => { + let resolvePreload: (() => void) | null = null + const preloadPromise = new Promise((resolve) => { + resolvePreload = resolve + }) + const handlePreload = vi.fn(() => preloadPromise) render( { }) expect(handlePreload).toHaveBeenCalledTimes(1) - expect(screen.getByTestId('section-visible')).toHaveTextContent('false') - expect(screen.getByTestId('chart-active')).toHaveTextContent('false') + expect(screen.queryByTestId('section-visible')).not.toBeInTheDocument() act(() => { MockIntersectionObserver.instances[1]?.trigger(true) }) + expect(screen.queryByTestId('section-visible')).not.toBeInTheDocument() + + await act(async () => { + resolvePreload?.() + await preloadPromise + }) + expect(screen.getByTestId('section-visible')).toHaveTextContent('true') expect(screen.getByTestId('chart-active')).toHaveTextContent('false') @@ -94,7 +104,8 @@ describe('AnimatedDashboardSection', () => { MockIntersectionObserver.instances.slice(2).forEach((observer) => observer.trigger(true)) }) + expect(screen.getByTestId('section-visible')).toHaveTextContent('true') expect(screen.getByTestId('chart-active')).toHaveTextContent('true') - expect(screen.getByTestId('chart-delay')).toHaveTextContent('190') + expect(screen.getByTestId('chart-delay')).toHaveTextContent('285') }) }) diff --git a/tests/frontend/distribution-analysis.test.tsx b/tests/frontend/distribution-analysis.test.tsx new file mode 100644 index 0000000..d1f7329 --- /dev/null +++ b/tests/frontend/distribution-analysis.test.tsx @@ -0,0 +1,125 @@ +// @vitest-environment jsdom + +import { act, render, screen } from '@testing-library/react' +import type { ReactNode } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { DistributionAnalysis } from '@/components/charts/DistributionAnalysis' +import { TooltipProvider } from '@/components/ui/tooltip' +import { initI18n } from '@/lib/i18n' +import type { DailyUsage } from '@/types' + +vi.mock('recharts', () => ({ + ResponsiveContainer: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + BarChart: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + Bar: ({ + children, + dataKey, + isAnimationActive, + }: { + children?: ReactNode + dataKey: string + isAnimationActive?: boolean + }) => ( +
+ {children} +
+ ), + XAxis: () => null, + YAxis: () => null, + CartesianGrid: () => null, + Tooltip: () => null, + Cell: () => null, +})) + +class MockIntersectionObserver { + static instances: MockIntersectionObserver[] = [] + + callback: IntersectionObserverCallback + + constructor(callback: IntersectionObserverCallback) { + this.callback = callback + MockIntersectionObserver.instances.push(this) + } + + observe() {} + + unobserve() {} + + disconnect() {} + + trigger(isIntersecting: boolean) { + this.callback( + [ + { + isIntersecting, + target: document.createElement('div'), + } as IntersectionObserverEntry, + ], + this as unknown as IntersectionObserver, + ) + } +} + +function buildDay(overrides: Partial): DailyUsage { + return { + date: '2026-04-01', + inputTokens: 100, + outputTokens: 40, + cacheCreationTokens: 10, + cacheReadTokens: 20, + thinkingTokens: 5, + totalTokens: 175, + totalCost: 12, + requestCount: 4, + modelsUsed: ['gpt-5.4'], + modelBreakdowns: [], + ...overrides, + } +} + +describe('DistributionAnalysis', () => { + beforeEach(async () => { + MockIntersectionObserver.instances = [] + vi.stubGlobal('IntersectionObserver', MockIntersectionObserver) + await initI18n('en') + }) + + it('keeps histogram bars inactive until the chart card becomes visible', () => { + render( + + + , + ) + + expect(screen.getAllByTestId('distribution-bar-count')).not.toHaveLength(0) + expect( + screen + .getAllByTestId('distribution-bar-count') + .every((bar) => bar.dataset.animate === 'false'), + ).toBe(true) + + act(() => { + MockIntersectionObserver.instances.forEach((observer) => observer.trigger(true)) + }) + + expect( + screen + .getAllByTestId('distribution-bar-count') + .every((bar) => bar.dataset.animate === 'true'), + ).toBe(true) + }) +}) diff --git a/tests/frontend/info-heading.test.tsx b/tests/frontend/info-heading.test.tsx index 12ac80d..55913f7 100644 --- a/tests/frontend/info-heading.test.tsx +++ b/tests/frontend/info-heading.test.tsx @@ -64,7 +64,7 @@ describe('Info heading semantics', () => { const infoButton = screen.getByRole('button', { name: 'Show info' }) expect(infoButton).toBeInTheDocument() expect(infoButton).toHaveClass('focus-visible:ring-2') - }) + }, 15_000) it('keeps feature card titles semantically separate from the info button', () => { render( @@ -75,5 +75,5 @@ describe('Info heading semantics', () => { expect(screen.getByRole('heading', { name: 'Request quality' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Show info' })).toBeInTheDocument() - }) + }, 15_000) }) diff --git a/tests/frontend/provider-limits-section.test.tsx b/tests/frontend/provider-limits-section.test.tsx index a69366d..562b81c 100644 --- a/tests/frontend/provider-limits-section.test.tsx +++ b/tests/frontend/provider-limits-section.test.tsx @@ -6,6 +6,28 @@ import { ProviderLimitsSection } from '@/components/features/limits/ProviderLimi import { initI18n } from '@/lib/i18n' import { TooltipProvider } from '@/components/ui/tooltip' +vi.mock('@/components/features/animations/AnimatedBarFill', () => ({ + AnimatedBarFill: ({ + width, + order, + delayMs, + durationMs, + }: { + width: string + order?: number + delayMs?: number + durationMs?: number + }) => ( +
+ ), +})) + describe('ProviderLimitsSection', () => { beforeEach(async () => { class MockIntersectionObserver { @@ -74,5 +96,30 @@ describe('ProviderLimitsSection', () => { expect(screen.getByText('0% Limit')).toBeInTheDocument() expect(screen.getByText('240% Abo')).toBeInTheDocument() expect(screen.getByText('Offen')).toBeInTheDocument() - }) + }, 15_000) + + it('does not force a visible minimum width or local timing overrides for subscription bars', () => { + render( + + + , + ) + + const fills = screen.getAllByTestId('animated-bar-fill') + expect(fills.some((fill) => fill.getAttribute('data-width') === '8%')).toBe(false) + expect(fills.some((fill) => fill.getAttribute('data-width') === '0%')).toBe(true) + expect(fills.every((fill) => fill.getAttribute('data-delay-ms') === '')).toBe(true) + expect(fills.every((fill) => fill.getAttribute('data-duration-ms') === '')).toBe(true) + }, 15_000) }) diff --git a/tests/frontend/request-cache-hit-rate-by-model.test.tsx b/tests/frontend/request-cache-hit-rate-by-model.test.tsx new file mode 100644 index 0000000..80c2bd8 --- /dev/null +++ b/tests/frontend/request-cache-hit-rate-by-model.test.tsx @@ -0,0 +1,109 @@ +// @vitest-environment jsdom + +import { render, screen } from '@testing-library/react' +import type { ReactNode } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { RequestCacheHitRateByModel } from '@/components/charts/RequestCacheHitRateByModel' +import { TooltipProvider } from '@/components/ui/tooltip' +import { initI18n } from '@/lib/i18n' +import type { DailyUsage } from '@/types' + +vi.mock('recharts', () => ({ + ResponsiveContainer: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + ComposedChart: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + BarChart: ({ + children, + barSize, + maxBarSize, + }: { + children: ReactNode + barSize?: number + maxBarSize?: number + }) => ( +
+ {children} +
+ ), + Area: () => null, + Line: () => null, + XAxis: () => null, + YAxis: () => null, + CartesianGrid: () => null, + Tooltip: () => null, + Legend: () => null, + Bar: ({ dataKey }: { dataKey: string }) =>
, + Cell: () => null, +})) + +class MockIntersectionObserver { + constructor() {} + observe() {} + unobserve() {} + disconnect() {} +} + +function buildDay(overrides: Partial): DailyUsage { + return { + date: '2026-04-01', + inputTokens: 100, + outputTokens: 40, + cacheCreationTokens: 10, + cacheReadTokens: 20, + thinkingTokens: 5, + totalTokens: 175, + totalCost: 12, + requestCount: 4, + modelsUsed: ['gpt-5.4'], + modelBreakdowns: [ + { + modelName: 'gpt-5.4', + inputTokens: 100, + outputTokens: 40, + cacheCreationTokens: 10, + cacheReadTokens: 20, + thinkingTokens: 5, + cost: 12, + requestCount: 4, + }, + ], + ...overrides, + } +} + +describe('RequestCacheHitRateByModel', () => { + beforeEach(async () => { + vi.stubGlobal('IntersectionObserver', MockIntersectionObserver) + await initI18n('en') + }) + + it('uses a thicker explicit bar size for the current model snapshot', () => { + render( + + + , + ) + + expect(screen.getByTestId('snapshot-bar-chart')).toHaveAttribute('data-bar-size', '6') + expect(screen.getByTestId('snapshot-bar-chart')).toHaveAttribute('data-max-bar-size', '6') + expect(screen.getByTestId('snapshot-bar-totalRate')).toBeInTheDocument() + expect(screen.getByTestId('snapshot-bar-trailing7Rate')).toBeInTheDocument() + }) +}) diff --git a/tests/frontend/request-quality.test.tsx b/tests/frontend/request-quality.test.tsx index 69166da..1c6b100 100644 --- a/tests/frontend/request-quality.test.tsx +++ b/tests/frontend/request-quality.test.tsx @@ -1,12 +1,41 @@ // @vitest-environment jsdom -import { render, screen } from '@testing-library/react' +import { render, screen, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { RequestQuality } from '@/components/features/request-quality/RequestQuality' import { TooltipProvider } from '@/components/ui/tooltip' import { initI18n } from '@/lib/i18n' import type { DashboardMetrics } from '@/types' +class MockIntersectionObserver { + static instances: MockIntersectionObserver[] = [] + + callback: IntersectionObserverCallback + + constructor(callback: IntersectionObserverCallback) { + this.callback = callback + MockIntersectionObserver.instances.push(this) + } + + observe() {} + + unobserve() {} + + disconnect() {} + + trigger(isIntersecting: boolean) { + this.callback( + [ + { + isIntersecting, + target: document.createElement('div'), + } as IntersectionObserverEntry, + ], + this as unknown as IntersectionObserver, + ) + } +} + const baseMetrics: DashboardMetrics = { totalCost: 0, totalTokens: 0, @@ -35,14 +64,8 @@ const baseMetrics: DashboardMetrics = { describe('RequestQuality', () => { beforeEach(async () => { - vi.stubGlobal( - 'IntersectionObserver', - class { - observe() {} - unobserve() {} - disconnect() {} - }, - ) + MockIntersectionObserver.instances = [] + vi.stubGlobal('IntersectionObserver', MockIntersectionObserver) await initI18n('en') }) @@ -81,4 +104,38 @@ describe('RequestQuality', () => { expect(progressBars.length).toBeGreaterThanOrEqual(4) expect(screen.queryByText('n/a')).not.toBeInTheDocument() }) + + it('animates request-quality bars only after the metric cards become visible', async () => { + const { container } = render( + + + , + ) + + const fills = () => Array.from(container.querySelectorAll('.h-full.rounded-full')) + + expect(fills().some((fill) => Number.parseFloat((fill as HTMLElement).style.width) > 0)).toBe( + false, + ) + + MockIntersectionObserver.instances.forEach((observer) => observer.trigger(true)) + + await waitFor(() => { + expect( + fills().filter((fill) => Number.parseFloat((fill as HTMLElement).style.width) > 0).length, + ).toBeGreaterThanOrEqual(4) + }) + }) }) diff --git a/tests/frontend/toast.test.tsx b/tests/frontend/toast.test.tsx index f8167a3..b80b0cc 100644 --- a/tests/frontend/toast.test.tsx +++ b/tests/frontend/toast.test.tsx @@ -39,7 +39,7 @@ describe('ToastProvider', () => { fireEvent.click(screen.getByRole('button', { name: 'Close' })) expect(screen.queryByRole('status')).not.toBeInTheDocument() - }) + }, 15_000) it('announces error toasts as alerts', () => { render( @@ -51,5 +51,5 @@ describe('ToastProvider', () => { fireEvent.click(screen.getByRole('button', { name: 'Trigger toast' })) expect(screen.getByRole('alert')).toHaveTextContent('Saved successfully') - }) + }, 15_000) }) diff --git a/tests/integration/server.test.ts b/tests/integration/server.test.ts index e165c6f..5e6ce34 100644 --- a/tests/integration/server.test.ts +++ b/tests/integration/server.test.ts @@ -1402,7 +1402,7 @@ describe('local server API', () => { } rmSync(runtimeRoot, { recursive: true, force: true }) } - }) + }, 20_000) it('fails cleanly when port 65535 is busy instead of retrying to 65536', async () => { const occupiedPortServer = createServer() @@ -1557,5 +1557,5 @@ describe('local server API', () => { } rmSync(runtimeRoot, { recursive: true, force: true }) } - }) + }, 20_000) }) From f26b6b6e7b8863fec1640767fc11a7a1b624ee00 Mon Sep 17 00:00:00 2001 From: tyl3r-ch Date: Wed, 15 Apr 2026 21:47:47 +0200 Subject: [PATCH 4/8] v6.2.2: Update changelog --- CHANGELOG.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 38ab581..20d6f0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## [6.2.2] - 2026-04-15 + +### Added + +- **Zentrales Motion-System für Dashboard-Sektionen und Einzel-Visualisierungen** — ein gemeinsamer Section-/Element-Lifecycle steuert jetzt Preload, Reveal und sichtbarkeitsgebundene Aufbauanimationen für Karten, Diagramme und Wertbalken konsistent aus einer Quelle +- **Gezielte Motion-Regressionstests für Dashboard und Diagramme** — neue Frontend-Tests decken Section-Preloading, sichtbarkeitsgebundene Chart-Starts, Histogramm-/Snapshot-Verhalten, responsives Legend-Layout sowie ROI-/Limits-Bar-Aufbau gezielt ab + +### Improved + +- **Dashboard-Animationen und Ladeverhalten** — Sektionen werden vor dem Eintritt robuster vorbereitet, blenden ruhiger ein und starten Diagramm- sowie Meter-Aufbauten zeitlich harmonischer und deutlich konsistenter als zuvor +- **Diagramm-Aufbau über das gesamte Dashboard** — Linien-, Flächen-, Balken-, Donut-, Scatter- und Heatmap-Visualisierungen nutzen jetzt abgestimmtere Timings, sichtbarkeitsgebundene Starts und ein ruhigeres Motion-Tuning statt gemischter Einzelpfade +- **Heatmap-, KPI- und Meter-Reveals** — Heatmaps bauen sich wochenweise auf, KPI-/Insight-Gruppen folgen einem gleichmäßigeren Reveal, und Dashboard-Bars verwenden denselben orchestrierten Startpfad wie die übrigen Visualisierungen +- **Legendendarstellung in Diagrammen mit vielen Labels** — Chart-Legenden umbrechen jetzt responsiv auf mehrere Zeilen statt horizontal zu scrollen, einschließlich der betreffenden Linien- und Donut-Diagramme +- **Verifizierbarkeit der Motion- und Chart-Änderungen** — zusätzliche gezielte Tests für Anfragequalität, Verteilungen, Cache-Hit-Rate, Legend-Integrationen und Motion-Zeitverhalten sichern die neue Dashboard-Politur gegen Regressionen ab + +### Fixed + +- **Vorzeitig fertig geladene oder flackernde Diagrammanimationen** — Chart-Aufbauten laufen nicht mehr verdeckt vor dem sichtbaren Reveal ab, sondern starten erst dann, wenn Section und konkretes Diagramm tatsächlich sichtbar sind +- **Inkonsistente Bar- und Vergleichsanimationen in Sekundärflächen** — Anfragequalität, Cache-Ersparnis (ROI), Limits & Abonnements sowie weitere Dashboard-Bars verwenden jetzt keine widersprüchlichen lokalen Sonderpfade oder künstlichen Mindestbreiten mehr +- **Chart-spezifische Darstellungsprobleme in Analyseflächen** — das Verteilungs-Histogramm folgt jetzt dem gemeinsamen Chart-Lifecycle, der Modell-Snapshot der Cache-Hit-Rate ist visuell sauberer gewichtet, und mehrere Legend-/Label-Pfade reagieren auf engem Platz stabiler + ## [6.2.1] - 2026-04-15 ### Added From 77e2cbb77c88106ce332fc7ded7f3c34c3aa1557 Mon Sep 17 00:00:00 2001 From: tyl3r-ch Date: Wed, 15 Apr 2026 22:17:00 +0200 Subject: [PATCH 5/8] v6.2.2: Address CodeRabbit motion follow-ups --- src/components/cards/MonthMetrics.tsx | 2 +- src/components/cards/PrimaryMetrics.tsx | 2 +- src/components/cards/SecondaryMetrics.tsx | 4 +- src/components/cards/TodayMetrics.tsx | 2 +- src/components/charts/ChartCard.tsx | 4 +- src/components/charts/CorrelationAnalysis.tsx | 9 +- src/components/charts/CostByModel.tsx | 2 +- .../charts/RequestCacheHitRateByModel.tsx | 17 +-- src/components/charts/chart-theme.ts | 6 +- ...shboard-motion.tsx => DashboardMotion.tsx} | 11 +- .../dashboard/DashboardSections.tsx | 62 +++++------ .../features/animations/AnimatedBarFill.tsx | 5 +- .../features/heatmap/HeatmapCalendar.tsx | 20 ++-- .../features/insights/UsageInsights.tsx | 15 +-- .../features/limits/ProviderLimitsSection.tsx | 2 +- .../request-quality/RequestQuality.tsx | 2 +- src/components/ui/card.tsx | 22 ++-- src/locales/de/common.json | 1 + src/locales/en/common.json | 1 + tests/frontend/correlation-analysis.test.tsx | 102 ++++++++++++++++++ tests/frontend/dashboard-motion.test.tsx | 73 ++++++++++++- tests/frontend/drill-down-modal.test.tsx | 2 +- tests/frontend/heatmap-calendar.test.tsx | 2 +- tests/frontend/phase4-correctness.test.tsx | 2 +- .../frontend/provider-limits-section.test.tsx | 4 +- .../request-cache-hit-rate-by-model.test.tsx | 21 ++++ tests/frontend/sortable-tables.test.tsx | 2 +- tests/frontend/usage-insights.test.tsx | 71 ++++++++++++ 28 files changed, 366 insertions(+), 102 deletions(-) rename src/components/dashboard/{dashboard-motion.tsx => DashboardMotion.tsx} (96%) create mode 100644 tests/frontend/correlation-analysis.test.tsx create mode 100644 tests/frontend/usage-insights.test.tsx diff --git a/src/components/cards/MonthMetrics.tsx b/src/components/cards/MonthMetrics.tsx index a14fe89..2ad9ed0 100644 --- a/src/components/cards/MonthMetrics.tsx +++ b/src/components/cards/MonthMetrics.tsx @@ -11,7 +11,7 @@ import { BrainCircuit, } from 'lucide-react' import { MetricCard } from './MetricCard' -import { DashboardMotionItem } from '@/components/dashboard/dashboard-motion' +import { DashboardMotionItem } from '@/components/dashboard/DashboardMotion' import { FormattedValue } from '@/components/ui/formatted-value' import { SectionHeader } from '@/components/ui/section-header' import { SECTION_HELP } from '@/lib/help-content' diff --git a/src/components/cards/PrimaryMetrics.tsx b/src/components/cards/PrimaryMetrics.tsx index c454efe..07c5c79 100644 --- a/src/components/cards/PrimaryMetrics.tsx +++ b/src/components/cards/PrimaryMetrics.tsx @@ -9,7 +9,7 @@ import { BrainCircuit, } from 'lucide-react' import { useTranslation } from 'react-i18next' -import { DashboardMotionItem } from '@/components/dashboard/dashboard-motion' +import { DashboardMotionItem } from '@/components/dashboard/DashboardMotion' import { MetricCard } from './MetricCard' import { FormattedValue } from '@/components/ui/formatted-value' import { formatCurrency, formatPercent, formatTokens, periodUnit } from '@/lib/formatters' diff --git a/src/components/cards/SecondaryMetrics.tsx b/src/components/cards/SecondaryMetrics.tsx index 555f585..d0e346b 100644 --- a/src/components/cards/SecondaryMetrics.tsx +++ b/src/components/cards/SecondaryMetrics.tsx @@ -1,6 +1,6 @@ import { TrendingUp, ChartBar, Sigma, Building2 } from 'lucide-react' import { useTranslation } from 'react-i18next' -import { DashboardMotionItem } from '@/components/dashboard/dashboard-motion' +import { DashboardMotionItem } from '@/components/dashboard/DashboardMotion' import { MetricCard } from './MetricCard' import { FormattedValue } from '@/components/ui/formatted-value' import { @@ -97,7 +97,7 @@ export function SecondaryMetrics({ label={t('metricCards.secondary.dominantProvider')} value={metrics.topProvider?.name ?? '–'} icon={} - info={t('metricCards.secondary.medianInfo')} + info={t('metricCards.secondary.dominantProviderInfo')} {...(topProviderSubtitle ? { subtitle: topProviderSubtitle } : {})} /> diff --git a/src/components/cards/TodayMetrics.tsx b/src/components/cards/TodayMetrics.tsx index d8f9ea0..655e0cf 100644 --- a/src/components/cards/TodayMetrics.tsx +++ b/src/components/cards/TodayMetrics.tsx @@ -8,7 +8,7 @@ import { BrainCircuit, } from 'lucide-react' import { useTranslation } from 'react-i18next' -import { DashboardMotionItem } from '@/components/dashboard/dashboard-motion' +import { DashboardMotionItem } from '@/components/dashboard/DashboardMotion' import { MetricCard } from './MetricCard' import { FormattedValue } from '@/components/ui/formatted-value' import { SectionHeader } from '@/components/ui/section-header' diff --git a/src/components/charts/ChartCard.tsx b/src/components/charts/ChartCard.tsx index 19b5ee5..4405504 100644 --- a/src/components/charts/ChartCard.tsx +++ b/src/components/charts/ChartCard.tsx @@ -17,7 +17,7 @@ import { DASHBOARD_MOTION, useDashboardElementMotion, useDashboardSectionMotion, -} from '@/components/dashboard/dashboard-motion' +} from '@/components/dashboard/DashboardMotion' import { CHART_ANIMATION } from './chart-theme' import { cn } from '@/lib/cn' import { buildCsvLine } from '@/lib/csv' @@ -202,6 +202,7 @@ export function ChartCard({ const fmt = valueFormatter ?? formatCurrency const renderChildren = (isExpanded: boolean) => typeof children === 'function' ? children(isExpanded) : children + const isSectionVisible = sectionMotion?.sectionVisible ?? true const handleExport = useCallback(() => { if (!chartData || chartData.length === 0) return @@ -241,6 +242,7 @@ export function ChartCard({
@@ -223,10 +224,10 @@ export function RequestCacheHitRateByModel({
- {t('charts.requestCacheHitRate.trailing7Rate')} + {trendLabel}
- {formatRate(summary.total.trailing7Rate)} + {formatRate(summary.total[trendRate])}
@@ -396,7 +397,7 @@ export function RequestCacheHitRateByModel({ formatter={(value) => formatRate(value)} pinnedEntryNames={[ t('charts.requestCacheHitRate.totalRate'), - t('charts.requestCacheHitRate.trailing7Rate'), + trendLabel, ]} showComputedTotal={false} /> @@ -424,8 +425,8 @@ export function RequestCacheHitRateByModel({ ))} []} diff --git a/src/components/charts/chart-theme.ts b/src/components/charts/chart-theme.ts index 02f3075..f8f99b6 100644 --- a/src/components/charts/chart-theme.ts +++ b/src/components/charts/chart-theme.ts @@ -42,11 +42,7 @@ export function getLineAnimationProps( } = {}, ) { const delayOffset = - role === 'secondary' - ? 140 + order * CHART_ANIMATION.stagger - : role === 'stacked' - ? order * CHART_ANIMATION.stagger - : order * CHART_ANIMATION.stagger + role === 'secondary' ? 140 + order * CHART_ANIMATION.stagger : order * CHART_ANIMATION.stagger return { isAnimationActive: active, diff --git a/src/components/dashboard/dashboard-motion.tsx b/src/components/dashboard/DashboardMotion.tsx similarity index 96% rename from src/components/dashboard/dashboard-motion.tsx rename to src/components/dashboard/DashboardMotion.tsx index 1883a80..1306876 100644 --- a/src/components/dashboard/dashboard-motion.tsx +++ b/src/components/dashboard/DashboardMotion.tsx @@ -195,7 +195,8 @@ export function AnimatedDashboardSection({ return preloadTask } - const preloadTask = Promise.resolve(onPreload()) + const preloadTask = Promise.resolve() + .then(() => onPreload()) .catch(() => undefined) .finally(() => { if (isMountedRef.current) { @@ -260,6 +261,7 @@ export function AnimatedDashboardSection({ const shouldRenderContent = eager || contentPrepared const showPlaceholder = !sectionVisible + const contentStyle = sectionVisible ? {} : { opacity: 0, pointerEvents: 'none' as const } const contextValue = useMemo( () => ({ @@ -292,14 +294,15 @@ export function AnimatedDashboardSection({
{shouldReduceMotion ? (
{children}
) : ( {children} diff --git a/src/components/dashboard/DashboardSections.tsx b/src/components/dashboard/DashboardSections.tsx index abc926b..8b314f7 100644 --- a/src/components/dashboard/DashboardSections.tsx +++ b/src/components/dashboard/DashboardSections.tsx @@ -20,7 +20,7 @@ import { SectionHeader } from '../ui/section-header' import { ExpandableCard } from '../ui/expandable-card' import { ChartCardSkeleton } from '../ui/skeleton' import { ErrorBoundary } from '../ui/error-boundary' -import { AnimatedDashboardSection } from './dashboard-motion' +import { AnimatedDashboardSection } from './DashboardMotion' import { SECTION_HELP } from '@/lib/help-content' import { cn } from '@/lib/cn' import type { ModelCostChartPoint } from '@/lib/data-transforms' @@ -38,6 +38,26 @@ import type { WeekdayData, } from '@/types' +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type PreloadableLazyComponent> = LazyExoticComponent & { + preload: () => Promise<{ default: T }> +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function lazyWithPreload>( + loader: () => Promise<{ default: T }>, +): PreloadableLazyComponent { + const Component = lazy(loader) as PreloadableLazyComponent + Component.preload = loader + return Component +} + +function preloadComponents( + ...components: Array<{ preload: () => Promise }> +): Promise { + return Promise.all(components.map((component) => component.preload())) +} + const CostForecast = lazyWithPreload(() => import('../features/forecast/CostForecast').then((module) => ({ default: module.CostForecast, @@ -139,26 +159,6 @@ const RecentDays = lazyWithPreload(() => })), ) -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type PreloadableLazyComponent> = LazyExoticComponent & { - preload: () => Promise<{ default: T }> -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function lazyWithPreload>( - loader: () => Promise<{ default: T }>, -): PreloadableLazyComponent { - const Component = lazy(loader) as PreloadableLazyComponent - Component.preload = loader - return Component -} - -function preloadComponents( - ...components: Array<{ preload: () => Promise }> -): Promise { - return Promise.all(components.map((component) => component.preload())) -} - interface DashboardSectionsProps { sectionOrder: DashboardSectionId[] sectionVisibility: Record @@ -273,6 +273,13 @@ export function DashboardSections({ comparisons: 'min-h-[420px]', tables: 'min-h-[900px]', } + const sectionAnchorMap: Partial> = { + costAnalysis: 'charts', + currentMonth: 'current-month', + tokenAnalysis: 'token-analysis', + requestAnalysis: 'request-analysis', + advancedAnalysis: 'advanced-analysis', + } const renderAnimatedSection = ( sectionId: DashboardSectionId, @@ -282,18 +289,7 @@ export function DashboardSections({ onPreload, }: { eager?: boolean; onPreload?: () => void | Promise } = {}, ) => { - const sectionAnchorId = - sectionId === 'costAnalysis' - ? 'charts' - : sectionId === 'currentMonth' - ? 'current-month' - : sectionId === 'tokenAnalysis' - ? 'token-analysis' - : sectionId === 'requestAnalysis' - ? 'request-analysis' - : sectionId === 'advancedAnalysis' - ? 'advanced-analysis' - : sectionId + const sectionAnchorId = sectionAnchorMap[sectionId] ?? sectionId return ( +
@@ -93,7 +94,7 @@ export function UsageInsights({ metrics, viewMode, totalCalendarDays }: UsageIns info={SECTION_HELP.insights} />
- + } @@ -124,7 +125,7 @@ export function UsageInsights({ metrics, viewMode, totalCalendarDays }: UsageIns /> - + } @@ -180,7 +181,7 @@ export function UsageInsights({ metrics, viewMode, totalCalendarDays }: UsageIns /> - + } @@ -228,7 +229,7 @@ export function UsageInsights({ metrics, viewMode, totalCalendarDays }: UsageIns /> - + } diff --git a/src/components/features/limits/ProviderLimitsSection.tsx b/src/components/features/limits/ProviderLimitsSection.tsx index 95d482f..5ce716b 100644 --- a/src/components/features/limits/ProviderLimitsSection.tsx +++ b/src/components/features/limits/ProviderLimitsSection.tsx @@ -16,7 +16,7 @@ import { import { AlertTriangle, CreditCard, ShieldCheck, TrendingUp } from 'lucide-react' import { Card, CardContent } from '@/components/ui/card' import { SectionHeader } from '@/components/ui/section-header' -import { DashboardMotionItem } from '@/components/dashboard/dashboard-motion' +import { DashboardMotionItem } from '@/components/dashboard/DashboardMotion' import { AnimatedBarFill } from '@/components/features/animations/AnimatedBarFill' import { ChartAnimationAware, ChartCard, ChartReveal } from '@/components/charts/ChartCard' import { ChartLegend } from '@/components/charts/ChartLegend' diff --git a/src/components/features/request-quality/RequestQuality.tsx b/src/components/features/request-quality/RequestQuality.tsx index 29e9529..cae46bd 100644 --- a/src/components/features/request-quality/RequestQuality.tsx +++ b/src/components/features/request-quality/RequestQuality.tsx @@ -76,7 +76,7 @@ export function RequestQuality({ metrics, viewMode }: RequestQualityProps) {
{item.hint}
const Card = React.forwardRef(({ className, ...props }, ref) => { const shouldReduceMotion = useShouldReduceMotion() const dashboardSectionMotion = useDashboardSectionMotion() - const motionProps = shouldReduceMotion - ? { - initial: false as const, - animate: { opacity: 1, y: 0 }, - transition: { duration: 0 }, - } - : dashboardSectionMotion - ? { - initial: false as const, - animate: { opacity: 1, y: 0 }, - transition: { duration: 0 }, - } + const staticMotion = { + initial: false as const, + animate: { opacity: 1, y: 0 }, + transition: { duration: 0 }, + } + const motionProps = + shouldReduceMotion || dashboardSectionMotion + ? staticMotion : { initial: { opacity: 0, y: 14 }, whileInView: { opacity: 1, y: 0 }, diff --git a/src/locales/de/common.json b/src/locales/de/common.json index ff700f4..8fccac8 100644 --- a/src/locales/de/common.json +++ b/src/locales/de/common.json @@ -239,6 +239,7 @@ "vsAverage": "{{direction}}{{value}}% vs. Ø", "vsAverageWithVolatility": "{{direction}}{{value}}% vs. Ø · σ Anf. {{volatility}}", "medianInfo": "Der Median zeigt den typischen Wert und ist weniger anfällig für Ausreisser als der Durchschnitt.", + "dominantProviderInfo": "Zeigt den Anbieter mit dem grössten Kostenanteil im aktuellen Ausschnitt zusammen mit dessen Gewichtung und Kostenbeitrag.", "requestLeader": "{{model}} · {{requests}} Anfragen", "dominantProviderSubtitle": "{{share}} Anteil · {{cost}}{{requestLeader}}" }, diff --git a/src/locales/en/common.json b/src/locales/en/common.json index fde20b9..ef27389 100644 --- a/src/locales/en/common.json +++ b/src/locales/en/common.json @@ -239,6 +239,7 @@ "vsAverage": "{{direction}}{{value}}% vs avg", "vsAverageWithVolatility": "{{direction}}{{value}}% vs avg · σ Req {{volatility}}", "medianInfo": "The median shows the typical value and is less sensitive to outliers than the average.", + "dominantProviderInfo": "Shows the provider with the largest share of cost in the current slice, together with its relative weight and cost contribution.", "requestLeader": "{{model}} · {{requests}} req", "dominantProviderSubtitle": "{{share}} share · {{cost}}{{requestLeader}}" }, diff --git a/tests/frontend/correlation-analysis.test.tsx b/tests/frontend/correlation-analysis.test.tsx new file mode 100644 index 0000000..24cdb9d --- /dev/null +++ b/tests/frontend/correlation-analysis.test.tsx @@ -0,0 +1,102 @@ +// @vitest-environment jsdom + +import { render, screen } from '@testing-library/react' +import type { ReactNode } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { CorrelationAnalysis } from '@/components/charts/CorrelationAnalysis' +import { TooltipProvider } from '@/components/ui/tooltip' +import { initI18n } from '@/lib/i18n' +import type { DailyUsage } from '@/types' + +vi.mock('@/lib/motion', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useShouldReduceMotion: () => true, + } +}) + +vi.mock('recharts', () => ({ + ResponsiveContainer: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + ScatterChart: ({ children }: { children: ReactNode }) =>
{children}
, + Scatter: ({ + data, + isAnimationActive, + }: { + data?: Array + isAnimationActive?: boolean + }) => ( +
+ ), + XAxis: () => null, + YAxis: () => null, + CartesianGrid: () => null, + Tooltip: () => null, + ZAxis: () => null, +})) + +class MockIntersectionObserver { + observe() {} + unobserve() {} + disconnect() {} +} + +function buildDay(overrides: Partial): DailyUsage { + return { + date: '2026-04-01', + inputTokens: 100, + outputTokens: 40, + cacheCreationTokens: 10, + cacheReadTokens: 20, + thinkingTokens: 5, + totalTokens: 175, + totalCost: 12, + requestCount: 4, + modelsUsed: ['gpt-5.4'], + modelBreakdowns: [ + { + modelName: 'gpt-5.4', + inputTokens: 100, + outputTokens: 40, + cacheCreationTokens: 10, + cacheReadTokens: 20, + thinkingTokens: 5, + cost: 12, + requestCount: 4, + }, + ], + ...overrides, + } +} + +describe('CorrelationAnalysis', () => { + beforeEach(async () => { + vi.stubGlobal('IntersectionObserver', MockIntersectionObserver) + await initI18n('en') + }) + + it('renders scatter points statically when reduced motion is enabled', () => { + render( + + + , + ) + + const series = screen.getAllByTestId('scatter-series') + expect(series[0]).toHaveAttribute('data-points', '2') + expect(series[1]).toHaveAttribute('data-points', '2') + expect(series[0]).toHaveAttribute('data-animate', 'false') + expect(series[1]).toHaveAttribute('data-animate', 'false') + }) +}) diff --git a/tests/frontend/dashboard-motion.test.tsx b/tests/frontend/dashboard-motion.test.tsx index 7f5687e..b037445 100644 --- a/tests/frontend/dashboard-motion.test.tsx +++ b/tests/frontend/dashboard-motion.test.tsx @@ -1,12 +1,12 @@ // @vitest-environment jsdom import { act, render, screen } from '@testing-library/react' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ChartCard, useChartAnimationState } from '@/components/charts/ChartCard' import { AnimatedDashboardSection, useDashboardSectionMotion, -} from '@/components/dashboard/dashboard-motion' +} from '@/components/dashboard/DashboardMotion' import { initI18n } from '@/lib/i18n' class MockIntersectionObserver { @@ -58,6 +58,10 @@ describe('AnimatedDashboardSection', () => { await initI18n('en') }) + afterEach(() => { + vi.unstubAllGlobals() + }) + it('preloads before reveal and only activates chart animation once visible', async () => { let resolvePreload: (() => void) | null = null const preloadPromise = new Promise((resolve) => { @@ -83,6 +87,10 @@ describe('AnimatedDashboardSection', () => { MockIntersectionObserver.instances[0]?.trigger(true) }) + await act(async () => { + await Promise.resolve() + }) + expect(handlePreload).toHaveBeenCalledTimes(1) expect(screen.queryByTestId('section-visible')).not.toBeInTheDocument() @@ -108,4 +116,65 @@ describe('AnimatedDashboardSection', () => { expect(screen.getByTestId('chart-active')).toHaveTextContent('true') expect(screen.getByTestId('chart-delay')).toHaveTextContent('285') }) + + it('keeps preloaded hidden content inert until the section is revealed', async () => { + render( + Promise.resolve()} + > + +
Chart body
+
+
, + ) + + act(() => { + MockIntersectionObserver.instances[0]?.trigger(true) + }) + + await act(async () => { + await Promise.resolve() + }) + + const inertContainer = document.querySelector('[inert]') + const button = screen.getByRole('button', { name: 'Demo chart expand' }) + + expect(inertContainer).toHaveAttribute('inert') + expect(button).toHaveAttribute('tabindex', '-1') + + act(() => { + MockIntersectionObserver.instances[1]?.trigger(true) + }) + + expect(document.querySelector('[data-section-visible="true"]')).toBeInTheDocument() + expect(button).not.toHaveAttribute('tabindex', '-1') + }) + + it('still prepares and reveals content when onPreload throws synchronously', async () => { + render( + { + throw new Error('sync preload failure') + }} + > + + + + , + ) + + act(() => { + MockIntersectionObserver.instances[0]?.trigger(true) + MockIntersectionObserver.instances[1]?.trigger(true) + }) + + await act(async () => { + await Promise.resolve() + }) + + expect(screen.getByTestId('section-visible')).toHaveTextContent('true') + }) }) diff --git a/tests/frontend/drill-down-modal.test.tsx b/tests/frontend/drill-down-modal.test.tsx index a54452d..a62c437 100644 --- a/tests/frontend/drill-down-modal.test.tsx +++ b/tests/frontend/drill-down-modal.test.tsx @@ -141,7 +141,7 @@ describe('DrillDownModal', () => { expect(within(openAiProviderCard).getAllByText('6').length).toBeGreaterThan(0) expect(screen.getByLabelText(/^Input: /)).toBeInTheDocument() - }) + }, 15_000) it('labels aggregated entries as periods and shows raw-day coverage', async () => { const monthEntry: DailyUsage = { diff --git a/tests/frontend/heatmap-calendar.test.tsx b/tests/frontend/heatmap-calendar.test.tsx index 745831d..3277d1f 100644 --- a/tests/frontend/heatmap-calendar.test.tsx +++ b/tests/frontend/heatmap-calendar.test.tsx @@ -154,7 +154,7 @@ describe('HeatmapCalendar', () => { await waitFor(() => { expect(screen.getByRole('gridcell', { name: /April 14, 2026/ })).toHaveFocus() }) - }) + }, 15_000) it('uses muted styling for zero-value cells so empty days do not dominate the heatmap', () => { const day = buildDailyUsage({ diff --git a/tests/frontend/phase4-correctness.test.tsx b/tests/frontend/phase4-correctness.test.tsx index 4af1b1c..4d2781d 100644 --- a/tests/frontend/phase4-correctness.test.tsx +++ b/tests/frontend/phase4-correctness.test.tsx @@ -120,7 +120,7 @@ describe('phase 4 UI correctness', () => { expect(document.body).not.toHaveTextContent('Infinity') expect(document.body).not.toHaveTextContent('NaN') expect(screen.getAllByText('–').length).toBeGreaterThan(0) - }) + }, 15_000) it('uses the canonical token sum instead of a stale day.totalTokens value', () => { const day: DailyUsage = { diff --git a/tests/frontend/provider-limits-section.test.tsx b/tests/frontend/provider-limits-section.test.tsx index 562b81c..457c8aa 100644 --- a/tests/frontend/provider-limits-section.test.tsx +++ b/tests/frontend/provider-limits-section.test.tsx @@ -96,7 +96,7 @@ describe('ProviderLimitsSection', () => { expect(screen.getByText('0% Limit')).toBeInTheDocument() expect(screen.getByText('240% Abo')).toBeInTheDocument() expect(screen.getByText('Offen')).toBeInTheDocument() - }, 15_000) + }) it('does not force a visible minimum width or local timing overrides for subscription bars', () => { render( @@ -121,5 +121,5 @@ describe('ProviderLimitsSection', () => { expect(fills.some((fill) => fill.getAttribute('data-width') === '0%')).toBe(true) expect(fills.every((fill) => fill.getAttribute('data-delay-ms') === '')).toBe(true) expect(fills.every((fill) => fill.getAttribute('data-duration-ms') === '')).toBe(true) - }, 15_000) + }) }) diff --git a/tests/frontend/request-cache-hit-rate-by-model.test.tsx b/tests/frontend/request-cache-hit-rate-by-model.test.tsx index 80c2bd8..ac7dc10 100644 --- a/tests/frontend/request-cache-hit-rate-by-model.test.tsx +++ b/tests/frontend/request-cache-hit-rate-by-model.test.tsx @@ -106,4 +106,25 @@ describe('RequestCacheHitRateByModel', () => { expect(screen.getByTestId('snapshot-bar-totalRate')).toBeInTheDocument() expect(screen.getByTestId('snapshot-bar-trailing7Rate')).toBeInTheDocument() }) + + it('uses the trend label consistently outside daily mode', () => { + render( + + + , + ) + + expect(screen.getAllByText('Trend avg').length).toBeGreaterThan(0) + expect(screen.queryByText('7-day avg')).not.toBeInTheDocument() + }) }) diff --git a/tests/frontend/sortable-tables.test.tsx b/tests/frontend/sortable-tables.test.tsx index be3d99c..73be34f 100644 --- a/tests/frontend/sortable-tables.test.tsx +++ b/tests/frontend/sortable-tables.test.tsx @@ -57,7 +57,7 @@ describe('sortable tables', () => { 'aria-sort', 'descending', ) - }) + }, 15_000) it('renders model efficiency sort controls as buttons inside column headers', () => { renderWithProviders( diff --git a/tests/frontend/usage-insights.test.tsx b/tests/frontend/usage-insights.test.tsx new file mode 100644 index 0000000..58f8f13 --- /dev/null +++ b/tests/frontend/usage-insights.test.tsx @@ -0,0 +1,71 @@ +// @vitest-environment jsdom + +import { render } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { UsageInsights } from '@/components/features/insights/UsageInsights' +import { TooltipProvider } from '@/components/ui/tooltip' +import { initI18n } from '@/lib/i18n' +import type { DashboardMetrics } from '@/types' + +const metrics: DashboardMetrics = { + totalCost: 5046.25, + totalTokens: 7_742_241_363, + activeDays: 63, + topModel: { name: 'Opus 4.6', cost: 4000 }, + topRequestModel: { name: 'Opus 4.6', requests: 49999 }, + topTokenModel: { name: 'Opus 4.6', tokens: 5_300_000_000 }, + topModelShare: 79, + topThreeModelsShare: 91, + topProvider: { name: 'Anthropic', share: 96, cost: 4800 }, + providerCount: 3, + hasRequestData: true, + cacheHitRate: 95.1, + costPerMillion: 0.65, + avgTokensPerRequest: 96_700, + avgCostPerRequest: 0.06, + avgModelsPerEntry: 2.6, + avgDailyCost: 80.1, + avgRequestsPerDay: 1270.3, + topDay: { date: '2026-03-06', cost: 366 }, + cheapestDay: null, + busiestWeek: { start: '2026-03-28', end: '2026-04-03', cost: 1218 }, + weekendCostShare: 35, + totalInput: 10, + totalOutput: 5, + totalCacheRead: 100, + totalCacheCreate: 0, + totalThinking: 1200, + totalRequests: 80_029, + weekOverWeekChange: null, + requestVolatility: 1319, + modelConcentrationIndex: 0, + providerConcentrationIndex: 0, +} + +describe('UsageInsights', () => { + beforeEach(async () => { + vi.stubGlobal( + 'IntersectionObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ) + await initI18n('en') + }) + + it('keeps the animated insight grid items stretched to full height', () => { + const { container } = render( + + + , + ) + + const motionItems = container.querySelectorAll('.grid > .h-full') + const cards = container.querySelectorAll('.grid > .h-full > .h-full') + + expect(motionItems.length).toBeGreaterThanOrEqual(4) + expect(cards.length).toBeGreaterThanOrEqual(4) + }) +}) From 9f655802eefd0aebf8a9fa3760f2ffb0d46c2fc9 Mon Sep 17 00:00:00 2001 From: tyl3r-ch Date: Wed, 15 Apr 2026 22:46:42 +0200 Subject: [PATCH 6/8] v6.2.2: Address final CodeRabbit follow-ups --- src/components/cards/SecondaryMetrics.tsx | 16 +++- src/components/charts/chart-theme.ts | 2 +- src/components/dashboard/DashboardMotion.tsx | 12 ++- .../features/insights/UsageInsights.tsx | 30 +++++-- .../request-quality/RequestQuality.tsx | 3 +- src/lib/help-content.ts | 10 +++ tests/frontend/request-quality-order.test.tsx | 66 ++++++++++++++ tests/frontend/secondary-metrics.test.tsx | 87 +++++++++++++++++++ tests/frontend/usage-insights.test.tsx | 15 ++-- tests/unit/chart-theme.test.ts | 12 +++ 10 files changed, 232 insertions(+), 21 deletions(-) create mode 100644 tests/frontend/request-quality-order.test.tsx create mode 100644 tests/frontend/secondary-metrics.test.tsx create mode 100644 tests/unit/chart-theme.test.ts diff --git a/src/components/cards/SecondaryMetrics.tsx b/src/components/cards/SecondaryMetrics.tsx index d0e346b..6ae6dc1 100644 --- a/src/components/cards/SecondaryMetrics.tsx +++ b/src/components/cards/SecondaryMetrics.tsx @@ -72,6 +72,18 @@ export function SecondaryMetrics({ volatility: Math.round(metrics.requestVolatility), }) : null + const topCostInfo = + viewMode === 'yearly' + ? METRIC_HELP.mostExpensiveYear + : viewMode === 'monthly' + ? METRIC_HELP.mostExpensiveMonth + : METRIC_HELP.mostExpensiveDay + const avgCostInfo = + viewMode === 'yearly' + ? METRIC_HELP.avgCostPerYear + : viewMode === 'monthly' + ? METRIC_HELP.avgCostPerMonth + : METRIC_HELP.avgCostPerDay return (
@@ -88,7 +100,7 @@ export function SecondaryMetrics({ metrics.topDay ? : '–' } icon={} - info={METRIC_HELP.mostExpensiveDay} + info={topCostInfo} {...(topDaySubtitle ? { subtitle: topDaySubtitle } : {})} /> @@ -116,7 +128,7 @@ export function SecondaryMetrics({ ) } icon={} - info={METRIC_HELP.avgCostPerDay} + info={avgCostInfo} {...(peakSubtitle ? { subtitle: peakSubtitle } : {})} /> diff --git a/src/components/charts/chart-theme.ts b/src/components/charts/chart-theme.ts index f8f99b6..ab7617a 100644 --- a/src/components/charts/chart-theme.ts +++ b/src/components/charts/chart-theme.ts @@ -94,7 +94,7 @@ export function getScatterAnimationProps(active: boolean, delayOffsetMs = 0) { return { isAnimationActive: active, animationBegin: CHART_ANIMATION.chartStartDelay + delayOffsetMs, - animationDuration: CHART_ANIMATION.barDuration, + animationDuration: CHART_ANIMATION.revealDuration, animationEasing: CHART_ANIMATION.easing, } } diff --git a/src/components/dashboard/DashboardMotion.tsx b/src/components/dashboard/DashboardMotion.tsx index 1306876..a3c36c8 100644 --- a/src/components/dashboard/DashboardMotion.tsx +++ b/src/components/dashboard/DashboardMotion.tsx @@ -102,6 +102,7 @@ interface DashboardMotionItemProps { order?: number delayMs?: number amount?: number + 'data-testid'?: string } /** Reveals one dashboard child element with the shared timing policy. */ @@ -111,6 +112,7 @@ export function DashboardMotionItem({ order = 0, delayMs, amount, + 'data-testid': dataTestId, }: DashboardMotionItemProps) { const itemRef = useRef(null) const itemMotion = useDashboardElementMotion(itemRef, { @@ -122,7 +124,7 @@ export function DashboardMotionItem({ if (itemMotion.shouldReduceMotion) { return ( -
+
{children}
) @@ -132,6 +134,7 @@ export function DashboardMotionItem({ ( () => ({ @@ -296,13 +300,14 @@ export function AnimatedDashboardSection({
{children}
) : ( {children} diff --git a/src/components/features/insights/UsageInsights.tsx b/src/components/features/insights/UsageInsights.tsx index 67ab50c..5e04149 100644 --- a/src/components/features/insights/UsageInsights.tsx +++ b/src/components/features/insights/UsageInsights.tsx @@ -29,11 +29,20 @@ interface InsightCardProps { summary: string details: { label: string; value: ReactNode }[] className?: string + testId?: string } -function InsightCard({ title, icon, value, summary, details, className }: InsightCardProps) { +function InsightCard({ + title, + icon, + value, + summary, + details, + className, + testId, +}: InsightCardProps) { return ( - +
@@ -93,9 +102,13 @@ export function UsageInsights({ metrics, viewMode, totalCalendarDays }: UsageIns description={t('dashboard.insights.description')} info={SECTION_HELP.insights} /> -
- +
+ } value={metrics.topProvider ? formatPercent(metrics.topProvider.share, 0) : '–'} @@ -125,8 +138,9 @@ export function UsageInsights({ metrics, viewMode, totalCalendarDays }: UsageIns /> - + } value={ @@ -181,8 +195,9 @@ export function UsageInsights({ metrics, viewMode, totalCalendarDays }: UsageIns /> - + } value={ @@ -229,8 +244,9 @@ export function UsageInsights({ metrics, viewMode, totalCalendarDays }: UsageIns /> - + } value={ diff --git a/src/components/features/request-quality/RequestQuality.tsx b/src/components/features/request-quality/RequestQuality.tsx index cae46bd..8826b85 100644 --- a/src/components/features/request-quality/RequestQuality.tsx +++ b/src/components/features/request-quality/RequestQuality.tsx @@ -67,7 +67,7 @@ export function RequestQuality({ metrics, viewMode }: RequestQualityProps) {
- {qualityMetrics.map((item) => ( + {qualityMetrics.map((item, index) => (
{item.label} @@ -77,6 +77,7 @@ export function RequestQuality({ metrics, viewMode }: RequestQualityProps) {
({ + AnimatedBarFill: ({ order }: { order?: number }) => ( +
+ ), +})) + +const metrics: DashboardMetrics = { + totalCost: 0, + totalTokens: 0, + totalInput: 0, + totalOutput: 0, + totalCacheCreate: 0, + totalCacheRead: 0, + totalThinking: 0, + totalRequests: 4, + activeDays: 2, + avgDailyCost: 0, + avgTokensPerRequest: 100_000, + avgCostPerRequest: 0.125, + peakDay: null, + cacheHitRate: 0, + modelCount: 0, + providerCount: 0, + topModels: [], + topRequestModel: null, + dailyBurnRate: 0, + requestVolatility: 0, + providerConcentrationIndex: 0, + topProvider: null, + hasRequestData: true, +} + +describe('RequestQuality bar ordering', () => { + beforeEach(async () => { + vi.stubGlobal( + 'IntersectionObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ) + await initI18n('en') + }) + + it('passes deterministic bar order values to AnimatedBarFill', () => { + render( + + + , + ) + + expect( + screen.getAllByTestId('quality-bar-fill').map((fill) => fill.getAttribute('data-order')), + ).toEqual(['0', '1', '2', '3']) + }) +}) diff --git a/tests/frontend/secondary-metrics.test.tsx b/tests/frontend/secondary-metrics.test.tsx new file mode 100644 index 0000000..358468d --- /dev/null +++ b/tests/frontend/secondary-metrics.test.tsx @@ -0,0 +1,87 @@ +// @vitest-environment jsdom + +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { SecondaryMetrics } from '@/components/cards/SecondaryMetrics' +import { TooltipProvider } from '@/components/ui/tooltip' +import { initI18n } from '@/lib/i18n' +import type { DashboardMetrics } from '@/types' + +vi.mock('@/components/features/help/InfoButton', () => ({ + InfoButton: ({ text }: { text: string }) => {text}, +})) + +const metrics: DashboardMetrics = { + totalCost: 0, + totalTokens: 0, + activeDays: 0, + topModel: null, + topRequestModel: null, + topTokenModel: null, + topModelShare: 0, + topThreeModelsShare: 0, + topProvider: { name: 'Anthropic', share: 72, cost: 120 }, + providerCount: 1, + hasRequestData: true, + cacheHitRate: 0, + costPerMillion: 0, + avgTokensPerRequest: 0, + avgCostPerRequest: 0, + avgModelsPerEntry: 0, + avgDailyCost: 42, + avgRequestsPerDay: 0, + topDay: { date: '2026-04-10', cost: 120 }, + cheapestDay: { date: '2026-04-01', cost: 12 }, + busiestWeek: null, + weekendCostShare: null, + totalInput: 0, + totalOutput: 0, + totalCacheRead: 0, + totalCacheCreate: 0, + totalThinking: 0, + totalRequests: 0, + weekOverWeekChange: null, + requestVolatility: 0, + modelConcentrationIndex: 0, + providerConcentrationIndex: 0, +} + +describe('SecondaryMetrics help text', () => { + beforeEach(async () => { + vi.stubGlobal( + 'IntersectionObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ) + await initI18n('en') + }) + + it('uses month-specific help text when viewMode is monthly', () => { + render( + + + , + ) + + const infoTexts = screen.getAllByTestId('metric-info').map((node) => node.textContent) + + expect(infoTexts).toContain('Shows the month with the highest API cost in the current slice.') + expect(infoTexts).toContain('Shows the average cost per active month in the current slice.') + }) + + it('uses year-specific help text when viewMode is yearly', () => { + render( + + + , + ) + + const infoTexts = screen.getAllByTestId('metric-info').map((node) => node.textContent) + + expect(infoTexts).toContain('Shows the year with the highest API cost in the current slice.') + expect(infoTexts).toContain('Shows the average cost per active year in the current slice.') + }) +}) diff --git a/tests/frontend/usage-insights.test.tsx b/tests/frontend/usage-insights.test.tsx index 58f8f13..a552ee7 100644 --- a/tests/frontend/usage-insights.test.tsx +++ b/tests/frontend/usage-insights.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { render } from '@testing-library/react' +import { render, screen, within } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { UsageInsights } from '@/components/features/insights/UsageInsights' import { TooltipProvider } from '@/components/ui/tooltip' @@ -56,16 +56,19 @@ describe('UsageInsights', () => { }) it('keeps the animated insight grid items stretched to full height', () => { - const { container } = render( + render( , ) - const motionItems = container.querySelectorAll('.grid > .h-full') - const cards = container.querySelectorAll('.grid > .h-full > .h-full') + const grid = screen.getByTestId('usage-insights-grid') + const motionItems = within(grid).getAllByTestId('usage-insight-motion-item') + const cards = within(grid).getAllByTestId('usage-insight-card') - expect(motionItems.length).toBeGreaterThanOrEqual(4) - expect(cards.length).toBeGreaterThanOrEqual(4) + expect(motionItems).toHaveLength(4) + expect(cards).toHaveLength(4) + motionItems.forEach((item) => expect(item).toHaveClass('h-full')) + cards.forEach((card) => expect(card).toHaveClass('h-full')) }) }) diff --git a/tests/unit/chart-theme.test.ts b/tests/unit/chart-theme.test.ts new file mode 100644 index 0000000..063fa95 --- /dev/null +++ b/tests/unit/chart-theme.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest' +import { CHART_ANIMATION, getScatterAnimationProps } from '@/components/charts/chart-theme' + +describe('chart theme helpers', () => { + it('uses the reveal duration for scatter animation timing', () => { + const props = getScatterAnimationProps(true, 70) + + expect(props.isAnimationActive).toBe(true) + expect(props.animationBegin).toBe(CHART_ANIMATION.chartStartDelay + 70) + expect(props.animationDuration).toBe(CHART_ANIMATION.revealDuration) + }) +}) From 5a6ce5d56dd1a3b3f6ce2e8cd452bb77d47787eb Mon Sep 17 00:00:00 2001 From: tyl3r-ch Date: Wed, 15 Apr 2026 23:08:57 +0200 Subject: [PATCH 7/8] v6.2.2: Harden dashboard motion follow-ups --- src/components/cards/SecondaryMetrics.tsx | 3 +- src/components/dashboard/DashboardMotion.tsx | 76 ++++++++++++++++++- src/lib/help-content.ts | 4 + tests/frontend/dashboard-motion.test.tsx | 51 +++++++++++++ tests/frontend/request-quality-order.test.tsx | 6 +- tests/frontend/secondary-metrics.test.tsx | 31 +++++++- 6 files changed, 165 insertions(+), 6 deletions(-) diff --git a/src/components/cards/SecondaryMetrics.tsx b/src/components/cards/SecondaryMetrics.tsx index 6ae6dc1..3fe48f3 100644 --- a/src/components/cards/SecondaryMetrics.tsx +++ b/src/components/cards/SecondaryMetrics.tsx @@ -84,6 +84,7 @@ export function SecondaryMetrics({ : viewMode === 'monthly' ? METRIC_HELP.avgCostPerMonth : METRIC_HELP.avgCostPerDay + const periodAverageInfo = viewMode === 'daily' ? METRIC_HELP.peak7Days : avgCostInfo return (
@@ -128,7 +129,7 @@ export function SecondaryMetrics({ ) } icon={} - info={avgCostInfo} + info={periodAverageInfo} {...(peakSubtitle ? { subtitle: peakSubtitle } : {})} /> diff --git a/src/components/dashboard/DashboardMotion.tsx b/src/components/dashboard/DashboardMotion.tsx index a3c36c8..d8ab50a 100644 --- a/src/components/dashboard/DashboardMotion.tsx +++ b/src/components/dashboard/DashboardMotion.tsx @@ -9,7 +9,7 @@ import { type RefObject, type ReactNode, } from 'react' -import { AnimatePresence, motion, useInView } from 'framer-motion' +import { AnimatePresence, motion } from 'framer-motion' import { cn } from '@/lib/cn' import { useShouldReduceMotion } from '@/lib/motion' @@ -58,6 +58,41 @@ interface DashboardElementMotionState { shouldReduceMotion: boolean } +function useElementInView(ref: RefObject, amount: number) { + const [isInView, setIsInView] = useState(typeof IntersectionObserver === 'undefined') + + useEffect(() => { + if (typeof IntersectionObserver === 'undefined') { + setIsInView(true) + return + } + + if (isInView) return + + const element = ref.current + if (!element) return + + const observer = new IntersectionObserver( + (entries) => { + const entry = entries[0] + if (entry?.isIntersecting) { + setIsInView(true) + observer.disconnect() + } + }, + { threshold: amount }, + ) + + observer.observe(element) + + return () => { + observer.disconnect() + } + }, [amount, isInView, ref]) + + return isInView +} + /** Tracks one dashboard element and only activates motion once the element itself is visible. */ export function useDashboardElementMotion( ref: RefObject, @@ -70,8 +105,9 @@ export function useDashboardElementMotion( ): DashboardElementMotionState { const sectionMotion = useDashboardSectionMotion() const shouldReduceMotion = useShouldReduceMotion() - const isInView = useInView(ref, { once: true, amount }) - const active = (sectionMotion?.sectionVisible ?? true) && isInView + const observerMissing = typeof IntersectionObserver === 'undefined' + const isInView = useElementInView(ref, amount) + const active = (sectionMotion?.sectionVisible ?? true) && (observerMissing ? true : isInView) const [runKey, setRunKey] = useState(0) const previousActiveRef = useRef(false) @@ -122,6 +158,38 @@ export function DashboardMotionItem({ ...(amount !== undefined ? { amount } : {}), }) + useEffect(() => { + const element = itemRef.current + if (!element) return + + const focusableElements = element.querySelectorAll( + 'a[href], button, input, select, textarea, [tabindex]', + ) + + focusableElements.forEach((focusable) => { + if (!itemMotion.active) { + if (!focusable.hasAttribute('data-dashboard-motion-tabindex')) { + focusable.setAttribute( + 'data-dashboard-motion-tabindex', + focusable.getAttribute('tabindex') ?? '', + ) + } + focusable.tabIndex = -1 + return + } + + if (!focusable.hasAttribute('data-dashboard-motion-tabindex')) return + + const originalTabIndex = focusable.getAttribute('data-dashboard-motion-tabindex') + if (originalTabIndex) { + focusable.setAttribute('tabindex', originalTabIndex) + } else { + focusable.removeAttribute('tabindex') + } + focusable.removeAttribute('data-dashboard-motion-tabindex') + }) + }, [itemMotion.active]) + if (itemMotion.shouldReduceMotion) { return (
@@ -135,6 +203,8 @@ export function DashboardMotionItem({ ref={itemRef} className={className} data-testid={dataTestId} + aria-hidden={!itemMotion.active} + {...(!itemMotion.active ? { style: { pointerEvents: 'none' as const } } : {})} initial={false} animate={ itemMotion.active diff --git a/src/lib/help-content.ts b/src/lib/help-content.ts index f69fd4e..69c46be 100644 --- a/src/lib/help-content.ts +++ b/src/lib/help-content.ts @@ -25,6 +25,8 @@ const HELP_CONTENT = { cheapestDay: 'Zeigt den Zeitraumspunkt mit den niedrigsten API-Kosten im aktuellen Ausschnitt.', avgCostPerDay: 'Zeigt die durchschnittlichen Kosten pro aktivem Zeitraumspunkt.', + peak7Days: + 'Zeigt das teuerste rollierende 7-Tage-Fenster im aktuellen Ausschnitt und macht kurzfristige Lastspitzen sichtbar.', avgCostPerMonth: 'Zeigt die durchschnittlichen Kosten pro aktivem Monat im aktuellen Ausschnitt.', avgCostPerYear: @@ -144,6 +146,8 @@ const HELP_CONTENT = { mostExpensiveYear: 'Shows the year with the highest API cost in the current slice.', cheapestDay: 'Shows the period point with the lowest API cost in the current slice.', avgCostPerDay: 'Shows the average cost per active period point.', + peak7Days: + 'Shows the highest-cost rolling 7-day window in the current slice to highlight short-term peaks.', avgCostPerMonth: 'Shows the average cost per active month in the current slice.', avgCostPerYear: 'Shows the average cost per active year in the current slice.', outputTokens: diff --git a/tests/frontend/dashboard-motion.test.tsx b/tests/frontend/dashboard-motion.test.tsx index b037445..6f4e25e 100644 --- a/tests/frontend/dashboard-motion.test.tsx +++ b/tests/frontend/dashboard-motion.test.tsx @@ -1,10 +1,13 @@ // @vitest-environment jsdom +import { useRef } from 'react' import { act, render, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ChartCard, useChartAnimationState } from '@/components/charts/ChartCard' import { AnimatedDashboardSection, + DashboardMotionItem, + useDashboardElementMotion, useDashboardSectionMotion, } from '@/components/dashboard/DashboardMotion' import { initI18n } from '@/lib/i18n' @@ -51,6 +54,17 @@ function MotionProbe() { ) } +function ItemMotionProbe() { + const itemRef = useRef(null) + const itemMotion = useDashboardElementMotion(itemRef) + + return ( +
+ {String(itemMotion.active)} +
+ ) +} + describe('AnimatedDashboardSection', () => { beforeEach(async () => { MockIntersectionObserver.instances = [] @@ -177,4 +191,41 @@ describe('AnimatedDashboardSection', () => { expect(screen.getByTestId('section-visible')).toHaveTextContent('true') }) + + it('treats item motion as active when IntersectionObserver is unavailable', async () => { + vi.unstubAllGlobals() + + render( + + + , + ) + + expect(screen.getByTestId('item-active')).toHaveTextContent('true') + }) + + it('keeps hidden dashboard items non-interactive until they reveal', () => { + render( + + + + + , + ) + + const wrapper = screen.getByTestId('item-wrapper') + const button = screen.getByRole('button', { name: 'Item action', hidden: true }) + + expect(wrapper).toHaveAttribute('aria-hidden', 'true') + expect(wrapper).toHaveStyle({ pointerEvents: 'none' }) + expect(button).toHaveAttribute('tabindex', '-1') + + act(() => { + MockIntersectionObserver.instances[0]?.trigger(true) + }) + + expect(wrapper).toHaveAttribute('aria-hidden', 'false') + expect(wrapper).not.toHaveStyle({ pointerEvents: 'none' }) + expect(button).not.toHaveAttribute('tabindex', '-1') + }) }) diff --git a/tests/frontend/request-quality-order.test.tsx b/tests/frontend/request-quality-order.test.tsx index 461ccbd..8e43183 100644 --- a/tests/frontend/request-quality-order.test.tsx +++ b/tests/frontend/request-quality-order.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { render, screen } from '@testing-library/react' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { RequestQuality } from '@/components/features/request-quality/RequestQuality' import { TooltipProvider } from '@/components/ui/tooltip' import { initI18n } from '@/lib/i18n' @@ -52,6 +52,10 @@ describe('RequestQuality bar ordering', () => { await initI18n('en') }) + afterEach(() => { + vi.unstubAllGlobals() + }) + it('passes deterministic bar order values to AnimatedBarFill', () => { render( diff --git a/tests/frontend/secondary-metrics.test.tsx b/tests/frontend/secondary-metrics.test.tsx index 358468d..18a06b9 100644 --- a/tests/frontend/secondary-metrics.test.tsx +++ b/tests/frontend/secondary-metrics.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { render, screen } from '@testing-library/react' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { SecondaryMetrics } from '@/components/cards/SecondaryMetrics' import { TooltipProvider } from '@/components/ui/tooltip' import { initI18n } from '@/lib/i18n' @@ -59,6 +59,35 @@ describe('SecondaryMetrics help text', () => { await initI18n('en') }) + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('uses peak-window help text when viewMode is daily', () => { + render( + + + , + ) + + const infoTexts = screen.getAllByTestId('metric-info').map((node) => node.textContent) + + expect(infoTexts).toContain( + 'Shows the highest-cost rolling 7-day window in the current slice to highlight short-term peaks.', + ) + }) + it('uses month-specific help text when viewMode is monthly', () => { render( From 73dd7e65f2e02512e78489fe37139f6bf79fee8b Mon Sep 17 00:00:00 2001 From: tyl3r-ch Date: Wed, 15 Apr 2026 23:18:59 +0200 Subject: [PATCH 8/8] v6.2.2: Harden dashboard motion observer tests --- tests/frontend/dashboard-motion.test.tsx | 41 ++++++++++++++++++------ 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/tests/frontend/dashboard-motion.test.tsx b/tests/frontend/dashboard-motion.test.tsx index 6f4e25e..a3df6d9 100644 --- a/tests/frontend/dashboard-motion.test.tsx +++ b/tests/frontend/dashboard-motion.test.tsx @@ -16,13 +16,18 @@ class MockIntersectionObserver { static instances: MockIntersectionObserver[] = [] callback: IntersectionObserverCallback + options?: IntersectionObserverInit + observedTargets = new Set() - constructor(callback: IntersectionObserverCallback) { + constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) { this.callback = callback + this.options = options MockIntersectionObserver.instances.push(this) } - observe() {} + observe(target: Element) { + this.observedTargets.add(target) + } unobserve() {} @@ -41,6 +46,16 @@ class MockIntersectionObserver { } } +function getObserver(predicate: (observer: MockIntersectionObserver) => boolean) { + const observer = MockIntersectionObserver.instances.find(predicate) + expect(observer).toBeDefined() + return observer! +} + +function getObservers(predicate: (observer: MockIntersectionObserver) => boolean) { + return MockIntersectionObserver.instances.filter(predicate) +} + function MotionProbe() { const motion = useDashboardSectionMotion() const chartMotion = useChartAnimationState() @@ -98,7 +113,7 @@ describe('AnimatedDashboardSection', () => { expect(screen.queryByTestId('section-visible')).not.toBeInTheDocument() act(() => { - MockIntersectionObserver.instances[0]?.trigger(true) + getObserver((observer) => observer.options?.rootMargin === '0px 0px 45% 0px').trigger(true) }) await act(async () => { @@ -109,7 +124,7 @@ describe('AnimatedDashboardSection', () => { expect(screen.queryByTestId('section-visible')).not.toBeInTheDocument() act(() => { - MockIntersectionObserver.instances[1]?.trigger(true) + getObserver((observer) => observer.options?.threshold === 0.14).trigger(true) }) expect(screen.queryByTestId('section-visible')).not.toBeInTheDocument() @@ -123,7 +138,11 @@ describe('AnimatedDashboardSection', () => { expect(screen.getByTestId('chart-active')).toHaveTextContent('false') act(() => { - MockIntersectionObserver.instances.slice(2).forEach((observer) => observer.trigger(true)) + getObservers( + (observer) => + observer.options?.rootMargin !== '0px 0px 45% 0px' && + observer.options?.threshold !== 0.14, + ).forEach((observer) => observer.trigger(true)) }) expect(screen.getByTestId('section-visible')).toHaveTextContent('true') @@ -145,7 +164,7 @@ describe('AnimatedDashboardSection', () => { ) act(() => { - MockIntersectionObserver.instances[0]?.trigger(true) + getObserver((observer) => observer.options?.rootMargin === '0px 0px 45% 0px').trigger(true) }) await act(async () => { @@ -159,7 +178,7 @@ describe('AnimatedDashboardSection', () => { expect(button).toHaveAttribute('tabindex', '-1') act(() => { - MockIntersectionObserver.instances[1]?.trigger(true) + getObserver((observer) => observer.options?.threshold === 0.14).trigger(true) }) expect(document.querySelector('[data-section-visible="true"]')).toBeInTheDocument() @@ -181,8 +200,8 @@ describe('AnimatedDashboardSection', () => { ) act(() => { - MockIntersectionObserver.instances[0]?.trigger(true) - MockIntersectionObserver.instances[1]?.trigger(true) + getObserver((observer) => observer.options?.rootMargin === '0px 0px 45% 0px').trigger(true) + getObserver((observer) => observer.options?.threshold === 0.14).trigger(true) }) await act(async () => { @@ -221,7 +240,9 @@ describe('AnimatedDashboardSection', () => { expect(button).toHaveAttribute('tabindex', '-1') act(() => { - MockIntersectionObserver.instances[0]?.trigger(true) + getObservers((observer) => observer.options?.threshold === 0.24).forEach((observer) => + observer.trigger(true), + ) }) expect(wrapper).toHaveAttribute('aria-hidden', 'false')