Skip to content

Implement Sankey report for spent and budgeted money#7220

Open
emiltb wants to merge 83 commits intoactualbudget:masterfrom
emiltb:sankey-report
Open

Implement Sankey report for spent and budgeted money#7220
emiltb wants to merge 83 commits intoactualbudget:masterfrom
emiltb:sankey-report

Conversation

@emiltb
Copy link

@emiltb emiltb commented Mar 16, 2026

Description

This PR adds a Sankey chart report that can visualise the money flow in two ways: spent and budgeted. It shows either the total spend amount for a time period and how it flows through categories and subcategories, or similarly for the budgeted amount how funds are allocated. This is a new way to visualise money flows, making it easy for users to compare the relative amounts they are spending or allocating in their budgets.

I derived this from the previous work in #6068. Notable changes from that PR are:

  • Made it possible to select date for a time range instead of only a single month.
  • Fixed the SankeyCard, which did not display data in all cases.
  • Removed the "Difference" section that was introduced in the previous PR, as the results from it were confusing in real budgets were funds are allocated across several months for large expenses (e.g. paying a mortgage every three months, but budgeting for it monthly). I'm open to reintroducing this if we can find a good way to do it, but did not like it in its current form.
  • Reduced clutter in the graphs by removing all data about Income amounts. This removes a level of 'nesting' in the chart, while providing basically the same info.
  • Now sorts categories and subcategories by amounts, to make the largest amounts appear towards the top of the graph.

Spent view:
2026-03-16T20:40:35,993104676+01:00

Budgeted view:
2026-03-16T20:40:50,521638159+01:00

I'm looking for feedback and ideas on what to improve from here on. One specific thing that should be improved, that I would like to discuss the implementation of is budgets with many small categories, which clutter the view. Ideally we should take the smallest subcategories and lump them together in an 'Other' category, which could show what data they contain in a tooltip. However, I'm unsure what the best approach is to select which categories goes into 'Other'. Options include:

  • Top-N + "Other": Show the largest N categories and group all smaller ones into an "Other" category.
  • Threshold-Based: Only display categories above a certain absolute amount or percentage of total spending, lumping the rest into "Other."
  • Statistical Cutoff: Include categories larger than a statistical measure (e.g., median or mean or some fraction thereof), combining smaller ones into "Other."

Related issue(s)

This fixes #1716, which has been addressed several times (#1919, #4156 and lately in #6068). I've continued the recent branch from #6068 and provided my own take on some of the outstanding issues.

Testing

I tested the visuals of the graph using the provided test budget in Actual and using my own Budget (with significantly more data). I also updated the test added previously in #6068 to reflect my changes, but I'm unsure how to update and run the visual tests, and was unable to figure that out from the Actual docs.

Checklist

  • Release notes added (see link above)
  • No obvious regressions in affected areas
  • Self-review has been performed - I understand what each change in the code does and why it is needed

Bundle Stats

Bundle Files count Total bundle size % Changed
desktop-client 27 12.11 MB → 12.18 MB (+72.8 kB) +0.59%
loot-core 1 4.83 MB → 4.83 MB (+18 B) +0.00%
api 4 4.06 MB → 4.06 MB (+17 B) +0.00%
cli 1 7.88 MB 0%
View detailed bundle stats

desktop-client

Total

Files count Total bundle size % Changed
27 12.11 MB → 12.18 MB (+72.8 kB) +0.59%
Changeset
File Δ Size
node_modules/recharts/es6/chart/Sankey.js 🆕 +23.63 kB 0 B → 23.63 kB
src/components/reports/reports/Sankey.tsx 🆕 +22.96 kB 0 B → 22.96 kB
src/components/reports/spreadsheets/sankey-spreadsheet.ts 🆕 +12.82 kB 0 B → 12.82 kB
src/components/reports/graphs/SankeyGraph.tsx 🆕 +9.08 kB 0 B → 9.08 kB
src/components/reports/reports/SankeyCard.tsx 🆕 +7.14 kB 0 B → 7.14 kB
node_modules/es-toolkit/dist/function/debounce.js 🆕 +1.53 kB 0 B → 1.53 kB
node_modules/es-toolkit/dist/compat/function/debounce.js 🆕 +1.26 kB 0 B → 1.26 kB
node_modules/es-toolkit/dist/compat/math/sumBy.js 🆕 +668 B 0 B → 668 B
node_modules/es-toolkit/dist/array/maxBy.js 🆕 +566 B 0 B → 566 B
node_modules/es-toolkit/dist/compat/math/maxBy.js 🆕 +506 B 0 B → 506 B
node_modules/es-toolkit/dist/compat/function/throttle.js 🆕 +502 B 0 B → 502 B
node_modules/es-toolkit/compat/throttle.js 🆕 +187 B 0 B → 187 B
node_modules/es-toolkit/compat/maxBy.js 🆕 +175 B 0 B → 175 B
node_modules/es-toolkit/compat/sumBy.js 🆕 +175 B 0 B → 175 B
home/runner/work/actual/actual/packages/component-library/src/icons/v1/List.tsx 🆕 +424 B 0 B → 424 B
node_modules/recharts/es6/component/ResponsiveContainer.js 📈 +6.28 kB (+2115.79%) 304 B → 6.58 kB
node_modules/recharts/es6/component/responsiveContainerUtils.js 📈 +1.82 kB (+752.63%) 247 B → 2.06 kB
node_modules/recharts/es6/context/chartDataContext.js 📈 +285 B (+62.50%) 456 B → 741 B
src/hooks/useFeatureFlag.ts 📈 +60 B (+12.10%) 496 B → 556 B
src/components/reports/ReportRouter.tsx 📈 +556 B (+10.54%) 5.15 kB → 5.7 kB
node_modules/recharts/es6/context/chartLayoutContext.js 📈 +204 B (+7.29%) 2.73 kB → 2.93 kB
src/components/reports/Overview.tsx 📈 +670 B (+2.70%) 24.23 kB → 24.89 kB
src/components/settings/Experimental.tsx 📈 +238 B (+2.35%) 9.87 kB → 10.11 kB
node_modules/recharts/es6/chart/PieChart.js 📈 +24 B (+1.19%) 1.96 kB → 1.99 kB
node_modules/recharts/es6/chart/PolarChart.js 📈 +20 B (+0.63%) 3.08 kB → 3.1 kB
node_modules/recharts/es6/shape/Trapezoid.js 📈 +17 B (+0.28%) 5.97 kB → 5.99 kB
node_modules/recharts/es6/shape/Symbols.js 📈 +6 B (+0.14%) 4.26 kB → 4.27 kB
src/components/reports/ReportLegend.tsx 📈 +2 B (+0.13%) 1.53 kB → 1.53 kB
src/components/accounts/AccountEmptyMessage.tsx 📈 +2 B (+0.08%) 2.47 kB → 2.47 kB
node_modules/recharts/es6/cartesian/XAxis.js 📈 +4 B (+0.06%) 6.48 kB → 6.49 kB
src/components/reports/spreadsheets/net-worth-spreadsheet.ts 📈 +2 B (+0.04%) 5 kB → 5 kB
src/components/reports/reports/BudgetAnalysis.tsx 📈 +7 B (+0.04%) 19 kB → 19.01 kB
src/components/reports/reports/NetWorthCard.tsx 📈 +2 B (+0.03%) 7.65 kB → 7.65 kB
src/components/reports/graphs/BarGraph.tsx 📈 +2 B (+0.02%) 10.77 kB → 10.78 kB
src/components/reports/SaveReport.tsx 📈 +2 B (+0.02%) 12.55 kB → 12.55 kB
src/components/reports/reports/NetWorth.tsx 📈 +2 B (+0.01%) 13.55 kB → 13.55 kB
locale/en.json 📉 -2 B (-0.00%) 170.76 kB → 170.76 kB
locale/fr.json 📉 -141 B (-0.08%) 177.61 kB → 177.47 kB
locale/ca.json 📉 -2.65 kB (-1.43%) 185.57 kB → 182.91 kB
locale/es.json 📉 -2.71 kB (-1.47%) 184.81 kB → 182.09 kB
locale/pt-BR.json 📉 -2.67 kB (-1.48%) 180.5 kB → 177.84 kB
locale/de.json 📉 -2.79 kB (-1.57%) 177.58 kB → 174.79 kB
locale/it.json 📉 -2.72 kB (-1.61%) 168.97 kB → 166.25 kB
locale/nb-NO.json 📉 -2.51 kB (-1.63%) 154.72 kB → 152.2 kB
locale/nl.json 📉 -2.65 kB (-2.37%) 111.58 kB → 108.93 kB
View detailed bundle breakdown

Added
No assets were added

Removed
No assets were removed

Bigger

Asset File Size % Changed
static/js/ReportRouter.js 1.02 MB → 1.1 MB (+78.96 kB) +7.55%
static/js/index.js 3.23 MB → 3.24 MB (+12.27 kB) +0.37%
static/js/useTransactionBatchActions.js 4.29 MB → 4.29 MB (+424 B) +0.01%

Smaller

Asset File Size % Changed
static/js/de.js 177.58 kB → 174.79 kB (-2.79 kB) -1.57%
static/js/it.js 168.97 kB → 166.25 kB (-2.72 kB) -1.61%
static/js/es.js 184.81 kB → 182.09 kB (-2.71 kB) -1.47%
static/js/pt-BR.js 180.5 kB → 177.84 kB (-2.67 kB) -1.48%
static/js/ca.js 185.57 kB → 182.91 kB (-2.65 kB) -1.43%
static/js/nl.js 111.58 kB → 108.93 kB (-2.65 kB) -2.37%
static/js/nb-NO.js 154.72 kB → 152.2 kB (-2.51 kB) -1.63%
static/js/fr.js 177.61 kB → 177.47 kB (-141 B) -0.08%
static/js/en.js 170.76 kB → 170.76 kB (-2 B) -0.00%

Unchanged

Asset File Size % Changed
static/js/BackgroundImage.js 119.98 kB 0%
static/js/FormulaEditor.js 846.44 kB 0%
static/js/TransactionList.js 81.29 kB 0%
static/js/da.js 104.66 kB 0%
static/js/en-GB.js 7.16 kB 0%
static/js/indexeddb-main-thread-worker-e59fee74.js 13.46 kB 0%
static/js/narrow.js 354.27 kB 0%
static/js/pl.js 88.34 kB 0%
static/js/resize-observer.js 18.03 kB 0%
static/js/sv.js 80.58 kB 0%
static/js/th.js 179.94 kB 0%
static/js/theme.js 30.68 kB 0%
static/js/uk.js 213.14 kB 0%
static/js/wide.js 418 B 0%
static/js/workbox-window.prod.es5.js 7.28 kB 0%

loot-core

Total

Files count Total bundle size % Changed
1 4.83 MB → 4.83 MB (+18 B) +0.00%
Changeset
File Δ Size
home/runner/work/actual/actual/packages/loot-core/src/server/dashboard/app.ts 📈 +18 B (+0.24%) 7.41 kB → 7.43 kB
View detailed bundle breakdown

Added

Asset File Size % Changed
kcab.worker.TQbAx9By.js 0 B → 4.83 MB (+4.83 MB) -

Removed

Asset File Size % Changed
kcab.worker.Bq2rqD2u.js 4.83 MB → 0 B (-4.83 MB) -100%

Bigger
No assets were bigger

Smaller
No assets were smaller

Unchanged
No assets were unchanged


api

Total

Files count Total bundle size % Changed
4 4.06 MB → 4.06 MB (+17 B) +0.00%
Changeset
File Δ Size
home/runner/work/actual/actual/packages/loot-core/src/server/dashboard/app.ts 📈 +17 B (+0.23%) 7.27 kB → 7.29 kB
View detailed bundle breakdown

Added
No assets were added

Removed
No assets were removed

Bigger

Asset File Size % Changed
index.js 3.84 MB → 3.84 MB (+17 B) +0.00%

Smaller
No assets were smaller

Unchanged

Asset File Size % Changed
from-Bl-Hslp4.js 167.73 kB 0%
multipart-parser-BnDysoMr.js 8.1 kB 0%
src-iMkUmuwR.js 43.64 kB 0%

cli

Total

Files count Total bundle size % Changed
1 7.88 MB 0%
View detailed bundle breakdown

Added
No assets were added

Removed
No assets were removed

Bigger
No assets were bigger

Smaller
No assets were smaller

Unchanged

Asset File Size % Changed
cli.js 7.88 MB 0%

andrewhumble and others added 30 commits November 4, 2025 10:24
Auto-generated by VRT workflow

PR: actualbudget#6068
Auto-generated by VRT workflow

PR: actualbudget#6068
Auto-generated by VRT workflow

PR: actualbudget#6068
Auto-generated by VRT workflow

PR: actualbudget#6068
Now better conforms with components from other reports, e.g. by reusing Header
Makes it possible to display a period longer than one month.
@youngcw
Copy link
Member

youngcw commented Mar 19, 2026

Could there be an "other" endpoint per group instead of one global other?

@emiltb
Copy link
Author

emiltb commented Mar 19, 2026

Could there be an "other" endpoint per group instead of one global other?

Yes, it could be done either way.

I chose the single Other group to limit the number of nodes at the last layer. Each node needs some extra vertical space, so having more nodes ends up reducing the height of the nodes, which might be hard to discern on phone screens. I guess it's also a question of what to optimize most for.

However, the current solution causes the links to cross to reach the Other node, which might be more confusing and/or less pleasing to look at.

@emiltb
Copy link
Author

emiltb commented Mar 21, 2026

I've pushed quite a few updates this evening.

  • The tooltip for the 'Other' category that shows up on the SankeyCard on the Reports overview page will now also show which categories were collecting into Other.
  • Several options were added for the user to select how they prefer to view their data:
    • Select whether there is a single global Other category or an Other category pr. main category
    • Select the max number of subcategories + other categories to show
    • Select whether the sorting of subcategories is pr. main category or globally
  • Limit the filtering options in the header to Account and Category, as the remaining possibilities doesn't make too much sense for this graph. Note that Budgets are account-independent so filtering on accounts has no effect on the budget.

Per category Other + Per category sort
2026-03-21T22:40:14,123532985+01:00

Global Other + Global sort
2026-03-21T22:39:54,146847565+01:00

I think the Sankey report covers everything that I personally would like to see from it right now, but I'm of course very open to feedback.

@youngcw I'm unsure what the exact process is from here. Is there any chance that this could make it into the 2026.4.0 release as an experimental feature, so feedback could be gathered from a larger group of users?

@Wirsing84
Copy link

This looks really good, especially with color coding per category group! Thank you everyone for picking this feature up once more!!!

@youngcw
Copy link
Member

youngcw commented Mar 23, 2026

The "other" option button should read "per group other" not per category since its one other per group.

Do you have a preferred option on the other grouping? I feel like the per group option makes the most sense and maybe should be the only option.

@emiltb
Copy link
Author

emiltb commented Mar 23, 2026

The "other" option button should read "per group other" not per category since its one other per group.

Do you have a preferred option on the other grouping? I feel like the per group option makes the most sense and maybe should be the only option.

I think both approaches are valid and could suit the needs of individual users. My preferred option would probably be to simplify the choice and condense it to a single option of:

  • Sort All: Which sorts globally and collects into a single Other category
  • Sort Groups: Which sorts per group and has an Other category per group.

This could make sense since the other combinations (global sort + per group other, group sort + global other) aren't as clean and sensible.

@youngcw
Copy link
Member

youngcw commented Mar 23, 2026

The "other" option button should read "per group other" not per category since its one other per group.
Do you have a preferred option on the other grouping? I feel like the per group option makes the most sense and maybe should be the only option.

I think both approaches are valid and could suit the needs of individual users. My preferred option would probably be to simplify the choice and condense it to a single option of:

  • Sort All: Which sorts globally and collects into a single Other category
  • Sort Groups: Which sorts per group and has an Other category per group.

This could make sense since the other combinations (global sort + per group other, group sort + global other) aren't as clean and sensible.

I think that makes sense, one setting instead of two. It may also be good to add a sort option for budget order. That may be hard with the other endpoint though.

And the setting for sorting should read "sort per group". There aren't sub-categories in Actual, only categories and groups of categories.

@emiltb
Copy link
Author

emiltb commented Mar 24, 2026

I think that makes sense, one setting instead of two. It may also be good to add a sort option for budget order. That may be hard with the other endpoint though.

And the setting for sorting should read "sort per group". There aren't sub-categories in Actual, only categories and groups of categories.

I have condensed the sorting to a single option as discussed. It now has options of "Sort per group", "Sort all" and "Sort as budget".

For the "Sort as budget" it gets the ordering directly from the categories: CategoryGroupEntity[] that is already passed to createSpreadsheet() as that seems to have the right ordering. I choose to go with group-level Other nodes, which does mean that it is the categories at the bottom of the list in each group that might be collected into Other, which is of course not necessarily the most important ones, but I don't really see another way to do it. This way it represents the users budget as close as we can do. I guess they can reorder categories, if there are ones they never want to go to an Other group.

@youngcw
Copy link
Member

youngcw commented Mar 25, 2026

The only thing left I think is that the dashboard card still is weird. It looks good otherwise

@emiltb
Copy link
Author

emiltb commented Mar 25, 2026

The only thing left I think is that the dashboard card still is weird. It looks good otherwise

Can you clarify what looks weird and maybe share a screenshot? It looks fine when I test it.

Screenshot_20260325-164456.png

@youngcw
Copy link
Member

youngcw commented Mar 25, 2026

The dashboard card becomes useless when there are more than about 3 groups.
image

@emiltb
Copy link
Author

emiltb commented Mar 25, 2026

The dashboard card becomes useless when there are more than about 3 groups.

I have implemented a fix, which dynamically adjusts how many groups are shown on the card, dependent on the height of the card. It will now show 1 + Other if the height is very small and up to all groups, if there is room.

@youngcw
Copy link
Member

youngcw commented Mar 26, 2026

That looks better. Ill look over the code, but I think its ready.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] Report - Sankey diagram from group categories and categories

5 participants