fix(charts): mobile ui, and desktop tooltips

This commit is contained in:
Víctor Falcón 2025-12-27 16:49:02 +00:00
parent 14a9343c1d
commit 818a49e799
4 changed files with 206 additions and 122 deletions

View File

@ -25,7 +25,7 @@ import {
} from '@/hooks/use-chart-views';
import { Account } from '@/types/account';
import { format, subMonths } from 'date-fns';
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Bar, BarChart, XAxis } from 'recharts';
interface BalanceDataPoint {
@ -189,6 +189,17 @@ export function AccountBalanceChart({
return formatCurrency(value, account.currency_code);
};
const scrollContainerRef = useRef<HTMLDivElement>(null);
const minBarWidth = 50;
const minChartWidth = chartData.length * minBarWidth;
useEffect(() => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollLeft =
scrollContainerRef.current.scrollWidth;
}
}, [chartData]);
if (initialLoading || isLoading) {
return (
<Card>
@ -264,33 +275,42 @@ export function AccountBalanceChart({
</CardHeader>
<CardContent className="relative">
{chartViews.currentView === 'stacked' && (
<ChartContainer
config={chartConfig}
className="h-[300px] w-full"
<div
ref={scrollContainerRef}
className="h-[300px] w-full overflow-x-auto"
>
<BarChart accessibilityLayer data={chartData.slice(1)}>
<XAxis
dataKey="month"
tickLine={false}
tickMargin={10}
axisLine={false}
tickFormatter={formatXAxisLabel}
/>
<ChartTooltip
content={
<ChartTooltipContent
hideLabel
valueFormatter={valueFormatter}
/>
}
/>
<Bar
dataKey="value"
fill="var(--color-chart-2)"
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ChartContainer>
<ChartContainer
config={chartConfig}
className="h-full w-full"
style={{ minWidth: `${minChartWidth}px` }}
>
<BarChart
accessibilityLayer
data={chartData.slice(1)}
>
<XAxis
dataKey="month"
tickLine={false}
tickMargin={10}
axisLine={false}
tickFormatter={formatXAxisLabel}
/>
<ChartTooltip
content={
<ChartTooltipContent
hideLabel
valueFormatter={valueFormatter}
/>
}
/>
<Bar
dataKey="value"
fill="var(--color-chart-2)"
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ChartContainer>
</div>
)}
{chartViews.currentView === 'mom' && (
<MoMChart

View File

@ -1,4 +1,9 @@
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { ChartViewType } from '@/hooks/use-chart-views';
import { cn } from '@/lib/utils';
import { BarChart3, Percent, TrendingUp } from 'lucide-react';
@ -12,11 +17,19 @@ interface ChartViewToggleProps {
const viewConfig: Record<
ChartViewType,
{ icon: React.ElementType; label: string }
{ icon: React.ElementType; label: string; tooltip: string }
> = {
stacked: { icon: BarChart3, label: 'Accounts' },
mom: { icon: TrendingUp, label: 'MoM' },
mom_percent: { icon: Percent, label: 'MoM%' },
stacked: {
icon: BarChart3,
label: 'Aggregate',
tooltip: 'Monthly balance',
},
mom: { icon: TrendingUp, label: 'MoM', tooltip: 'Month over month change' },
mom_percent: {
icon: Percent,
label: 'MoM%',
tooltip: 'Month over month change (%)',
},
};
export function ChartViewToggle({
@ -40,15 +53,20 @@ export function ChartViewToggle({
const config = viewConfig[view];
const Icon = config.icon;
return (
<ToggleGroupItem
key={view}
value={view}
aria-label={config.label}
className="gap-1.5 px-2"
>
<Icon className="size-3.5" />
<span className="hidden sm:inline">{config.label}</span>
</ToggleGroupItem>
<Tooltip key={view}>
<TooltipTrigger asChild>
<ToggleGroupItem
value={view}
aria-label={config.label}
className="px-2"
>
<Icon className="size-3.5" />
</ToggleGroupItem>
</TooltipTrigger>
<TooltipContent side="bottom">
{config.tooltip}
</TooltipContent>
</Tooltip>
);
})}
</ToggleGroup>

View File

@ -2,6 +2,7 @@ import { AmountDisplay } from '@/components/ui/amount-display';
import { ChartConfig, ChartContainer } from '@/components/ui/chart';
import { ChangeDataPoint } from '@/lib/chart-calculations';
import { cn } from '@/lib/utils';
import { useEffect, useRef } from 'react';
import {
Bar,
BarChart,
@ -17,6 +18,7 @@ interface MoMChartProps {
currencyCode: string;
xAxisFormatter?: (value: string) => string;
className?: string;
minBarWidth?: number;
}
const chartConfig: ChartConfig = {
@ -77,56 +79,76 @@ export function MoMChart({
currencyCode,
xAxisFormatter,
className,
minBarWidth = 50,
}: MoMChartProps) {
const scrollContainerRef = useRef<HTMLDivElement>(null);
const minChartWidth = data.length * minBarWidth;
useEffect(() => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollLeft =
scrollContainerRef.current.scrollWidth;
}
}, [data]);
const chartData = data.map((point) => ({
...point,
displayValue: point.change,
}));
return (
<ChartContainer config={chartConfig} className={className}>
<BarChart accessibilityLayer data={chartData}>
<XAxis
dataKey="month"
tickLine={false}
tickMargin={10}
axisLine={false}
tickFormatter={xAxisFormatter}
/>
<YAxis
tickLine={false}
axisLine={false}
tickFormatter={(value: number) => {
return new Intl.NumberFormat('en-US', {
notation: 'compact',
compactDisplay: 'short',
}).format(value / 100);
}}
width={50}
/>
<ReferenceLine
y={0}
stroke="var(--color-border)"
strokeDasharray="3 3"
/>
<Tooltip
content={<CustomTooltip currencyCode={currencyCode} />}
cursor={{ fill: 'var(--color-muted)', opacity: 0.3 }}
/>
<Bar dataKey="displayValue" radius={[4, 4, 0, 0]}>
{chartData.map((entry, index) => {
const value = entry.displayValue;
let fill = 'var(--color-muted)';
if (value !== null) {
fill =
value >= 0
? 'var(--color-chart-2)'
: 'var(--color-chart-5)';
}
return <Cell key={`cell-${index}`} fill={fill} />;
})}
</Bar>
</BarChart>
</ChartContainer>
<div
ref={scrollContainerRef}
className={cn('overflow-x-auto', className)}
>
<ChartContainer
config={chartConfig}
className="h-full w-full"
style={{ minWidth: `${minChartWidth}px` }}
>
<BarChart accessibilityLayer data={chartData}>
<XAxis
dataKey="month"
tickLine={false}
tickMargin={10}
axisLine={false}
tickFormatter={xAxisFormatter}
/>
<YAxis
tickLine={false}
axisLine={false}
tickFormatter={(value: number) => {
return new Intl.NumberFormat('en-US', {
notation: 'compact',
compactDisplay: 'short',
}).format(value / 100);
}}
width={50}
/>
<ReferenceLine
y={0}
stroke="var(--color-border)"
strokeDasharray="3 3"
/>
<Tooltip
content={<CustomTooltip currencyCode={currencyCode} />}
cursor={{ fill: 'var(--color-muted)', opacity: 0.3 }}
/>
<Bar dataKey="displayValue" radius={[4, 4, 0, 0]}>
{chartData.map((entry, index) => {
const value = entry.displayValue;
let fill = 'var(--color-muted)';
if (value !== null) {
fill =
value >= 0
? 'var(--color-chart-2)'
: 'var(--color-chart-5)';
}
return <Cell key={`cell-${index}`} fill={fill} />;
})}
</Bar>
</BarChart>
</ChartContainer>
</div>
);
}

View File

@ -1,6 +1,7 @@
import { ChartConfig, ChartContainer } from '@/components/ui/chart';
import { formatPercentValue, PercentDataPoint } from '@/lib/chart-calculations';
import { cn } from '@/lib/utils';
import { useEffect, useRef } from 'react';
import {
Bar,
BarChart,
@ -15,6 +16,7 @@ interface MoMPercentChartProps {
data: PercentDataPoint[];
xAxisFormatter?: (value: string) => string;
className?: string;
minBarWidth?: number;
}
const chartConfig: ChartConfig = {
@ -68,51 +70,73 @@ export function MoMPercentChart({
data,
xAxisFormatter,
className,
minBarWidth = 50,
}: MoMPercentChartProps) {
const scrollContainerRef = useRef<HTMLDivElement>(null);
const minChartWidth = data.length * minBarWidth;
useEffect(() => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollLeft =
scrollContainerRef.current.scrollWidth;
}
}, [data]);
const chartData = data.map((point) => ({
...point,
displayValue: point.percent,
}));
return (
<ChartContainer config={chartConfig} className={className}>
<BarChart accessibilityLayer data={chartData}>
<XAxis
dataKey="month"
tickLine={false}
tickMargin={10}
axisLine={false}
tickFormatter={xAxisFormatter}
/>
<YAxis
tickLine={false}
axisLine={false}
tickFormatter={(value: number) => `${value.toFixed(0)}%`}
width={50}
/>
<ReferenceLine
y={0}
stroke="var(--color-border)"
strokeDasharray="3 3"
/>
<Tooltip
content={<CustomTooltip />}
cursor={{ fill: 'var(--color-muted)', opacity: 0.3 }}
/>
<Bar dataKey="displayValue" radius={[4, 4, 0, 0]}>
{chartData.map((entry, index) => {
const value = entry.displayValue;
let fill = 'var(--color-muted)';
if (value !== null) {
fill =
value >= 0
? 'var(--color-chart-2)'
: 'var(--color-chart-5)';
<div
ref={scrollContainerRef}
className={cn('overflow-x-auto', className)}
>
<ChartContainer
config={chartConfig}
className="h-full w-full"
style={{ minWidth: `${minChartWidth}px` }}
>
<BarChart accessibilityLayer data={chartData}>
<XAxis
dataKey="month"
tickLine={false}
tickMargin={10}
axisLine={false}
tickFormatter={xAxisFormatter}
/>
<YAxis
tickLine={false}
axisLine={false}
tickFormatter={(value: number) =>
`${value.toFixed(0)}%`
}
return <Cell key={`cell-${index}`} fill={fill} />;
})}
</Bar>
</BarChart>
</ChartContainer>
width={50}
/>
<ReferenceLine
y={0}
stroke="var(--color-border)"
strokeDasharray="3 3"
/>
<Tooltip
content={<CustomTooltip />}
cursor={{ fill: 'var(--color-muted)', opacity: 0.3 }}
/>
<Bar dataKey="displayValue" radius={[4, 4, 0, 0]}>
{chartData.map((entry, index) => {
const value = entry.displayValue;
let fill = 'var(--color-muted)';
if (value !== null) {
fill =
value >= 0
? 'var(--color-chart-2)'
: 'var(--color-chart-5)';
}
return <Cell key={`cell-${index}`} fill={fill} />;
})}
</Bar>
</BarChart>
</ChartContainer>
</div>
);
}