Enhance Sankey chart datamodel, show income and allow layer filtering (#7582)

* Refactor to use directed, weighted graph as datamodel

* Fix percentage labels

* Reimplement sorting and topN handling

* Fix typing. Show toBudget on graph.

* Implement better DAG model

* Fix Other-grouping with new datamodel

* Add global sorting

* Reorder spreadsheet code for clarity

* Add percentageLabels back

* Fix all sorting modes

* Better color handling

* Handle if overbudgeted

* Fix filtering issue related to hidden nodes for Spent report

* Implement enums for special names

* Linting and typechecking

* Add layer selectors

* Trim SankeyCard

* Fix issue with empty nodes making the graph unreadable

* Add release note

* Update release note

* Reorder code

* Address coderabbit comments

* Ensure that layer-from and layer-to cannot be equal

* Update layer selectors to match selected view mode

* Fix wrong graph object reference

* Cap regex length

* Fixed wrong layer assignment for budget income categories

* Make translation not optional in createSpreadsheet

* Use predefined suffix for 'Other'

* Avoid invalid layer selection for Budgeted

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #7582

* Import translation in spreadsheet, instead of passing as argument

* Remove all non-null assertions and handle safely

* Fix most uses of 'as'

* Fix issues hiding Other categories and giving wrong toBudget value

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Emil Tveden Bjerglund
2026-04-24 20:20:15 +02:00
committed by GitHub
parent 227c995155
commit 686f10247d
8 changed files with 1355 additions and 689 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 119 KiB

View File

@@ -13,20 +13,22 @@ import {
} from 'recharts';
import type { SankeyData } from 'recharts/types/chart/Sankey';
import { getColorScale } from '#components/reports/chart-theme';
import { Container } from '#components/reports/Container';
import { useFormat } from '#hooks/useFormat';
import { usePrivacyMode } from '#hooks/usePrivacyMode';
type SankeyGraphNode = SankeyData['nodes'][number] & {
hasChildren?: boolean;
isCollapsed?: boolean;
toBudget?: number;
isNegative?: boolean;
actualValue?: number;
value: number;
percentageLabel?: string;
targetLinks?: Array<Record<string, unknown>>;
sourceLinks?: Array<Record<string, unknown>>;
key: string;
color?: string;
};
type SankeyLinkPayload = {
source: SankeyGraphNode;
target: SankeyGraphNode;
value: number;
color?: string;
};
type SankeyLinkProps = {
@@ -38,16 +40,10 @@ type SankeyLinkProps = {
targetControlX: number;
linkWidth: number;
index: number;
payload: {
source: SankeyGraphNode;
target: SankeyGraphNode;
value: number;
isNegative?: boolean;
};
payload: SankeyLinkPayload;
isHovered: boolean;
onMouseEnter: () => void;
onMouseLeave: () => void;
color: string;
};
function SankeyLink({
@@ -62,9 +58,11 @@ function SankeyLink({
isHovered,
onMouseEnter,
onMouseLeave,
color,
}: SankeyLinkProps) {
const linkColor = payload.isNegative ? theme.errorText : color;
if (payload.value <= 0) {
return null;
}
const linkColor = payload.color ?? theme.reportsGray;
const strokeWidth = linkWidth;
const strokeOpacity = isHovered ? 1 : 0.6;
@@ -91,8 +89,8 @@ type SankeyNodeProps = {
index: number;
payload: SankeyGraphNode;
containerWidth: number;
containerHeight: number;
showPercentages?: boolean;
color?: string;
};
function SankeyNode({
x,
@@ -102,21 +100,17 @@ function SankeyNode({
index: _index,
payload,
containerWidth,
containerHeight,
showPercentages,
}: SankeyNodeProps) {
const privacyMode = usePrivacyMode();
const format = useFormat();
if (payload.value <= 0) {
return null;
}
const isOut = x + width + 6 > containerWidth;
const fillColor = payload.isNegative ? theme.errorText : theme.reportsBlue;
const toBudget = payload.toBudget ?? 0;
const availableBelow = Math.max(0, containerHeight - 25 - (y + height));
const proportionalHeight =
toBudget > 0 && payload.value ? height * (toBudget / payload.value) : 0;
const isClamped = proportionalHeight > availableBelow;
const toBudgetHeight = Math.min(proportionalHeight, availableBelow);
const fillColor = payload.color ?? theme.reportsBlue;
const renderText = (
text: string,
@@ -142,27 +136,6 @@ function SankeyNode({
return (
<Layer>
<Rectangle x={x} y={y} width={width} height={height} fill={fillColor} />
{toBudgetHeight > 0 &&
(isClamped ? (
<polygon
points={`
${x},${y + height}
${x + width},${y + height}
${x + width},${y + height + toBudgetHeight - 8}
${x + width / 2},${y + height + toBudgetHeight}
${x},${y + height + toBudgetHeight - 8}
`}
fill={theme.toBudgetPositive}
/>
) : (
<Rectangle
x={x}
y={y + height}
width={width}
height={toBudgetHeight}
fill={theme.toBudgetPositive}
/>
))}
{renderText(payload.name || '', height / 2)}
{renderText(
showPercentages && payload.percentageLabel
@@ -173,24 +146,6 @@ function SankeyNode({
0.5,
privacyMode ? t('Redacted Script') : undefined,
)}
{toBudgetHeight > 0 &&
renderText(
format(toBudget, 'financial'),
toBudgetHeight / 2 + 13,
11,
0.5,
privacyMode ? t('Redacted Script') : undefined,
y + height,
)}
{toBudgetHeight > 0 &&
renderText(
t('To budget'),
toBudgetHeight / 2,
13,
1,
undefined,
y + height,
)}
</Layer>
);
}
@@ -199,7 +154,6 @@ type SankeyGraphProps = {
style?: CSSProperties;
data: SankeyData;
showTooltip?: boolean;
collapsedNodes?: string[];
showPercentages?: boolean;
};
export function SankeyGraph({
@@ -212,19 +166,6 @@ export function SankeyGraph({
const format = useFormat();
const [hoveredLinkIndex, setHoveredLinkIndex] = useState<number | null>(null);
const colors = getColorScale('qualitative');
const sourceColorMap = new Map(
[
...new Set(
data.links
.filter(l => (l.source as number) !== 0)
.map(l => data.nodes[l.source as number]?.name),
),
]
.filter(Boolean)
.map((name, i) => [name, colors[i % colors.length]]),
);
return (
<Container style={style}>
{(width, height) => (
@@ -235,7 +176,6 @@ export function SankeyGraph({
<SankeyNode
{...props}
containerWidth={width}
containerHeight={height}
showPercentages={showPercentages}
/>
)}
@@ -245,10 +185,6 @@ export function SankeyGraph({
isHovered={hoveredLinkIndex === props.index}
onMouseEnter={() => setHoveredLinkIndex(props.index)}
onMouseLeave={() => setHoveredLinkIndex(null)}
color={
sourceColorMap.get(props.payload.source.name) ??
theme.reportsGray
}
/>
)}
sort={false}

View File

@@ -4,7 +4,13 @@ import { useParams } from 'react-router';
import { Button } from '@actual-app/components/button';
import { useResponsive } from '@actual-app/components/hooks/useResponsive';
import { SvgArrowDown, SvgList } from '@actual-app/components/icons/v1';
import {
SvgArrowDown,
SvgCheveronRight,
SvgLayers,
SvgList,
SvgRefresh,
} from '@actual-app/components/icons/v1';
import { Menu } from '@actual-app/components/menu';
import { Paragraph } from '@actual-app/components/paragraph';
import { Popover } from '@actual-app/components/popover';
@@ -32,8 +38,9 @@ import { LoadingIndicator } from '#components/reports/LoadingIndicator';
import { ModeButton } from '#components/reports/ModeButton';
import { calculateTimeRange } from '#components/reports/reportRanges';
import {
GRAPH_LAYER_ORDER,
GraphLayers,
createSpreadsheet as sankeySpreadsheet,
withPercentageLabels,
} from '#components/reports/spreadsheets/sankey-spreadsheet';
import { useReport } from '#components/reports/useReport';
import { fromDateRepr } from '#components/reports/util';
@@ -166,6 +173,91 @@ function CategorySortSelector({ value, onChange }: CategorySortSelectorProps) {
);
}
type LayerSelectorProps = {
direction: 'from' | 'to';
value: GraphLayers;
otherLayer: GraphLayers | undefined;
onChange: (layer: GraphLayers) => void;
graphMode: GraphMode;
};
function LayerSelector({
direction,
value,
otherLayer,
onChange,
graphMode,
}: LayerSelectorProps) {
const { t } = useTranslation();
const triggerRef = useRef<HTMLButtonElement | null>(null);
const [isOpen, setIsOpen] = useState(false);
const LAYER_LABELS: Record<GraphLayers, string> = {
[GraphLayers.IncomePayee]: t('Payee'),
[GraphLayers.IncomeCategory]: t('Income category'),
[GraphLayers.Account]: t('Account'),
[GraphLayers.Budget]: t('Budget'),
[GraphLayers.CategoryGroup]: t('Category group'),
[GraphLayers.Category]: t('Category'),
};
// Filter available layers based on graph mode
const availableLayers: readonly GraphLayers[] =
graphMode === 'budgeted'
? GRAPH_LAYER_ORDER.filter(
layer => layer !== GraphLayers.IncomePayee, // IncomePayee not available in budgeted
)
: GRAPH_LAYER_ORDER.filter(
layer => layer !== GraphLayers.Budget, // Budget not available in spent
);
const otherIndex =
otherLayer !== undefined && availableLayers.includes(otherLayer)
? availableLayers.indexOf(otherLayer)
: direction === 'from'
? availableLayers.length - 1
: 0;
const menuItems =
direction === 'from'
? availableLayers.slice(0, otherIndex)
: availableLayers.slice(otherIndex + 1);
const translatedDirection = direction === 'from' ? t('from') : t('to');
return (
<>
<Button
ref={triggerRef}
variant="bare"
onPress={() => setIsOpen(true)}
aria-label={t('Change layer {{direction}}', {
direction: translatedDirection,
})}
>
<span style={{ marginLeft: 5 }}>{LAYER_LABELS[value]}</span>
</Button>
<Popover
triggerRef={triggerRef}
placement="bottom start"
isOpen={isOpen}
onOpenChange={() => setIsOpen(false)}
>
<Menu
onMenuSelect={item => {
onChange(item as GraphLayers);
setIsOpen(false);
}}
items={menuItems.map(layer => ({
name: layer,
text: LAYER_LABELS[layer],
}))}
/>
</Popover>
</>
);
}
type GraphModeSelectorProps = {
mode: GraphMode;
onChange: (mode: GraphMode) => void;
@@ -296,6 +388,62 @@ function SankeyInner({ widget }: SankeyInnerProps) {
widget?.meta?.showPercentages ?? false,
);
// Determine default layer based on mode
const defaultLayerFrom = (mode: GraphMode) =>
mode === 'budgeted' ? GraphLayers.IncomeCategory : GraphLayers.IncomePayee;
const [layerFrom, setLayerFrom] = useState<GraphLayers>(() => {
const metaLayer = widget?.meta?.layerFrom as GraphLayers | undefined;
if (metaLayer) {
// Validate that the layer is valid for the current mode
const mode = widget?.meta?.mode ?? 'spent';
if (mode === 'budgeted' && metaLayer === GraphLayers.IncomePayee) {
return defaultLayerFrom('budgeted');
}
if (mode === 'spent' && metaLayer === GraphLayers.Budget) {
return defaultLayerFrom('spent');
}
return metaLayer;
}
return defaultLayerFrom(widget?.meta?.mode ?? 'spent');
});
const [layerTo, setLayerTo] = useState<GraphLayers>(() => {
const metaLayer = widget?.meta?.layerTo as GraphLayers | undefined;
if (metaLayer) {
// Validate that the layer is valid for the current mode
const mode = widget?.meta?.mode ?? 'spent';
if (mode === 'budgeted' && metaLayer === GraphLayers.IncomePayee) {
return GraphLayers.Category;
}
if (mode === 'spent' && metaLayer === GraphLayers.Budget) {
return GraphLayers.Category;
}
return metaLayer;
}
return GraphLayers.Category;
});
// Reset invalid layer selections when switching modes
useEffect(() => {
const availableLayers =
graphMode === 'budgeted'
? (GRAPH_LAYER_ORDER.filter(
layer => layer !== GraphLayers.IncomePayee,
) as GraphLayers[])
: (GRAPH_LAYER_ORDER.filter(
layer => layer !== GraphLayers.Budget,
) as GraphLayers[]);
const fromIndex = availableLayers.indexOf(layerFrom);
const toIndex = availableLayers.indexOf(layerTo);
if (fromIndex === -1 || toIndex === -1 || fromIndex >= toIndex) {
setLayerFrom(defaultLayerFrom(graphMode));
setLayerTo(GraphLayers.Category);
}
}, [graphMode, layerFrom, layerTo]);
const { data: { grouped: groupedCategories = [] } = { grouped: [] } } =
useCategories();
@@ -313,6 +461,8 @@ function SankeyInner({ widget }: SankeyInnerProps) {
graphMode,
topNcategories,
categorySort,
layerFrom,
layerTo,
);
}, [
datesInitialized,
@@ -324,6 +474,8 @@ function SankeyInner({ widget }: SankeyInnerProps) {
graphMode,
topNcategories,
categorySort,
layerFrom,
layerTo,
]);
const defaultGetData = async (
@@ -418,6 +570,8 @@ function SankeyInner({ widget }: SankeyInnerProps) {
topNcategories,
categorySort,
showPercentages,
layerFrom,
layerTo,
timeFrame: {
start,
end,
@@ -549,6 +703,46 @@ function SankeyInner({ widget }: SankeyInnerProps) {
value={categorySort}
onChange={setCategorySort}
/>
<View
style={{
width: 1,
height: 28,
backgroundColor: theme.pillBorderDark,
marginRight: 10,
marginLeft: 10,
}}
/>
<SvgLayers style={{ width: 12, height: 12 }} />
<LayerSelector
direction="from"
value={layerFrom}
otherLayer={layerTo}
onChange={setLayerFrom}
graphMode={graphMode}
/>
<SvgCheveronRight style={{ width: 12, height: 12 }} />
<LayerSelector
direction="to"
value={layerTo}
otherLayer={layerFrom}
onChange={setLayerTo}
graphMode={graphMode}
/>
<Button
variant="bare"
onPress={() => {
if (graphMode === 'budgeted') {
setLayerFrom(GraphLayers.IncomeCategory);
setLayerTo(GraphLayers.Category);
} else {
setLayerFrom(GraphLayers.IncomePayee);
setLayerTo(GraphLayers.Category);
}
}}
aria-label={t('Reset layers')}
>
<SvgRefresh style={{ width: 12, height: 12 }} />
</Button>
</>
}
>
@@ -606,11 +800,7 @@ function SankeyInner({ widget }: SankeyInnerProps) {
{data && data.links && data.links.length > 0 ? (
<SankeyGraph
style={{ flexGrow: 1 }}
data={
showPercentages
? withPercentageLabels(data as SankeyData)
: (data as SankeyData)
}
data={data}
showPercentages={showPercentages}
/>
) : (

View File

@@ -14,9 +14,9 @@ import { ReportCard } from '#components/reports/ReportCard';
import { ReportCardName } from '#components/reports/ReportCardName';
import { calculateTimeRange } from '#components/reports/reportRanges';
import {
compactSankeyData,
GraphLayers,
// compactSankeyData,
createSpreadsheet as sankeySpreadsheet,
withPercentageLabels,
} from '#components/reports/spreadsheets/sankey-spreadsheet';
import { useDashboardWidgetCopyMenu } from '#components/reports/useDashboardWidgetCopyMenu';
import { useReport } from '#components/reports/useReport';
@@ -56,6 +56,13 @@ export function SankeyCard({
setCardHeight(rect.height);
});
const HEADER_HEIGHT = 82;
const PX_PER_NODE = 50;
const topN = Math.max(
2,
Math.floor((cardHeight - HEADER_HEIGHT) / PX_PER_NODE),
);
const params = useMemo(
() =>
sankeySpreadsheet(
@@ -65,22 +72,25 @@ export function SankeyCard({
meta?.conditions ?? [],
meta?.conditionsOp ?? 'and',
mode,
topN,
meta?.categorySort,
GraphLayers.IncomePayee,
GraphLayers.CategoryGroup,
),
[start, end, groupedCategories, meta?.conditions, meta?.conditionsOp, mode],
[
start,
end,
groupedCategories,
meta?.conditions,
meta?.conditionsOp,
mode,
topN,
meta?.categorySort,
],
);
const data = useReport('sankey', params);
const HEADER_HEIGHT = 82;
const PX_PER_NODE = 50;
const topN = Math.max(
2,
Math.floor((cardHeight - HEADER_HEIGHT) / PX_PER_NODE),
);
const compactData = useMemo(
() => (data ? compactSankeyData(data, topN) : null),
[data, topN],
);
const compactData = useMemo(() => data, [data]);
const startDate = d.parseISO(start);
const endDate = d.parseISO(end);
@@ -154,11 +164,7 @@ export function SankeyCard({
{compactData ? (
<SankeyGraph
data={
meta?.showPercentages
? withPercentageLabels(compactData)
: compactData
}
data={compactData}
showPercentages={meta?.showPercentages}
showTooltip={!isEditing}
style={{ height: 'auto', flex: 1 }}

View File

@@ -223,5 +223,7 @@ export type SankeyWidget = AbstractWidget<
topNcategories?: number;
categorySort?: 'per-group' | 'global' | 'budget-order';
showPercentages?: boolean;
layerFrom?: string;
layerTo?: string;
} | null
>;

View File

@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [emiltb]
---
Optimize Sankey chart datamodel to include income sources, allow layer filtering and better budget handling