Adding Concentric Donut Pie Chart type to custom report charts (#7038)

* Initial commit for concentric donut chart implementation

* [autofix.ci] apply automated fixes

* Update upcoming-release-notes/7038.md

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Fix coderabbit comments (Recalculated total to avoid hidden cats, remove tooltip, add proper types)

* Update packages/desktop-client/src/components/reports/graphs/DonutGraph.tsx

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Fix zero total group

* lint issues fix

* Fix lint issues

* Empty commit to retriger the process

* [autofix.ci] apply automated fixes

* Removed line betweeen arc and label. I beleive the view is cleaner this way.

* Fixed line for outer donut

* split active shape for concentric circles to avoid impacting original chart

* [autofix.ci] apply automated fixes

* Fixing mid point to align with mid point on inner circle

* 1- make line always start inside the core circle
2- fix bug where inner circle label was showing below the outer circle

* - Fixed Dashboard issue when height too low.
- Rewrite of the activeShape part for simplicity.
- Centralize radius calculation.
- Provide differnt dimensions for compact vs standard rendering
- fix mid line point to auto fix at 70% of the inner radius
- More readable code

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #7038

* Fixed distance issue for arc on single ring.

* [autofix.ci] apply automated fixes

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #7038

* Update packages/desktop-client/src/components/reports/graphs/DonutGraph.tsx

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Fixing Code Rabbit Comments

* rerunning tests

* Added Group click through passing all categories iwth a workaorund on showActivity

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Karim Kodera
2026-03-17 22:10:30 +02:00
committed by GitHub
parent 15358b6b54
commit 4cdb26f9a7
8 changed files with 504 additions and 119 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 121 KiB

View File

@@ -53,6 +53,7 @@ const balanceTypeOptions = [
const groupByOptions = [
{ description: t('Category'), key: 'Category' },
{ description: t('Group'), key: 'Group' },
{ description: t('Category+Group'), key: 'CategoryGroup' }, // new: two-ring donut support
{ description: t('Payee'), key: 'Payee' },
{ description: t('Account'), key: 'Account' },
{ description: t('Interval'), key: 'Interval' },
@@ -220,12 +221,10 @@ const intervalOptions: intervalOptionsProps[] = [
format: 'yy-MM-dd',
range: 'weekRangeInclusive',
},
//{ value: 3, description: 'Fortnightly', name: 3},
{
description: t('Monthly'),
key: 'Monthly',
name: 'Month',
format: "MMM ''yy",
range: 'rangeInclusive',
},
@@ -328,17 +327,11 @@ export const categoryLists = (categories: {
const categoriesToSort = [...categories.list];
const categoryList: UncategorizedEntity[] = [
...categoriesToSort.sort((a, b) => {
//The point of this sorting is to make the graphs match the "budget" page
const catGroupA = categories.grouped.find(f => f.id === a.group);
const catGroupB = categories.grouped.find(f => f.id === b.group);
//initial check that both a and b have a sort_order and category group
return a.sort_order && b.sort_order && catGroupA && catGroupB
? /*sorting by "is_income" because sort_order for this group is
separate from other groups*/
Number(catGroupA.is_income) - Number(catGroupB.is_income) ||
//Next, sorting by group sort_order
? Number(catGroupA.is_income) - Number(catGroupB.is_income) ||
(catGroupA.sort_order ?? 0) - (catGroupB.sort_order ?? 0) ||
//Finally, sorting by category within each group
a.sort_order - b.sort_order
: 0;
}),
@@ -382,6 +375,20 @@ export const groupBySelections = (
});
groupByLabel = 'categoryGroup';
break;
// CategoryGroup uses category-level data from createCustomSpreadsheet.
// The group-level data comes from groupedData (createGroupedSpreadsheet).
// This case just prevents the default throw so the spreadsheet doesn't error.
case 'CategoryGroup':
groupByList = categoryGroup.map(group => {
return {
...group,
id: group.id,
name: group.name,
hidden: group.hidden,
};
});
groupByLabel = 'categoryGroup';
break;
case 'Payee':
groupByList = payees.map(payee => {
return { id: payee.id, name: payee.name, hidden: false };

View File

@@ -63,10 +63,12 @@ type graphOptions = {
disableLabel?: boolean;
disableSort?: boolean;
};
const totalGraphOptions: graphOptions[] = [
{
description: 'TableGraph',
disabledSplit: [],
// CategoryGroup is only valid for DonutGraph
disabledSplit: ['CategoryGroup'],
defaultSplit: 'Category',
disabledType: [],
defaultType: 'Payment',
@@ -76,7 +78,8 @@ const totalGraphOptions: graphOptions[] = [
},
{
description: 'BarGraph',
disabledSplit: [],
// CategoryGroup is only valid for DonutGraph
disabledSplit: ['CategoryGroup'],
defaultSplit: 'Category',
disabledType: [],
defaultType: 'Payment',
@@ -84,7 +87,8 @@ const totalGraphOptions: graphOptions[] = [
},
{
description: 'AreaGraph',
disabledSplit: ['Category', 'Group', 'Payee', 'Account'],
// CategoryGroup is only valid for DonutGraph
disabledSplit: ['Category', 'Group', 'CategoryGroup', 'Payee', 'Account'],
defaultSplit: 'Interval',
disabledType: [],
defaultType: 'Payment',
@@ -94,6 +98,7 @@ const totalGraphOptions: graphOptions[] = [
},
{
description: 'DonutGraph',
// CategoryGroup is allowed here — it enables the two-ring concentric donut
disabledSplit: [],
defaultSplit: 'Category',
disabledType: ['Net'],
@@ -105,7 +110,8 @@ const totalGraphOptions: graphOptions[] = [
const timeGraphOptions: graphOptions[] = [
{
description: 'TableGraph',
disabledSplit: ['Interval'],
// CategoryGroup disabled in time mode (DonutGraph not available in time mode)
disabledSplit: ['Interval', 'CategoryGroup'],
defaultSplit: 'Category',
disabledType: ['Net Payment', 'Net Deposit'],
defaultType: 'Payment',
@@ -116,7 +122,8 @@ const timeGraphOptions: graphOptions[] = [
},
{
description: 'StackedBarGraph',
disabledSplit: ['Interval'],
// CategoryGroup disabled in time mode
disabledSplit: ['Interval', 'CategoryGroup'],
defaultSplit: 'Category',
disabledType: [],
defaultType: 'Payment',
@@ -125,7 +132,8 @@ const timeGraphOptions: graphOptions[] = [
},
{
description: 'LineGraph',
disabledSplit: ['Interval'],
// CategoryGroup disabled in time mode
disabledSplit: ['Interval', 'CategoryGroup'],
defaultSplit: 'Category',
disabledType: [],
defaultType: 'Payment',

View File

@@ -1,9 +1,9 @@
// @ts-strict-ignore
import React, { useState } from 'react';
import React, { useMemo, useState } from 'react';
import type { CSSProperties } from 'react';
import { theme } from '@actual-app/components/theme';
import { Pie, PieChart, Sector, Tooltip } from 'recharts';
import { Pie, PieChart, Sector } from 'recharts';
import type { PieSectorShapeProps } from 'recharts';
import type {
@@ -11,6 +11,7 @@ import type {
DataEntity,
GroupedEntity,
IntervalEntity,
LegendEntity,
RuleConditionEntity,
} from 'loot-core/types/models';
@@ -31,52 +32,162 @@ const RADIAN = Math.PI / 180;
const canDeviceHover = () => window.matchMedia('(hover: hover)').matches;
const ActiveShapeMobile = props => {
const {
cx,
cy,
innerRadius,
outerRadius,
startAngle,
endAngle,
fill,
payload,
percent,
value,
format,
} = props;
const yAxis = payload.name ?? payload.date;
// ---------------------------------------------------------------------------
// Dimension helpers
// ---------------------------------------------------------------------------
const sin = Math.sin(-RADIAN * 240);
const my = cy + outerRadius * sin;
const ey = my - 5;
type DonutDimensions = {
chartInnerRadius: number;
chartMidRadius: number;
chartOuterRadius: number;
compact: boolean;
};
const getDonutDimensions = (
width: number,
height: number,
twoRings: boolean,
): DonutDimensions => {
const minDim = Math.min(width, height);
const compact = height <= 300 || width <= 300;
return {
chartInnerRadius: minDim * (twoRings && compact ? 0.16 : 0.2),
chartMidRadius: minDim * (compact ? 0.27 : 0.31),
chartOuterRadius: minDim * (compact ? 0.36 : 0.42),
compact,
};
};
// ---------------------------------------------------------------------------
// Color helpers
// ---------------------------------------------------------------------------
const resolveCSSVariable = (color: string): string => {
if (!color.startsWith('var(')) return color;
const inner = color.slice(4, -1).trim();
const varName = inner.split(',')[0].trim();
return getComputedStyle(document.documentElement)
.getPropertyValue(varName)
.trim();
};
const hexToRgb = (hex: string) => {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result
? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16),
}
: { r: 0, g: 0, b: 0 };
};
const shadeColor = (resolvedHex: string, percent: number): string => {
const { r, g, b } = hexToRgb(resolvedHex);
const adjust = (c: number) =>
Math.min(255, Math.max(0, Math.round(c + (255 - c) * percent)));
return `rgb(${adjust(r)}, ${adjust(g)}, ${adjust(b)})`;
};
const buildColorMap = (
groupedData: GroupedEntity[],
legend: LegendEntity[],
): Map<string, string> => {
const legendById = new Map(
legend
.filter(l => l.id != null)
.map(l => [l.id, resolveCSSVariable(l.color)]),
);
return groupedData.reduce((acc, group) => {
if (!group.id) return acc;
const groupColor = legendById.get(group.id);
if (!groupColor) return acc;
acc.set(group.id, groupColor);
// Fix 1: capture cats once to avoid group.categories.length on undefined
const cats = group.categories ?? [];
cats.forEach((cat, i) => {
if (!cat.id) return;
const shade = 0.15 + (i / Math.max(cats.length, 1)) * 0.5;
acc.set(cat.id, shadeColor(groupColor, shade));
});
return acc;
}, new Map<string, string>());
};
// ---------------------------------------------------------------------------
// Active shapes
// ---------------------------------------------------------------------------
type ActiveShapeProps = {
cx: number;
cy: number;
midAngle: number;
innerRadius: number;
outerRadius: number;
startAngle: number;
endAngle: number;
fill: string;
payload: { name?: string; date?: string };
percent: number;
value: number;
expandInward: boolean;
chartInnerRadius: number;
chartMidRadius: number;
chartOuterRadius: number;
};
const ActiveShapeMobile = ({
cx,
cy,
innerRadius,
outerRadius,
startAngle,
endAngle,
fill,
payload,
percent,
value,
expandInward,
chartInnerRadius,
chartMidRadius,
chartOuterRadius,
}: ActiveShapeProps) => {
const format = useFormat();
// Fix 2: guard against undefined payload.name and payload.date
const yAxis = payload.name ?? payload.date ?? '';
const expansionInner = expandInward ? chartInnerRadius - 4 : outerRadius + 2;
const expansionOuter = expandInward ? chartInnerRadius - 2 : outerRadius + 4;
const ey = cy + chartOuterRadius * Math.sin(-RADIAN * 240) - 5;
return (
<g>
<text
x={cx}
y={cy + outerRadius * Math.sin(-RADIAN * 270) + 15}
dy={0}
y={cy + chartOuterRadius * Math.sin(-RADIAN * 270) + 17}
textAnchor="middle"
fill={fill}
>
{`${yAxis}`}
{yAxis}
</text>
<PrivacyFilter>
<FinancialText
as="text"
x={cx + outerRadius * Math.cos(-RADIAN * 240) - 30}
x={cx + chartOuterRadius * Math.cos(-RADIAN * 240) - 30}
y={ey}
dy={0}
textAnchor="end"
fill={fill}
>
{`${format(value, 'financial')}`}
{format(value, 'financial')}
</FinancialText>
<text
x={cx + outerRadius * Math.cos(-RADIAN * 330) + 10}
x={cx + chartOuterRadius * Math.cos(-RADIAN * 330) + 10}
y={ey}
dy={0}
textAnchor="start"
fill="#999"
>
@@ -97,43 +208,52 @@ const ActiveShapeMobile = props => {
cy={cy}
startAngle={startAngle}
endAngle={endAngle}
innerRadius={innerRadius - 8}
outerRadius={innerRadius - 6}
innerRadius={expansionInner}
outerRadius={expansionOuter}
fill={fill}
/>
</g>
);
};
const ActiveShapeMobileWithFormat = props => (
<ActiveShapeMobile {...props} format={props.format} />
);
const ActiveShape = props => {
const {
cx,
cy,
midAngle,
innerRadius,
outerRadius,
startAngle,
endAngle,
fill,
payload,
percent,
value,
format,
} = props;
const yAxis = payload.name ?? payload.date;
const ActiveShapeDesktop = ({
cx,
cy,
midAngle,
innerRadius,
outerRadius,
startAngle,
endAngle,
fill,
payload,
percent,
value,
expandInward,
chartInnerRadius,
chartMidRadius,
chartOuterRadius,
}: ActiveShapeProps) => {
const format = useFormat();
// Fix 2: guard against undefined payload.name and payload.date
const yAxis = payload.name ?? payload.date ?? '';
const sin = Math.sin(-RADIAN * midAngle);
const cos = Math.cos(-RADIAN * midAngle);
const sx = cx + (innerRadius - 10) * cos;
const sy = cy + (innerRadius - 10) * sin;
const mx = cx + (innerRadius - 30) * cos;
const my = cy + (innerRadius - 30) * sin;
const expansionInner = expandInward ? chartInnerRadius - 10 : outerRadius + 6;
const expansionOuter = expandInward ? chartInnerRadius - 6 : outerRadius + 10;
const lineStart = expandInward
? chartInnerRadius - 20
: chartInnerRadius - 10;
const lineMid = chartInnerRadius * 0.7;
const sx = cx + lineStart * cos;
const sy = cy + lineStart * sin;
const mx = cx + lineMid * cos;
const my = cy + lineMid * sin;
const ex = cx + (cos >= 0 ? 1 : -1) * yAxis.length * 4;
const ey = cy + 8;
const textAnchor = cos <= 0 ? 'start' : 'end';
const labelX = ex + (cos <= 0 ? 1 : -1) * 16;
return (
<g>
@@ -151,8 +271,8 @@ const ActiveShape = props => {
cy={cy}
startAngle={startAngle}
endAngle={endAngle}
innerRadius={outerRadius + 6}
outerRadius={outerRadius + 10}
innerRadius={expansionInner}
outerRadius={expansionOuter}
fill={fill}
/>
<path
@@ -161,30 +281,21 @@ const ActiveShape = props => {
fill="none"
/>
<circle cx={ex} cy={ey} r={3} fill={fill} stroke="none" />
<text
x={ex + (cos <= 0 ? 1 : -1) * 16}
y={ey}
textAnchor={textAnchor}
fill={fill}
>{`${yAxis}`}</text>
<text x={labelX} y={ey} textAnchor={textAnchor} fill={fill}>
{yAxis}
</text>
<PrivacyFilter>
<FinancialText
as="text"
x={ex + (cos <= 0 ? 1 : -1) * 16}
x={labelX}
y={ey}
dy={18}
textAnchor={textAnchor}
fill={fill}
>
{`${format(value, 'financial')}`}
{format(value, 'financial')}
</FinancialText>
<text
x={ex + (cos <= 0 ? 1 : -1) * 16}
y={ey}
dy={36}
textAnchor={textAnchor}
fill="#999"
>
<text x={labelX} y={ey} dy={36} textAnchor={textAnchor} fill="#999">
{`(${(percent * 100).toFixed(2)}%)`}
</text>
</PrivacyFilter>
@@ -192,15 +303,14 @@ const ActiveShape = props => {
);
};
const ActiveShapeWithFormat = props => (
<ActiveShape {...props} format={props.format} />
);
// ---------------------------------------------------------------------------
// Custom label
// ---------------------------------------------------------------------------
const customLabel = props => {
const radius =
props.innerRadius + (props.outerRadius - props.innerRadius) * 0.5;
const size = props.cx > props.cy ? props.cy : props.cx;
const calcX = props.cx + radius * Math.cos(-props.midAngle * RADIAN);
const calcY = props.cy + radius * Math.sin(-props.midAngle * RADIAN);
const textAnchor = calcX > props.cx ? 'start' : 'end';
@@ -209,7 +319,6 @@ const customLabel = props => {
const showLabel = props.percent;
const showLabelThreshold = 0.05;
const fill = theme.reportsInnerLabel;
return renderCustomLabel(
calcX,
calcY,
@@ -222,6 +331,10 @@ const customLabel = props => {
);
};
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
type DonutGraphProps = {
style?: CSSProperties;
data: DataEntity;
@@ -245,7 +358,6 @@ export function DonutGraph({
showOffBudget,
showTooltip = true,
}: DonutGraphProps) {
const format = useFormat();
const animationProps = useRechartsAnimation({ isAnimationActive: false });
const yAxis = groupBy === 'Interval' ? 'date' : 'name';
@@ -259,18 +371,248 @@ export function DonutGraph({
const getVal = (obj: GroupedEntity | IntervalEntity) => {
if (['totalDebts', 'netDebts'].includes(balanceTypeOp)) {
return -1 * obj[balanceTypeOp];
} else {
return obj[balanceTypeOp];
}
return obj[balanceTypeOp];
};
const [activeIndex, setActiveIndex] = useState(0);
const [activeGroupIndex, setActiveGroupIndex] = useState(0);
const [activeCategoryIndex, setActiveCategoryIndex] = useState(0);
const [activeRing, setActiveRing] = useState<'group' | 'category'>(
'category',
);
const isCategoryGroup =
groupBy === 'CategoryGroup' && !!data.groupedData?.length;
const { adjustedGroupData, flatCategories } = useMemo(() => {
if (!isCategoryGroup || !data.groupedData) {
return { adjustedGroupData: [], flatCategories: [] };
}
const adjustedGroups = data.groupedData
.map(group => {
const visibleCats = group.categories ?? [];
return {
...group,
totalAssets: visibleCats.reduce((sum, c) => sum + c.totalAssets, 0),
totalDebts: visibleCats.reduce((sum, c) => sum + c.totalDebts, 0),
totalTotals: visibleCats.reduce((sum, c) => sum + c.totalTotals, 0),
netAssets: visibleCats.reduce((sum, c) => sum + c.netAssets, 0),
netDebts: visibleCats.reduce((sum, c) => sum + c.netDebts, 0),
};
})
.filter(group =>
['totalDebts', 'netDebts'].includes(balanceTypeOp)
? -1 * group[balanceTypeOp] !== 0
: group[balanceTypeOp] !== 0,
);
return {
adjustedGroupData: adjustedGroups,
flatCategories: data.groupedData.flatMap(g => g.categories ?? []),
};
}, [isCategoryGroup, data.groupedData, balanceTypeOp]);
const colorMap = useMemo(
() =>
isCategoryGroup
? buildColorMap(data.groupedData ?? [], data.legend ?? [])
: new Map<string, string>(),
[isCategoryGroup, data.groupedData, data.legend],
);
return (
<Container style={style}>
{(width, height) => {
const compact = height <= 300 || width <= 300;
const { chartInnerRadius, chartMidRadius, chartOuterRadius, compact } =
getDonutDimensions(width, height, isCategoryGroup);
const showActiveShape = width >= 220 && height >= 130;
// ---------------------------------------------------------------
// Two-ring concentric donut (CategoryGroup mode)
// ---------------------------------------------------------------
if (isCategoryGroup) {
return (
data.groupedData && (
<div>
{!compact && <div style={{ marginTop: '15px' }} />}
<PieChart
responsive
width={width}
height={height}
style={{ cursor: pointer }}
>
{/* Inner ring — Category Groups */}
<Pie
dataKey={val => getVal(val)}
nameKey="name"
{...animationProps}
data={adjustedGroupData}
innerRadius={chartInnerRadius}
outerRadius={chartMidRadius}
startAngle={90}
endAngle={-270}
shape={(props: PieSectorShapeProps, index: number) => {
const item = adjustedGroupData[index];
const fill =
colorMap.get(item?.id ?? item?.name ?? '') ??
props.fill;
const isActive =
activeRing === 'group' && index === activeGroupIndex;
if (isActive && showActiveShape) {
return compact ? (
<ActiveShapeMobile
{...(props as unknown as ActiveShapeProps)}
fill={fill}
expandInward
chartInnerRadius={chartInnerRadius}
chartMidRadius={chartMidRadius}
chartOuterRadius={chartOuterRadius}
/>
) : (
<ActiveShapeDesktop
{...(props as unknown as ActiveShapeProps)}
fill={fill}
expandInward
chartInnerRadius={chartInnerRadius}
chartMidRadius={chartMidRadius}
chartOuterRadius={chartOuterRadius}
/>
);
}
return <Sector {...props} fill={fill} />;
}}
onMouseLeave={() => setPointer('')}
onMouseEnter={(_, index) => {
if (canDeviceHover()) {
setActiveGroupIndex(index);
setActiveRing('group');
}
}}
onClick={(item, index) => {
if (!canDeviceHover()) {
setActiveGroupIndex(index);
setActiveRing('group');
}
if (
(canDeviceHover() || activeGroupIndex === index) &&
((compact && showTooltip) || !compact)
) {
const groupCategoryIds = (
data.groupedData?.find(g => g.id === item.id)
?.categories ?? []
)
.map(c => c.id)
.filter((c): c is string => c != null);
showActivity({
navigate,
categories,
accounts,
balanceTypeOp,
filters,
showHiddenCategories,
showOffBudget,
type: 'totals',
startDate: data.startDate,
endDate: data.endDate,
field: 'category',
id: groupCategoryIds,
});
}
}}
/>
{/* Outer ring — Categories */}
<Pie
dataKey={val => getVal(val)}
nameKey="name"
{...animationProps}
data={flatCategories}
innerRadius={chartMidRadius}
outerRadius={chartOuterRadius}
startAngle={90}
endAngle={-270}
labelLine={false}
label={e =>
viewLabels && !compact ? customLabel(e) : null
}
shape={(props: PieSectorShapeProps, index: number) => {
const item = flatCategories[index];
const fill =
colorMap.get(item?.id ?? item?.name ?? '') ??
props.fill;
const isActive =
activeRing === 'category' &&
index === activeCategoryIndex;
if (isActive && showActiveShape) {
return compact ? (
<ActiveShapeMobile
{...(props as unknown as ActiveShapeProps)}
fill={fill}
expandInward={false}
chartInnerRadius={chartInnerRadius}
chartMidRadius={chartMidRadius}
chartOuterRadius={chartOuterRadius}
/>
) : (
<ActiveShapeDesktop
{...(props as unknown as ActiveShapeProps)}
fill={fill}
expandInward={false}
chartInnerRadius={chartInnerRadius}
chartMidRadius={chartMidRadius}
chartOuterRadius={chartOuterRadius}
/>
);
}
return <Sector {...props} fill={fill} />;
}}
onMouseLeave={() => setPointer('')}
onMouseEnter={(_, index) => {
if (canDeviceHover()) {
setActiveCategoryIndex(index);
setActiveRing('category');
setPointer('pointer');
}
}}
onClick={(item, index) => {
if (!canDeviceHover()) {
setActiveCategoryIndex(index);
setActiveRing('category');
}
if (
(canDeviceHover() || activeCategoryIndex === index) &&
((compact && showTooltip) || !compact)
) {
showActivity({
navigate,
categories,
accounts,
balanceTypeOp,
filters,
showHiddenCategories,
showOffBudget,
type: 'totals',
startDate: data.startDate,
endDate: data.endDate,
field: 'category',
id: item.id,
});
}
}}
/>
</PieChart>
</div>
)
);
}
// ---------------------------------------------------------------
// Single-ring donut (all other groupBy modes)
// ---------------------------------------------------------------
return (
data[splitData] && (
<div>
@@ -285,13 +627,8 @@ export function DonutGraph({
dataKey={val => getVal(val)}
nameKey={yAxis}
{...animationProps}
data={
data[splitData]?.map(item => ({
...item,
})) ?? []
}
innerRadius={Math.min(width, height) * 0.2}
fill="#8884d8"
data={data[splitData]?.map(item => ({ ...item })) ?? []}
innerRadius={chartInnerRadius}
labelLine={false}
label={e =>
viewLabels && !compact ? customLabel(e) : <div />
@@ -299,15 +636,28 @@ export function DonutGraph({
startAngle={90}
endAngle={-270}
shape={(props: PieSectorShapeProps, index: number) => {
const fill = data.legend[index]?.color ?? props.fill;
const showActiveShape = width >= 220 && height >= 130;
const isActive = props.isActive || index === activeIndex;
// Fix 3: optional chain data.legend to guard against undefined
const fill = data.legend?.[index]?.color ?? props.fill;
const isActive = index === activeIndex;
if (isActive && showActiveShape) {
const shapeProps = { ...props, fill, format };
return compact ? (
<ActiveShapeMobileWithFormat {...shapeProps} />
<ActiveShapeMobile
{...(props as unknown as ActiveShapeProps)}
fill={fill}
expandInward
chartInnerRadius={chartInnerRadius}
chartMidRadius={chartMidRadius}
chartOuterRadius={chartOuterRadius}
/>
) : (
<ActiveShapeWithFormat {...shapeProps} />
<ActiveShapeDesktop
{...(props as unknown as ActiveShapeProps)}
fill={fill}
expandInward={false}
chartInnerRadius={chartInnerRadius}
chartMidRadius={chartMidRadius}
chartOuterRadius={chartOuterRadius}
/>
);
}
return <Sector {...props} fill={fill} />;
@@ -325,12 +675,21 @@ export function DonutGraph({
if (!canDeviceHover()) {
setActiveIndex(index);
}
if (
!['Group', 'Interval'].includes(groupBy) &&
!['Interval'].includes(groupBy) &&
(canDeviceHover() || activeIndex === index) &&
((compact && showTooltip) || !compact)
) {
const groupCategoryIds =
groupBy === 'Group'
? (
categories.grouped.find(g => g.id === item.id)
?.categories ?? []
)
.map(c => c.id)
.filter((c): c is string => c != null)
: undefined;
showActivity({
navigate,
categories,
@@ -342,17 +701,15 @@ export function DonutGraph({
type: 'totals',
startDate: data.startDate,
endDate: data.endDate,
field: groupBy.toLowerCase(),
id: item.id,
field:
groupBy === 'Group'
? 'category'
: groupBy.toLowerCase(),
id: groupBy === 'Group' ? groupCategoryIds : item.id,
});
}
}}
/>
<Tooltip
content={() => null}
defaultIndex={activeIndex}
active
/>
</PieChart>
</div>
)

View File

@@ -23,7 +23,7 @@ type showActivityProps = {
startDate: string;
endDate?: string;
field?: string;
id?: string;
id?: string | string[]; // changed: supports array for oneOf
interval?: string;
};
@@ -55,7 +55,13 @@ export function showActivity({
const filterConditions = [
...filters,
id && { field, op: 'is', value: id, type: 'id' },
id && {
// changed: use oneOf when id is an array, is when it's a string
field,
op: Array.isArray(id) ? 'oneOf' : 'is',
value: id,
type: 'id',
},
{
field: 'date',
op: isDateOp ? 'gte' : 'is',
@@ -97,6 +103,7 @@ export function showActivity({
type: 'id',
},
].filter(f => f);
void navigate('/accounts', {
state: {
goBack: true,