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 diff --git a/src/components/cards/MonthMetrics.tsx b/src/components/cards/MonthMetrics.tsx index 6a1e03a..2ad9ed0 100644 --- a/src/components/cards/MonthMetrics.tsx +++ b/src/components/cards/MonthMetrics.tsx @@ -11,9 +11,9 @@ import { BrainCircuit, } from 'lucide-react' import { MetricCard } from './MetricCard' +import { DashboardMotionItem } from '@/components/dashboard/DashboardMotion' 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,8 +144,8 @@ export function MonthMetrics({ daily, metrics }: MonthMetricsProps) { description={t('metricCards.month.description')} info={SECTION_HELP.currentMonth} /> - -
+
+ } @@ -159,12 +159,16 @@ export function MonthMetrics({ daily, metrics }: MonthMetricsProps) { : null } /> + + } icon={} {...(tokensSubtitle ? { subtitle: tokensSubtitle } : {})} /> + + } /> + + } {...(modelsSubtitle ? { subtitle: modelsSubtitle } : {})} /> + + } icon={} {...(costPerMillionSubtitle ? { subtitle: costPerMillionSubtitle } : {})} /> + + } @@ -194,6 +204,8 @@ export function MonthMetrics({ daily, metrics }: MonthMetricsProps) { })} icon={} /> + + } /> + + } icon={} {...(thinkingSubtitle ? { subtitle: thinkingSubtitle } : {})} /> -
- + +
) } diff --git a/src/components/cards/PrimaryMetrics.tsx b/src/components/cards/PrimaryMetrics.tsx index a486f90..07c5c79 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/DashboardMotion' 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..3fe48f3 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/DashboardMotion' import { MetricCard } from './MetricCard' import { FormattedValue } from '@/components/ui/formatted-value' import { @@ -71,55 +72,76 @@ 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 + const periodAverageInfo = viewMode === 'daily' ? METRIC_HELP.peak7Days : avgCostInfo 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={topCostInfo} + {...(topDaySubtitle ? { subtitle: topDaySubtitle } : {})} + /> + + + } + info={t('metricCards.secondary.dominantProviderInfo')} + {...(topProviderSubtitle ? { subtitle: topProviderSubtitle } : {})} + /> + + + + ) : ( + + ) + } + icon={} + info={periodAverageInfo} + {...(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 7f674f2..655e0cf 100644 --- a/src/components/cards/TodayMetrics.tsx +++ b/src/components/cards/TodayMetrics.tsx @@ -8,10 +8,10 @@ import { BrainCircuit, } from 'lucide-react' import { useTranslation } from 'react-i18next' +import { DashboardMotionItem } from '@/components/dashboard/DashboardMotion' 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,8 +83,8 @@ export function TodayMetrics({ today, metrics }: TodayMetricsProps) { description={t('metricCards.today.description')} info={SECTION_HELP.today} /> - -
+
+ } @@ -96,6 +96,8 @@ export function TodayMetrics({ today, metrics }: TodayMetricsProps) { } {...(costSubtitle ? { subtitle: costSubtitle } : {})} /> + + } {...(tokensSubtitle ? { subtitle: tokensSubtitle } : {})} /> + + } {...(modelSubtitle ? { subtitle: modelSubtitle } : {})} /> + + } {...(costPerMillionSubtitle ? { subtitle: costPerMillionSubtitle } : {})} /> + + } @@ -140,6 +148,8 @@ export function TodayMetrics({ today, metrics }: TodayMetricsProps) { })} icon={} /> + + } /> + + } icon={} {...(thinkingSubtitle ? { subtitle: thinkingSubtitle } : {})} /> -
- + +
) } diff --git a/src/components/charts/ChartCard.tsx b/src/components/charts/ChartCard.tsx index e9f3c7d..4405504 100644 --- a/src/components/charts/ChartCard.tsx +++ b/src/components/charts/ChartCard.tsx @@ -8,11 +8,17 @@ 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, + useDashboardElementMotion, + useDashboardSectionMotion, +} from '@/components/dashboard/DashboardMotion' +import { CHART_ANIMATION } from './chart-theme' import { cn } from '@/lib/cn' import { buildCsvLine } from '@/lib/csv' import { formatCurrency } from '@/lib/formatters' @@ -48,18 +54,43 @@ export function buildChartCsv(chartData: Record[]): string { ].join('\n') } -const ChartAnimationContext = createContext(false) +interface ChartAnimationState { + active: boolean + delayMs: number + runKey: number +} + +const ChartAnimationContext = createContext({ + active: false, + delayMs: 0, + runKey: 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 +} + +/** 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() - const animationActive = useChartAnimationActive() - return <>{children(shouldReduceMotion ? false : animationActive)} + const animationState = useChartAnimationState() + return <>{children(shouldReduceMotion ? false : animationState.active)} } interface ChartRevealProps { @@ -69,20 +100,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 runKey = useChartAnimationRunKey() + 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} -
+
{children}
+ ) } @@ -101,10 +148,40 @@ 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 elementMotion = useDashboardElementMotion(cardRef, { + kind: 'chart', + amount: 0.3, + }) + const animationState = useMemo(() => { + if (expanded) { + return { active: true, delayMs: 0, runKey: 1 } + } + + if (sectionMotion) { + return { + active: elementMotion.active, + delayMs: elementMotion.delayMs, + runKey: elementMotion.runKey, + } + } + + return { + active: isInView, + delayMs: DASHBOARD_MOTION.chartStartDelayMs, + runKey: isInView ? 1 : 0, + } + }, [ + elementMotion.active, + elementMotion.delayMs, + elementMotion.runKey, + expanded, + isInView, + sectionMotion, + ]) const stats = useMemo(() => { if (!chartData || !valueKey) return null @@ -125,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 @@ -156,7 +234,7 @@ export function ChartCard({ return ( <> - + {header} {renderChildren(false)} @@ -164,6 +242,7 @@ export function ChartCard({ + + , + ) + + 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(() => { + getObservers((observer) => observer.options?.threshold === 0.24).forEach((observer) => + observer.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/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/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/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/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 a69366d..457c8aa 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 { @@ -75,4 +97,29 @@ describe('ProviderLimitsSection', () => { expect(screen.getByText('240% Abo')).toBeInTheDocument() expect(screen.getByText('Offen')).toBeInTheDocument() }) + + 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) + }) }) 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..ac7dc10 --- /dev/null +++ b/tests/frontend/request-cache-hit-rate-by-model.test.tsx @@ -0,0 +1,130 @@ +// @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() + }) + + 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/request-quality-order.test.tsx b/tests/frontend/request-quality-order.test.tsx new file mode 100644 index 0000000..8e43183 --- /dev/null +++ b/tests/frontend/request-quality-order.test.tsx @@ -0,0 +1,70 @@ +// @vitest-environment jsdom + +import { render, screen } 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' + +vi.mock('@/components/features/animations/AnimatedBarFill', () => ({ + 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') + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + 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/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/secondary-metrics.test.tsx b/tests/frontend/secondary-metrics.test.tsx new file mode 100644 index 0000000..18a06b9 --- /dev/null +++ b/tests/frontend/secondary-metrics.test.tsx @@ -0,0 +1,116 @@ +// @vitest-environment jsdom + +import { render, screen } from '@testing-library/react' +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' +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') + }) + + 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( + + + , + ) + + 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/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/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/frontend/usage-insights.test.tsx b/tests/frontend/usage-insights.test.tsx new file mode 100644 index 0000000..a552ee7 --- /dev/null +++ b/tests/frontend/usage-insights.test.tsx @@ -0,0 +1,74 @@ +// @vitest-environment jsdom + +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' +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', () => { + render( + + + , + ) + + 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).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/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) }) 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) + }) +})