Compare commits

...

7 Commits

Author SHA1 Message Date
youngcw
0412491bd1 Revert "Fix yarn generate:icons command (#7281)"
This reverts commit e5bca59bc6.
2026-04-05 07:29:25 -06:00
L. Warren Thompson
2ccdf0f6e8 Fix updateTransaction corrupting split parents with partial updates (#7242)
* [AI] Fix updateTransaction corrupting split parents with partial updates

When `api.updateTransaction(id, { notes: '...' })` is called on a split
parent, the `updateTransaction` helper replaces the parent with the
sparse update object (`{ id, notes }`) instead of merging it with
the existing transaction data.  This causes `recalculateSplit` to see
`amount` as `undefined` (→ 0), which doesn't match the children's
total and sets a `SplitTransactionError` on the parent.  `makeChild`
also inherits undefined `account`, `date`, and `cleared` values,
potentially creating broken child rows.

Fix: merge the incoming partial fields (`{ ...trans, ...transaction }`)
so all existing properties are preserved.

Add a test that performs a notes-only update on a split parent and
asserts no error is set and the amount stays intact.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* [AI] Add release notes for PR #7242

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Address review feedback: remove verbose comment and simplify release note

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: L. Warren Thompson <lwarrenthompson@Warren-MBP.local>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 08:01:06 +00:00
James Skinner
b96eec51ca Standardise ledger scrolling when using keyboard shortcuts (#7283)
* Standardise table keyboard navigation by preventing browser scroll with arrow keys

* Add release note

* Apply the preventDefault() in specific cases so that it is not applied to default

---------

Co-authored-by: youngcw <calebyoung94@gmail.com>
2026-04-05 08:00:58 +00:00
James Skinner
e5bca59bc6 Fix yarn generate:icons command (#7281)
* fix icon templates with `module.exports` to `export default`

* Add `@svgr/babel-plugin-add-jsx-attribute` to dependencies

* Run `yarn generate:icons`, and set prettier singleQuote to reduce changes

* Add release note

* Add temporary fix for `SvgChartArea`

* Add `ChartArea` svg from the existing tsx

* CI rerun
2026-04-05 08:00:56 +00:00
Emil Tveden Bjerglund
b756332583 Implement Sankey report for spent and budgeted money (#7220)
* Implement Sankey graph report

* Add release notes

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #6068

* Remove local debug settings

* [autofix.ci] apply automated fixes

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #6068

* Improve graphs from comments

* Fix lints

* coderabit fixes

* Fix filtering and UI enhancements

* remove pngs

* Fix typecheck

* Another type issue

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #6068

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #6068

* Fix strict typing issues

* Update report page

Now better conforms with components from other reports, e.g. by reusing Header
Makes it possible to display a period longer than one month.

* Change view description order

* Formatting and cleanup

* Removed difference section, as it will be difficult to get a reliable view across months

* Introduce the Timeframe param, similar to Spending report, to allow saving a Live sliding window.

* Allow filtering just the last month

* Fix linting errors

* Remove all information about income

* Remove debugging statement

* Sort categories and subcategories by amount

* Move compact mode to spreadsheet to fix Card view more easily

* Update tests file

* Add release notes

* Rename release notes to match PR#

* Fix autofix.ci issues

* Update packages/desktop-client/e2e/sankey.test.ts

Enable experimental feature fall all tests, pr. coderabbit recommendation

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

* Add sankey-card to isWidgetType

* Gate Sankey routes to prevent direct URL bypass

* Fix typo

* Change node transformation to work by key instead of name, to remove risk of duplicate issues

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

* Prevent false-positive pass in month-change test.

* Translate mode to a proper label

* Fix message for empty data

* Enabled LoadingIndicator until data is ready

* Change card default mode

* More robust filtering

* Fixed issue with budgeted spreadsheet not using 'end' date

* Allow copying SankeyCard to dashboard

* Fix typing and linting issues

* Remove e2e tests

I cannot currently get them to pass, because I dont fully understand playwright and how they are supposed to work. I can see that they don't exist for other reports. We can add them later if required.

* Remove unecessary sankey reference

* Refactor spreadsheet

* Remove dead code from SankeyGraph

* Collect to Other if too many subcategories

* Edit wrong comment

* Linting and typechecking

* Show remaining amount to budget

* Hide description on narrow device

* Add visual clue if 'To budget' is larger than 'Budgeted' and would extend below the edge of the graph

* Add colors to the links

* Fix report card showing subcategories instead of main categories

* Add tooltip info to Other on SankeyCard

* Create globalOther flag and implement greedy category reduction algorithm

* Allow user to select between Global or Per category Other

* Allow user to choose number of subcategories to show

* Allow user to select how subcategories are sorted

* Fix budget filtering

* [autofix.ci] apply automated fixes

* Condense sorting and Other-grouping to one option

* Implement Sort as budget option

* Dynamically adjust topN based on SankeyCard height

* Remove old feature flags from previous PR

---------

Co-authored-by: andrewhumble <43395285+andrewhumble@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: youngcw <calebyoung94@gmail.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-04-05 08:00:55 +00:00
Juulz
e1a4fde2f3 [Doc] More tour image (mostly) updates & a hotkey fix (#7328)
* Fix keyboard shortcut Mac key for undo operations

Updated keyboard shortcut instructions for Mac & make consistent.

* Add files via upload

* Fix undo shortcut from 'K' to 'Z'

Updated keyboard shortcut for undo operation in payees guide. COFFEE!

* Revise budget section for clarity and consistency

Updated category descriptions and improved Markdown support details.

* Add files via upload

* Fix grammatical error in budget.md

* Fix typo and clarify Markdown description in budget.md

Corrected a typo in the documentation regarding the chevrons and clarified the description of rendered Markdown.

* Fix spelling error in budget documentation

Corrected the spelling of 'cheverons' to 'chevrons'.

* Add files via upload

* Remove redundant text in budget.md

* Fix formatting issues in payees.md

* count points script should fetch the release note from the PR directly (#7309)

* get pr release note from PR, not top of master

* note

* [AI] Mobile: Post transaction today on global account lists (#7311) (#7322)

* [AI] Mobile: pass today for Post transaction today on global account lists (#7311)

All Accounts, On budget, and Off budget transaction lists now forward the
today flag to schedule/post-transaction, matching single-account mobile
and desktop behavior.

Made-with: Cursor

* [AI] Add release note for PR 7322 (#7311)

Made-with: Cursor

* [AI] Tighten release note wording for PR 7322 (imperative)

Made-with: Cursor

---------

Co-authored-by: Pranay Mac M1 <pranayseela@yahoo.com>

---------

Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>
Co-authored-by: Pranay S <pranayritvik@gmail.com>
Co-authored-by: Pranay Mac M1 <pranayseela@yahoo.com>
Co-authored-by: youngcw <calebyoung94@gmail.com>
2026-04-05 08:00:29 +00:00
Matt Fiddaman
af5c068991 trim down some unused/unnecessary dependencies (#7350)
* fix github actions inconsistencies

* fix pinning of transitive deps in eslint-plugin

* drop use of node-fetch in api

* drop md5 dependency in favour of node:crypto

* drop slash

* drop unused top level packages

* add note about node-polyfills warning

* remove unused deps from desktop-client

* drop pegjs types

* note

* drop node-jq
2026-04-05 08:00:22 +00:00
47 changed files with 1997 additions and 302 deletions

View File

@@ -84,7 +84,7 @@ jobs:
cli:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up environment
uses: ./.github/actions/setup
with:
@@ -96,12 +96,12 @@ jobs:
- name: Prepare bundle stats artifact
run: cp packages/cli/dist/stats.json cli-stats.json
- name: Upload Build
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: actual-cli
path: packages/cli/actual-cli.tgz
- name: Upload CLI bundle stats
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: cli-build-stats
path: cli-stats.json

View File

@@ -130,7 +130,7 @@ jobs:
path: head
allow_forks: true
- name: Download CLI build artifact from ${{github.base_ref}}
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
uses: dawidd6/action-download-artifact@1f8785ff7a5130826f848e7f72725c85d241860f # v18
with:
branch: ${{github.base_ref}}
workflow: build.yml
@@ -138,7 +138,7 @@ jobs:
name: cli-build-stats
path: base
- name: Download CLI stats from PR
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
uses: dawidd6/action-download-artifact@1f8785ff7a5130826f848e7f72725c85d241860f # v18
with:
pr: ${{github.event.pull_request.number}}
workflow: build.yml

View File

@@ -61,7 +61,6 @@
"install:server": "yarn workspaces focus @actual-app/sync-server --production",
"constraints": "yarn constraints",
"typecheck": "tsgo -p tsconfig.root.json --noEmit && lage typecheck",
"jq": "./node_modules/node-jq/bin/jq",
"prepare": "husky"
},
"devDependencies": {
@@ -70,7 +69,6 @@
"@types/prompts": "^2.4.9",
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
"@yarnpkg/types": "^4.0.1",
"baseline-browser-mapping": "^2.10.0",
"cross-env": "^10.1.0",
"eslint": "^9.39.3",
"eslint-plugin-perfectionist": "^5.6.0",
@@ -80,14 +78,12 @@
"lage": "^2.14.19",
"lint-staged": "^16.3.2",
"minimatch": "^10.2.4",
"node-jq": "^6.3.1",
"npm-run-all": "^4.1.5",
"oxfmt": "^0.32.0",
"oxlint": "^1.51.0",
"oxlint-tsgolint": "^0.13.0",
"p-limit": "^7.3.0",
"prompts": "^2.4.2",
"source-map-support": "^0.5.21",
"ts-node": "^10.9.2",
"typescript": "^5.9.3"
},

View File

@@ -1,8 +1,3 @@
import type {
RequestInfo as FetchInfo,
RequestInit as FetchInit,
} from 'node-fetch';
import { init as initLootCore } from '@actual-app/core/server/main';
import type { InitConfig, lib } from '@actual-app/core/server/main';
@@ -17,14 +12,6 @@ export let internal: typeof lib | null = null;
export async function init(config: InitConfig = {}) {
validateNodeVersion();
if (!globalThis.fetch) {
globalThis.fetch = (url: URL | RequestInfo, init?: RequestInit) => {
return import('node-fetch').then(({ default: fetch }) =>
fetch(url as unknown as FetchInfo, init as unknown as FetchInit),
) as unknown as Promise<Response>;
};
}
internal = await initLootCore(config);
return internal;
}

View File

@@ -33,7 +33,6 @@
"@actual-app/crdt": "workspace:*",
"better-sqlite3": "^12.6.2",
"compare-versions": "^6.1.1",
"node-fetch": "^3.3.2",
"uuid": "^13.0.0"
},
"devDependencies": {

View File

@@ -66,10 +66,8 @@
"jsdom": "^27.4.0",
"lodash": "^4.17.23",
"mdast-util-newline-to-break": "^2.0.0",
"memoize-one": "^6.0.0",
"pikaday": "1.8.2",
"promise-retry": "^2.0.1",
"prop-types": "^15.8.1",
"re-resizable": "^6.11.2",
"react": "19.2.4",
"react-aria": "^3.46.0",

View File

@@ -34,6 +34,7 @@ import { CustomReportListCards } from './reports/CustomReportListCards';
import { FormulaCard } from './reports/FormulaCard';
import { MarkdownCard } from './reports/MarkdownCard';
import { NetWorthCard } from './reports/NetWorthCard';
import { SankeyCard } from './reports/SankeyCard';
import { SpendingCard } from './reports/SpendingCard';
import './overview.scss';
import { SummaryCard } from './reports/SummaryCard';
@@ -97,6 +98,8 @@ export function Overview({ dashboard }: OverviewProps) {
const { data: customReports = [], isPending: isCustomReportsLoading } =
useReports();
const sankeyFeatureFlag = useFeatureFlag('sankeyReport');
const customReportMap = useMemo(
() => new Map(customReports.map(report => [report.id, report])),
[customReports],
@@ -608,6 +611,14 @@ export function Overview({ dashboard }: OverviewProps) {
},
]
: []),
...(sankeyFeatureFlag
? [
{
name: 'sankey-card' as const,
text: t('Sankey card'),
},
]
: []),
{
name: 'custom-report' as const,
text: t('New custom report'),
@@ -861,6 +872,17 @@ export function Overview({ dashboard }: OverviewProps) {
onCopyWidget(item.i, targetDashboardId)
}
/>
) : widget.type === 'sankey-card' && sankeyFeatureFlag ? (
<SankeyCard
widgetId={item.i}
isEditing={isEditing}
meta={widget.meta}
onMetaChange={newMeta => onMetaChange(item, newMeta)}
onRemove={() => onRemoveWidget(item.i)}
onCopy={targetDashboardId =>
onCopyWidget(item.i, targetDashboardId)
}
/>
) : null}
</div>
);

View File

@@ -8,6 +8,7 @@ import { Crossover } from './reports/Crossover';
import { CustomReport } from './reports/CustomReport';
import { Formula } from './reports/Formula';
import { NetWorth } from './reports/NetWorth';
import { Sankey } from './reports/Sankey';
import { Spending } from './reports/Spending';
import { Summary } from './reports/Summary';
import { ReportsDashboardRouter } from './ReportsDashboardRouter';
@@ -17,6 +18,7 @@ import { useFeatureFlag } from '@desktop-client/hooks/useFeatureFlag';
export function ReportRouter() {
const crossoverReportEnabled = useFeatureFlag('crossoverReport');
const budgetAnalysisReportEnabled = useFeatureFlag('budgetAnalysisReport');
const sankeyReportEnabled = useFeatureFlag('sankeyReport');
return (
<Routes>
@@ -48,6 +50,12 @@ export function ReportRouter() {
<Route path="/calendar/:id" element={<Calendar />} />
<Route path="/formula" element={<Formula />} />
<Route path="/formula/:id" element={<Formula />} />
{sankeyReportEnabled && (
<>
<Route path="/sankey" element={<Sankey />} />
<Route path="/sankey/:id" element={<Sankey />} />
</>
)}
</Routes>
);
}

View File

@@ -0,0 +1,330 @@
import { useState } from 'react';
import type { CSSProperties } from 'react';
import { theme } from '@actual-app/components/theme';
import { css } from '@emotion/css';
import { t } from 'i18next';
import {
Layer,
Rectangle,
ResponsiveContainer,
Sankey,
Tooltip,
} from 'recharts';
import type { SankeyData } from 'recharts/types/chart/Sankey';
import { getColorScale } from '@desktop-client/components/reports/chart-theme';
import { Container } from '@desktop-client/components/reports/Container';
import { useFormat } from '@desktop-client/hooks/useFormat';
import { usePrivacyMode } from '@desktop-client/hooks/usePrivacyMode';
type SankeyGraphNode = SankeyData['nodes'][number] & {
hasChildren?: boolean;
isCollapsed?: boolean;
toBudget?: number;
isNegative?: boolean;
actualValue?: number;
targetLinks?: Array<Record<string, unknown>>;
sourceLinks?: Array<Record<string, unknown>>;
};
type SankeyLinkProps = {
sourceX: number;
sourceY: number;
sourceControlX: number;
targetX: number;
targetY: number;
targetControlX: number;
linkWidth: number;
index: number;
payload: {
source: SankeyGraphNode;
target: SankeyGraphNode;
value: number;
isNegative?: boolean;
};
isHovered: boolean;
onMouseEnter: () => void;
onMouseLeave: () => void;
color: string;
};
function SankeyLink({
sourceX,
sourceY,
sourceControlX,
targetX,
targetY,
targetControlX,
linkWidth,
payload,
isHovered,
onMouseEnter,
onMouseLeave,
color,
}: SankeyLinkProps) {
const linkColor = payload.isNegative ? theme.errorText : color;
const strokeWidth = linkWidth;
const strokeOpacity = isHovered ? 1 : 0.6;
return (
<path
d={`M${sourceX},${sourceY} C${sourceControlX},${sourceY} ${targetControlX},${targetY} ${targetX},${targetY}`}
fill="none"
stroke={linkColor}
strokeWidth={strokeWidth}
strokeOpacity={strokeOpacity}
cursor="default"
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
style={{ transition: 'stroke-opacity 0.2s ease' }}
/>
);
}
type SankeyNodeProps = {
x: number;
y: number;
width: number;
height: number;
index: number;
payload: SankeyGraphNode;
containerWidth: number;
containerHeight: number;
};
function SankeyNode({
x,
y,
width,
height,
index,
payload,
containerWidth,
containerHeight,
}: SankeyNodeProps) {
const privacyMode = usePrivacyMode();
const format = useFormat();
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 renderText = (
text: string,
yOffset: number,
fontSize = 13,
opacity = 1,
fontFamily?: string,
yBase = y,
) => (
<text
textAnchor={isOut ? 'end' : 'start'}
x={isOut ? x - 6 : x + width + 6}
y={yBase + yOffset}
fontSize={fontSize}
strokeOpacity={opacity}
fill={theme.pageText}
fontFamily={fontFamily}
>
{text}
</text>
);
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(
format(payload.value, 'financial'),
height / 2 + 13,
11,
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>
);
}
type SankeyGraphProps = {
style?: CSSProperties;
data: SankeyData;
showTooltip?: boolean;
collapsedNodes?: string[];
};
export function SankeyGraph({
style,
data,
showTooltip = true,
}: SankeyGraphProps) {
const privacyMode = usePrivacyMode();
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) => (
<ResponsiveContainer>
<Sankey
data={data}
node={props => (
<SankeyNode
{...props}
containerWidth={width}
containerHeight={height}
/>
)}
link={props => (
<SankeyLink
{...props}
isHovered={hoveredLinkIndex === props.index}
onMouseEnter={() => setHoveredLinkIndex(props.index)}
onMouseLeave={() => setHoveredLinkIndex(null)}
color={
sourceColorMap.get(props.payload.source.name) ??
theme.reportsGray
}
/>
)}
sort={false}
iterations={1000}
nodePadding={23}
width={width}
height={height}
margin={{
left: 0,
right: 0,
top: 10,
bottom: 25,
}}
>
{showTooltip && (
<Tooltip
content={({ active, payload }) => {
if (!active || !payload?.length) return null;
const { value = 0, name = '' } = payload[0];
const tooltipInfo =
hoveredLinkIndex !== null
? (
data.links[hoveredLinkIndex] as {
tooltipInfo?: Array<{
name: string;
value: number;
}>;
}
)?.tooltipInfo
: undefined;
return (
<div
className={css({
zIndex: 1000,
pointerEvents: 'none',
borderRadius: 2,
boxShadow: '0 1px 6px rgba(0, 0, 0, .20)',
backgroundColor: theme.menuBackground,
color: theme.menuItemText,
padding: 10,
})}
>
<div style={{ lineHeight: 1.4 }}>
{name && <div style={{ marginBottom: 5 }}>{name}</div>}
<div
style={{
fontFamily: privacyMode
? t('Redacted Script')
: undefined,
}}
>
{format(value, 'financial')}
</div>
{tooltipInfo && tooltipInfo.length > 0 && (
<div
style={{ marginTop: 6, fontSize: 11, opacity: 0.7 }}
>
{tooltipInfo.map(item => (
<div key={item.name}>
{item.name} (
<span
style={{
fontFamily: privacyMode
? t('Redacted Script')
: undefined,
}}
>
{format(item.value, 'financial')}
</span>
)
</div>
))}
</div>
)}
</div>
</div>
);
}}
isAnimationActive={false}
/>
)}
</Sankey>
</ResponsiveContainer>
)}
</Container>
);
}

View File

@@ -0,0 +1,615 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
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 { Menu } from '@actual-app/components/menu';
import { Paragraph } from '@actual-app/components/paragraph';
import { Popover } from '@actual-app/components/popover';
import { Select } from '@actual-app/components/select';
import { SpaceBetween } from '@actual-app/components/space-between';
import { styles } from '@actual-app/components/styles';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { Tooltip } from '@actual-app/components/tooltip';
import { View } from '@actual-app/components/view';
import * as d from 'date-fns';
import type { SankeyData } from 'recharts/types/chart/Sankey';
import { send } from 'loot-core/platform/client/connection';
import * as monthUtils from 'loot-core/shared/months';
import type {
RuleConditionEntity,
SankeyWidget,
TimeFrame,
} from 'loot-core/types/models';
import { EditablePageHeaderTitle } from '@desktop-client/components/EditablePageHeaderTitle';
import { AppliedFilters } from '@desktop-client/components/filters/AppliedFilters';
import { FilterButton } from '@desktop-client/components/filters/FiltersMenu';
import { MobileBackButton } from '@desktop-client/components/mobile/MobileBackButton';
import {
MobilePageHeader,
Page,
PageHeader,
} from '@desktop-client/components/Page';
import { SankeyGraph } from '@desktop-client/components/reports/graphs/SankeyGraph';
import { Header } from '@desktop-client/components/reports/Header';
import { LoadingIndicator } from '@desktop-client/components/reports/LoadingIndicator';
import { ModeButton } from '@desktop-client/components/reports/ModeButton';
import { calculateTimeRange } from '@desktop-client/components/reports/reportRanges';
import { createSpreadsheet as sankeySpreadsheet } from '@desktop-client/components/reports/spreadsheets/sankey-spreadsheet';
import { useReport } from '@desktop-client/components/reports/useReport';
import { fromDateRepr } from '@desktop-client/components/reports/util';
import { useCategories } from '@desktop-client/hooks/useCategories';
import { useDashboardWidget } from '@desktop-client/hooks/useDashboardWidget';
import { useLocale } from '@desktop-client/hooks/useLocale';
import { useNavigate } from '@desktop-client/hooks/useNavigate';
import { useRuleConditionFilters } from '@desktop-client/hooks/useRuleConditionFilters';
import type { useSpreadsheet } from '@desktop-client/hooks/useSpreadsheet';
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
import { useDispatch } from '@desktop-client/redux';
import { useUpdateDashboardWidgetMutation } from '@desktop-client/reports/mutations';
export function Sankey() {
const params = useParams();
const { data: widget, isLoading } = useDashboardWidget<SankeyWidget>({
id: params.id ?? '',
type: 'sankey-card',
});
if (isLoading) {
return <LoadingIndicator />;
}
return <SankeyInner widget={widget} />;
}
type GraphMode = 'budgeted' | 'spent';
const TOP_N_OPTIONS = [10, 15, 20, 25, 30] as const;
type TopNSelectorProps = {
value: number;
onChange: (value: number) => void;
};
function TopNSelector({ value, onChange }: TopNSelectorProps) {
const { t } = useTranslation();
const triggerRef = useRef<HTMLButtonElement | null>(null);
const [isOpen, setIsOpen] = useState(false);
return (
<>
<Button
ref={triggerRef}
variant="bare"
onPress={() => setIsOpen(true)}
aria-label={t('Change category limit')}
>
<SvgList style={{ width: 12, height: 12 }} />
<span style={{ marginLeft: 5 }}>{t('Show {{n}}', { n: value })}</span>
</Button>
<Popover
triggerRef={triggerRef}
placement="bottom start"
isOpen={isOpen}
onOpenChange={() => setIsOpen(false)}
>
<Menu
onMenuSelect={item => {
onChange(Number(item));
setIsOpen(false);
}}
items={TOP_N_OPTIONS.map(n => ({
name: String(n),
text: t('Show {{n}}', { n }),
}))}
/>
</Popover>
</>
);
}
type CategorySortSelectorProps = {
value: 'per-group' | 'global' | 'budget-order';
onChange: (value: 'per-group' | 'global' | 'budget-order') => void;
};
function CategorySortSelector({ value, onChange }: CategorySortSelectorProps) {
const { t } = useTranslation();
const triggerRef = useRef<HTMLButtonElement | null>(null);
const [isOpen, setIsOpen] = useState(false);
const options: Array<{
key: 'per-group' | 'global' | 'budget-order';
label: string;
}> = [
{ key: 'per-group', label: t('Sort per group') },
{ key: 'global', label: t('Sort all') },
{ key: 'budget-order', label: t('Sort as budget') },
];
const currentLabel =
value === 'global'
? t('Sort all')
: value === 'budget-order'
? t('Sort as budget')
: t('Sort per group');
return (
<>
<Button
ref={triggerRef}
variant="bare"
onPress={() => setIsOpen(true)}
aria-label={t('Change category sort order')}
>
<SvgArrowDown style={{ width: 12, height: 12 }} />
<span style={{ marginLeft: 5 }}>{currentLabel}</span>
</Button>
<Popover
triggerRef={triggerRef}
placement="bottom start"
isOpen={isOpen}
onOpenChange={() => setIsOpen(false)}
>
<Menu
onMenuSelect={item => {
onChange(item as 'per-group' | 'global' | 'budget-order');
setIsOpen(false);
}}
items={options.map(({ key, label }) => ({
name: key,
text: label,
}))}
/>
</Popover>
</>
);
}
type GraphModeSelectorProps = {
mode: GraphMode;
onChange: (mode: GraphMode) => void;
};
function GraphModeSelector({ mode, onChange }: GraphModeSelectorProps) {
return (
<SpaceBetween gap={5}>
<ModeButton
selected={mode === 'spent'}
style={{
backgroundColor: 'inherit',
}}
onSelect={() => {
onChange('spent');
}}
>
<Trans>Spent</Trans>
</ModeButton>
<ModeButton
selected={mode === 'budgeted'}
onSelect={() => {
onChange('budgeted');
}}
style={{
backgroundColor: 'inherit',
}}
>
<Trans>Budgeted</Trans>
</ModeButton>
</SpaceBetween>
);
}
type SankeyInnerProps = {
widget?: SankeyWidget;
};
function SankeyInner({ widget }: SankeyInnerProps) {
const locale = useLocale();
const dispatch = useDispatch();
const { t } = useTranslation();
const navigate = useNavigate();
const { isNarrowWidth } = useResponsive();
const {
conditions,
conditionsOp,
onApply: onApplyFilter,
onDelete: onDeleteFilter,
onUpdate: onUpdateFilter,
onConditionsOpChange,
} = useRuleConditionFilters<RuleConditionEntity>(
widget?.meta?.conditions,
widget?.meta?.conditionsOp,
);
const currentMonth = monthUtils.currentMonth();
const [allMonths, setAllMonths] = useState([
{
name: currentMonth,
pretty: monthUtils.format(currentMonth, 'MMMM yyyy', locale),
},
]);
const [start, setStart] = useState(monthUtils.currentMonth());
const [end, setEnd] = useState(monthUtils.currentMonth());
const [timeFrameMode, setTimeFrameMode] = useState<TimeFrame['mode']>(
widget?.meta?.timeFrame?.mode ?? 'sliding-window',
);
const [datesInitialized, setDatesInitialized] = useState(false);
const [earliestTransaction, setEarliestTransaction] = useState('');
const [latestTransaction, setLatestTransaction] = useState('');
const [graphMode, setGraphMode] = useState<GraphMode>(
widget?.meta?.mode ?? 'spent',
);
const [topNcategories, settopNcategories] = useState<number>(
widget?.meta?.topNcategories ?? 15,
);
const [categorySort, setCategorySort] = useState<
'per-group' | 'global' | 'budget-order'
>(widget?.meta?.categorySort ?? 'per-group');
const { data: { grouped: groupedCategories = [] } = { grouped: [] } } =
useCategories();
const reportParams = useMemo(() => {
if (!datesInitialized) {
return null;
}
return sankeySpreadsheet(
start,
end,
groupedCategories,
conditions,
conditionsOp,
graphMode,
topNcategories,
categorySort,
);
}, [
datesInitialized,
start,
end,
groupedCategories,
conditions,
conditionsOp,
graphMode,
topNcategories,
categorySort,
]);
const defaultGetData = async (
spreadsheet: ReturnType<typeof useSpreadsheet>,
setData: (data: SankeyData) => void,
) => setData({ nodes: [], links: [] });
const data = useReport('sankey', reportParams ?? defaultGetData);
useEffect(() => {
async function run() {
const earliestTransaction = await send('get-earliest-transaction');
const earliestTransactionDate = earliestTransaction
? earliestTransaction.date
: monthUtils.currentDay();
setEarliestTransaction(earliestTransactionDate);
const latestTransaction = await send('get-latest-transaction');
const latestTransactionDate = latestTransaction
? latestTransaction.date
: monthUtils.currentDay();
setLatestTransaction(latestTransactionDate);
const [initialStart, initialEnd, initialMode] = calculateTimeRange(
widget?.meta?.timeFrame,
undefined,
latestTransactionDate,
);
setStart(initialStart);
setEnd(initialEnd);
setTimeFrameMode(initialMode);
setDatesInitialized(true);
const currentMonth = monthUtils.currentMonth();
let earliestMonth = earliestTransaction
? monthUtils.monthFromDate(
d.parseISO(fromDateRepr(earliestTransaction.date)),
)
: currentMonth;
const latestTransactionMonth = latestTransaction
? monthUtils.monthFromDate(
d.parseISO(fromDateRepr(latestTransaction.date)),
)
: currentMonth;
const latestMonth =
latestTransactionMonth > currentMonth
? latestTransactionMonth
: currentMonth;
// Make sure the month selects are at least populated with a
// year's worth of months. We can undo this when we have fancier
// date selects.
const yearAgo = monthUtils.subMonths(latestMonth, 12);
if (earliestMonth > yearAgo) {
earliestMonth = yearAgo;
}
const allMonths = monthUtils
.rangeInclusive(earliestMonth, latestMonth)
.map(month => ({
name: month,
pretty: monthUtils.format(month, 'MMMM yyyy', locale),
}))
.reverse();
setAllMonths(allMonths);
}
void run();
}, [locale, widget?.meta?.timeFrame]);
function onChangeDates(start: string, end: string, mode: TimeFrame['mode']) {
setStart(start);
setEnd(end);
setTimeFrameMode(mode);
}
const updateDashboardWidgetMutation = useUpdateDashboardWidgetMutation();
async function onSaveWidget() {
if (!widget) {
throw new Error('No widget that could be saved.');
}
updateDashboardWidgetMutation.mutate(
{
widget: {
id: widget.id,
meta: {
...(widget.meta ?? {}),
conditions,
conditionsOp,
mode: graphMode,
topNcategories,
categorySort,
timeFrame: {
start,
end,
mode: timeFrameMode,
},
},
},
},
{
onSuccess: () => {
dispatch(
addNotification({
notification: {
type: 'message',
message: t('Dashboard widget successfully saved.'),
},
}),
);
},
},
);
}
const onSaveWidgetName = async (newName: string) => {
if (!widget) {
throw new Error('No widget that could be saved.');
}
const name = newName || t('Sankey');
await send('dashboard-update-widget', {
id: widget.id,
meta: {
...(widget.meta ?? {}),
name,
},
});
};
const title = widget?.meta?.name || t('Sankey');
if (!datesInitialized || !data) {
return <LoadingIndicator />;
}
return (
<Page
header={
isNarrowWidth ? (
<MobilePageHeader
title={title}
leftContent={
<MobileBackButton onPress={() => navigate('/reports')} />
}
/>
) : (
<PageHeader
title={
widget ? (
<EditablePageHeaderTitle
title={title}
onSave={onSaveWidgetName}
/>
) : (
title
)
}
/>
)
}
padding={0}
>
<Header
allMonths={allMonths}
start={start}
end={end}
mode={timeFrameMode}
show1Month
earliestTransaction={earliestTransaction}
latestTransaction={latestTransaction}
onChangeDates={onChangeDates}
filters={conditions}
onApply={onApplyFilter}
onUpdateFilter={onUpdateFilter}
onDeleteFilter={onDeleteFilter}
conditionsOp={conditionsOp}
onConditionsOpChange={onConditionsOpChange}
filterExclude={[
'date',
'payee',
'notes',
'amount',
'cleared',
'reconciled',
'transfer',
]}
inlineContent={
<>
<View
style={{
width: 1,
height: 28,
backgroundColor: theme.pillBorderDark,
marginRight: 10,
marginLeft: 10,
}}
/>
<GraphModeSelector mode={graphMode} onChange={setGraphMode} />
<View
style={{
width: 1,
height: 28,
backgroundColor: theme.pillBorderDark,
marginRight: 10,
marginLeft: 10,
}}
/>
<TopNSelector value={topNcategories} onChange={settopNcategories} />
<CategorySortSelector
value={categorySort}
onChange={setCategorySort}
/>
</>
}
>
{widget && (
<Button variant="primary" onPress={onSaveWidget}>
<Trans>Save widget</Trans>
</Button>
)}
</Header>
<View
style={{
backgroundColor: theme.tableBackground,
padding: 20,
paddingTop: 0,
flex: '1 0 auto',
overflowY: 'visible',
}}
>
<View
style={{
display: 'flex',
flexDirection: 'row',
paddingTop: 0,
flexGrow: 1,
}}
>
<View
style={{
flexGrow: 1,
}}
>
<View
style={{
backgroundColor: theme.tableBackground,
padding: 20,
paddingTop: 0,
flex: '1 0 auto',
overflowY: 'auto',
}}
>
<View
style={{
flexDirection: 'column',
flexGrow: 1,
padding: 10,
paddingTop: 10,
}}
>
{data && data.links && data.links.length > 0 ? (
<SankeyGraph
style={{ flexGrow: 1 }}
data={data as SankeyData}
/>
) : (
<View
style={{
flexGrow: 1,
alignItems: 'center',
justifyContent: 'center',
color: theme.pageTextSubdued,
}}
>
<Text style={{ fontSize: 16, textAlign: 'center' }}>
{graphMode === 'budgeted' && (
<Trans>
No data available for this period. Try budgeting
categories or selecting a different period.
</Trans>
)}
{graphMode === 'spent' && (
<Trans>
No data available for this period. Try adding
transactions or selecting a different period.
</Trans>
)}
</Text>
</View>
)}
{!isNarrowWidth && (
<View style={{ marginTop: 30 }}>
<Trans>
<Paragraph>
<strong>What is a Sankey plot?</strong>
</Paragraph>
<Paragraph>
A Sankey plot visualizes the flow of quantities between
multiple categories, emphasizing the distribution and
proportional relationships of data streams.
</Paragraph>
<Paragraph>
<strong>View options:</strong>
</Paragraph>
<ul style={{ marginTop: 0, paddingLeft: 20 }}>
<li style={{ marginBottom: 5 }}>
<strong>Spent:</strong> Displays actual spending by
category from transactions.
</li>
<li style={{ marginBottom: 5 }}>
<strong>Budgeted:</strong> Shows how your budget is
allocated across categories.
</li>
</ul>
</Trans>
</View>
)}
</View>
</View>
</View>
</View>
</View>
</Page>
);
}

View File

@@ -0,0 +1,168 @@
import { useMemo, useState } from 'react';
import type { ReactElement } from 'react';
import { useTranslation } from 'react-i18next';
import { Block } from '@actual-app/components/block';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import * as d from 'date-fns';
import type { SankeyWidget } from 'loot-core/types/models';
import { DateRange } from '@desktop-client/components/reports/DateRange';
import { SankeyGraph } from '@desktop-client/components/reports/graphs/SankeyGraph';
import { LoadingIndicator } from '@desktop-client/components/reports/LoadingIndicator';
import { ReportCard } from '@desktop-client/components/reports/ReportCard';
import { ReportCardName } from '@desktop-client/components/reports/ReportCardName';
import { calculateTimeRange } from '@desktop-client/components/reports/reportRanges';
import {
compactSankeyData,
createSpreadsheet as sankeySpreadsheet,
} from '@desktop-client/components/reports/spreadsheets/sankey-spreadsheet';
import { useDashboardWidgetCopyMenu } from '@desktop-client/components/reports/useDashboardWidgetCopyMenu';
import { useReport } from '@desktop-client/components/reports/useReport';
import { useCategories } from '@desktop-client/hooks/useCategories';
import { useLocale } from '@desktop-client/hooks/useLocale';
import { useResizeObserver } from '@desktop-client/hooks/useResizeObserver';
type SankeyCardProps = {
widgetId: string;
isEditing?: boolean;
meta?: SankeyWidget['meta'];
onMetaChange: (newMeta: SankeyWidget['meta']) => void;
onRemove: () => void;
onCopy: (targetDashboardId: string) => void;
};
export function SankeyCard({
widgetId,
isEditing,
meta,
onMetaChange,
onRemove,
onCopy,
}: SankeyCardProps) {
const { t } = useTranslation();
const locale = useLocale();
const [nameMenuOpen, setNameMenuOpen] = useState(false);
const { menuItems: copyMenuItems, handleMenuSelect: handleCopyMenuSelect } =
useDashboardWidgetCopyMenu(onCopy);
const { data: { grouped: groupedCategories = [] } = { grouped: [] } } =
useCategories();
const [start, end] = calculateTimeRange(meta?.timeFrame);
const mode = meta?.mode ?? 'spent';
const [cardHeight, setCardHeight] = useState(0);
const containerRef = useResizeObserver<HTMLDivElement>(rect => {
setCardHeight(rect.height);
});
const params = useMemo(
() =>
sankeySpreadsheet(
start,
end,
groupedCategories,
meta?.conditions ?? [],
meta?.conditionsOp ?? 'and',
mode,
),
[start, end, groupedCategories, meta?.conditions, meta?.conditionsOp, mode],
);
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 startDate = d.parseISO(start);
const endDate = d.parseISO(end);
const formattedStartDate = d.format(startDate, 'MMM yyyy', { locale });
const formattedEndDate = d.format(endDate, 'MMM yyyy', { locale });
let dateDescription: string | ReactElement;
if (
startDate.getFullYear() !== endDate.getFullYear() ||
startDate.getMonth() !== endDate.getMonth()
) {
dateDescription = formattedStartDate + ' - ' + formattedEndDate;
} else {
dateDescription = formattedEndDate;
}
const modeLabel = mode === 'budgeted' ? t('Budgeted') : t('Spent');
dateDescription += ` (${modeLabel})`;
return (
<ReportCard
isEditing={isEditing}
disableClick={nameMenuOpen}
to={`/reports/sankey/${widgetId}`}
menuItems={[
{
name: 'rename',
text: t('Rename'),
},
{
name: 'remove',
text: t('Remove'),
},
...copyMenuItems,
]}
onMenuSelect={item => {
if (handleCopyMenuSelect(item)) return;
switch (item) {
case 'rename':
setNameMenuOpen(true);
break;
case 'remove':
onRemove();
break;
default:
throw new Error(`Unrecognized selection: ${item}`);
}
}}
>
<View ref={containerRef} style={{ flex: 1 }}>
<View style={{ flexDirection: 'row', padding: 20 }}>
<View style={{ flex: 1 }}>
<ReportCardName
name={meta?.name || t('Sankey')}
isEditing={nameMenuOpen}
onChange={newName => {
onMetaChange({
...meta,
name: newName,
});
setNameMenuOpen(false);
}}
onClose={() => setNameMenuOpen(false)}
/>
<Block style={{ color: theme.pageTextSubdued }}>
{dateDescription}
</Block>
</View>
</View>
{compactData ? (
<SankeyGraph
data={compactData}
showTooltip={!isEditing}
style={{ height: 'auto', flex: 1 }}
/>
) : (
<LoadingIndicator />
)}
</View>
</ReportCard>
);
}

View File

@@ -0,0 +1,730 @@
import { send } from 'loot-core/platform/client/connection';
import * as monthUtils from 'loot-core/shared/months';
import { q } from 'loot-core/shared/query';
import type {
CategoryGroupEntity,
RuleConditionEntity,
} from 'loot-core/types/models';
import type { useSpreadsheet } from '@desktop-client/hooks/useSpreadsheet';
import { aqlQuery } from '@desktop-client/queries/aqlQuery';
type BudgetMonthCategory = {
id: string;
name: string;
spent?: number;
budgeted?: number;
balance?: number;
};
type BudgetMonthGroup = {
id: string;
name: string;
is_income: boolean;
categories: BudgetMonthCategory[];
};
type BudgetMonthResponse = {
categoryGroups: BudgetMonthGroup[];
totalIncome: number;
fromLastMonth: number;
forNextMonth: number;
toBudget: number;
};
type AggregatedBudget = {
toBudget: number;
categoryGroupsMap: Map<string, BudgetMonthGroup>;
};
type SankeyNode = {
name: string;
toBudget?: number;
isNegative?: boolean;
};
type SankeyLink = {
source: number;
target: number;
value: number;
isNegative?: boolean;
tooltipInfo?: Array<{ name: string; value: number }>;
};
type SankeyData = {
nodes: SankeyNode[];
links: SankeyLink[];
};
type CategoryEntry = {
mainCategory: string;
group: string;
value: number;
isNegative?: boolean;
};
type CategoryOrder = Array<{ mainCategory: string; categories: string[] }>;
// Filter budget category groups to only those matching the user's conditions.
// Budget data is fetched unconditionally from api/budget-month, so we must
// apply category conditions manually in JS (unlike the transaction path which
// passes conditions directly into the AQL query).
function filterCategoryGroups(
categoryGroups: BudgetMonthGroup[],
conditions: RuleConditionEntity[],
conditionsOp: 'and' | 'or',
): BudgetMonthGroup[] {
const categoryConditions = conditions.filter(
cond => cond.field === 'category',
);
const categoryGroupConditions = conditions.filter(
cond => cond.field === 'category_group',
);
if (categoryConditions.length === 0 && categoryGroupConditions.length === 0) {
return categoryGroups;
}
const matchesStringCondition = (
id: string,
name: string,
cond: RuleConditionEntity,
): boolean => {
const value = cond.value;
const op = cond.op as string;
if (op === 'is') return id === value;
if (op === 'isNot') return id !== value;
if (op === 'oneOf') return Array.isArray(value) && value.includes(id);
if (op === 'notOneOf') return !Array.isArray(value) || !value.includes(id);
if (op === 'contains') {
return (
typeof value === 'string' &&
name.toLowerCase().includes(value.toLowerCase())
);
}
if (op === 'doesNotContain') {
return (
typeof value === 'string' &&
!name.toLowerCase().includes(value.toLowerCase())
);
}
if (op === 'matches') {
if (typeof value !== 'string') return false;
try {
const regex =
value.startsWith('/') && value.lastIndexOf('/') > 0
? new RegExp(
value.slice(1, value.lastIndexOf('/')),
value.slice(value.lastIndexOf('/') + 1),
)
: new RegExp(value);
return regex.test(name);
} catch {
return false;
}
}
return false;
};
const categoryMatchesConditions = (
catId: string,
catName: string,
groupId: string,
groupName: string,
): boolean => {
const matchesCat = (cond: RuleConditionEntity) =>
matchesStringCondition(catId, catName, cond);
const matchesGroup = (cond: RuleConditionEntity) =>
matchesStringCondition(groupId, groupName, cond);
if (conditionsOp === 'or') {
return (
categoryConditions.some(matchesCat) ||
categoryGroupConditions.some(matchesGroup)
);
}
// 'and': all category conditions AND all category_group conditions must match
const catMatch =
categoryConditions.length === 0 || categoryConditions.every(matchesCat);
const groupMatch =
categoryGroupConditions.length === 0 ||
categoryGroupConditions.every(matchesGroup);
return catMatch && groupMatch;
};
return categoryGroups
.map(group => ({
...group,
categories: group.categories.filter(cat =>
categoryMatchesConditions(cat.id, cat.name, group.id, group.name),
),
}))
.filter(group => group.categories.length > 0);
}
export function createSpreadsheet(
start: string,
end: string,
categories: CategoryGroupEntity[],
conditions: RuleConditionEntity[] = [],
conditionsOp: 'and' | 'or' = 'and',
mode: 'budgeted' | 'spent' = 'spent',
topNcategories: number = 15,
categorySort: 'per-group' | 'global' | 'budget-order' = 'per-group',
) {
let globalOther: boolean;
let groupSort: 'per-group' | 'global';
let categoryOrder: CategoryOrder | undefined;
if (categorySort === 'global') {
globalOther = true;
groupSort = 'global';
} else if (categorySort === 'budget-order') {
globalOther = false;
groupSort = 'per-group';
categoryOrder = categories
.filter(g => !g.hidden && !g.is_income)
.map(g => ({
mainCategory: g.name,
categories: (g.categories ?? [])
.filter(c => !c.hidden)
.map(c => c.name),
}));
} else {
globalOther = false;
groupSort = 'per-group';
}
return async (
spreadsheet: ReturnType<typeof useSpreadsheet>,
setData: (data: ReturnType<typeof transformToSankeyData>) => void,
) => {
if (mode === 'budgeted') {
const data = await createBudgetSpreadsheet(
start,
end,
conditions,
conditionsOp,
globalOther,
topNcategories,
groupSort,
categoryOrder,
)(spreadsheet, setData);
return data;
} else if (mode === 'spent') {
const data = await createTransactionsSpreadsheet(
start,
end,
categories,
conditions,
conditionsOp,
globalOther,
topNcategories,
groupSort,
categoryOrder,
)(spreadsheet, setData);
return data;
}
};
}
export function createBudgetSpreadsheet(
start: string,
end: string,
conditions: RuleConditionEntity[] = [],
conditionsOp: 'and' | 'or' = 'and',
globalOther: boolean = false,
topNcategories: number = 15,
groupSort: 'per-group' | 'global' = 'per-group',
categoryOrder?: CategoryOrder,
) {
return async (
spreadsheet: ReturnType<typeof useSpreadsheet>,
setData: (data: ReturnType<typeof transformToSankeyData>) => void,
) => {
const months =
end && end !== start ? monthUtils.rangeInclusive(start, end) : [start];
const monthResponses = await Promise.all(
months.map(
m =>
send('api/budget-month', {
month: m,
}) as unknown as Promise<BudgetMonthResponse>,
),
);
const aggregated = monthResponses.reduce<AggregatedBudget>(
(acc, response) => {
acc.toBudget += response.toBudget;
for (const group of response.categoryGroups) {
const existingGroup = acc.categoryGroupsMap.get(group.id);
if (!existingGroup) {
acc.categoryGroupsMap.set(group.id, {
...group,
categories: group.categories.map(cat => ({ ...cat })),
});
continue;
}
for (const cat of group.categories) {
const existingCat = existingGroup.categories.find(
c => c.id === cat.id,
);
if (!existingCat) {
existingGroup.categories.push({ ...cat });
continue;
}
existingCat.budgeted =
(existingCat.budgeted ?? 0) + (cat.budgeted ?? 0);
existingCat.spent = (existingCat.spent ?? 0) + (cat.spent ?? 0);
existingCat.balance =
(existingCat.balance ?? 0) + (cat.balance ?? 0);
}
}
return acc;
},
{
toBudget: 0,
categoryGroupsMap: new Map<string, BudgetMonthGroup>(),
},
);
const categoryGroups = Array.from(aggregated.categoryGroupsMap.values());
const filteredCategoryGroups = filterCategoryGroups(
categoryGroups,
conditions,
conditionsOp,
);
const categoryData: CategoryEntry[] = filteredCategoryGroups
.filter(group => !group.is_income)
.flatMap(group =>
group.categories.map(cat => ({
mainCategory: group.name,
group: cat.name,
value: cat.budgeted ?? 0,
})),
);
const { toBudget } = aggregated;
setData(
transformToSankeyData(
categoryData,
toBudget,
'Budgeted',
topNcategories,
globalOther,
groupSort,
categoryOrder,
),
);
};
}
export function createTransactionsSpreadsheet(
start: string,
end: string,
categories: CategoryGroupEntity[],
conditions: RuleConditionEntity[] = [],
conditionsOp: 'and' | 'or' = 'and',
globalOther: boolean = false,
topNcategories: number = 15,
groupSort: 'per-group' | 'global' = 'per-group',
categoryOrder?: CategoryOrder,
) {
return async (
spreadsheet: ReturnType<typeof useSpreadsheet>,
setData: (data: ReturnType<typeof transformToSankeyData>) => void,
) => {
// gather filters user has set
const { filters } = await send('make-filters-from-conditions', {
conditions: conditions.filter(cond => !cond.customName),
});
const conditionsOpKey = conditionsOp === 'or' ? '$or' : '$and';
const categoryData = await fetchCategoryData(
categories,
conditionsOpKey,
filters,
start,
end,
);
// convert retrieved data into the proper sankey format
setData(
transformToSankeyData(
categoryData,
0,
'Spent',
topNcategories,
globalOther,
groupSort,
categoryOrder,
),
);
};
}
// retrieve sum of group expenses
async function fetchCategoryData(
categories: CategoryGroupEntity[],
conditionsOpKey: string = '$and',
filters: unknown[] = [],
start: string,
end: string,
): Promise<CategoryEntry[]> {
const nested = await Promise.all(
categories.map(async (mainCategory: CategoryGroupEntity) => {
const entries = await Promise.all(
(mainCategory.categories || [])
.filter(group => !group?.is_income)
.map(async group => {
const results = await aqlQuery(
q('transactions')
.filter({ [conditionsOpKey]: filters })
.filter({
$and: [
{ date: { $gte: monthUtils.firstDayOfMonth(start) } },
{ date: { $lte: monthUtils.lastDayOfMonth(end) } },
],
})
.filter({ category: group.id })
.calculate({ $sum: '$amount' }),
);
return {
mainCategory: mainCategory.name,
group: group.name,
value: results.data * -1,
} satisfies CategoryEntry;
}),
);
return entries;
}),
);
return nested.flat();
}
type LeafState = {
mainCategory: string;
group: string;
value: number;
isNegative: boolean;
visible: boolean;
};
type OtherBucket = {
total: number;
entries: Array<{ name: string; value: number }>;
};
type GreedyReductionResult = {
allLeaves: LeafState[];
perCategoryOther: Map<string, OtherBucket>;
globalOtherBucket: OtherBucket;
};
function greedyReduceLeaves(
allLeaves: LeafState[],
topNcategories: number,
globalOther: boolean,
): GreedyReductionResult {
const perCategoryOther = new Map<string, OtherBucket>();
const globalOtherBucket: OtherBucket = { total: 0, entries: [] };
let visibleCount = allLeaves.length;
let otherNodeCount = 0;
// Collapse the lowest-value visible leaf into an Other bucket until the
// total displayed node count (individual + Other nodes) <= topNcategories.
while (visibleCount + otherNodeCount > topNcategories && visibleCount > 0) {
const minLeaf = allLeaves
.filter(l => l.visible)
.reduce((min, l) => (l.value < min.value ? l : min));
minLeaf.visible = false;
visibleCount -= 1;
if (globalOther) {
if (globalOtherBucket.total === 0) otherNodeCount += 1;
globalOtherBucket.total += minLeaf.value;
globalOtherBucket.entries.push({
name: minLeaf.group,
value: minLeaf.value,
});
} else {
if (!perCategoryOther.has(minLeaf.mainCategory)) otherNodeCount += 1;
const bucket = perCategoryOther.get(minLeaf.mainCategory) ?? {
total: 0,
entries: [],
};
bucket.total += minLeaf.value;
bucket.entries.push({ name: minLeaf.group, value: minLeaf.value });
perCategoryOther.set(minLeaf.mainCategory, bucket);
}
}
// Promote single-entry Other buckets back to visible — a 1-item "Other"
// node wastes a slot and hides information.
if (globalOther) {
if (globalOtherBucket.entries.length === 1) {
const entry = globalOtherBucket.entries[0];
const leaf = allLeaves.find(l => l.group === entry.name && !l.visible);
if (leaf) {
leaf.visible = true;
globalOtherBucket.total = 0;
globalOtherBucket.entries = [];
}
}
} else {
for (const [catName, bucket] of perCategoryOther) {
if (bucket.entries.length === 1) {
const entry = bucket.entries[0];
const leaf = allLeaves.find(
l =>
l.mainCategory === catName && l.group === entry.name && !l.visible,
);
if (leaf) {
leaf.visible = true;
perCategoryOther.delete(catName);
}
}
}
}
return { allLeaves, perCategoryOther, globalOtherBucket };
}
function transformToSankeyData(
categoryData: CategoryEntry[],
toBudgetAmount: number = 0,
rootNodeName: string,
topNcategories: number = 15,
globalOther: boolean = false,
groupSort: 'per-group' | 'global' = 'per-group',
categoryOrder?: CategoryOrder,
): SankeyData {
// Phase 1 — Initialise leaves
const allLeaves: LeafState[] = categoryData
.filter(e => e.value > 0)
.map(e => ({
mainCategory: e.mainCategory,
group: e.group,
value: e.value,
isNegative: e.isNegative ?? false,
visible: true,
}));
// Phase 2 — Greedy reduction (collapse lowest-value leaves into Other buckets)
const { perCategoryOther, globalOtherBucket } = greedyReduceLeaves(
allLeaves,
topNcategories,
globalOther,
);
// Phase 3 — Compute category totals (sum of ALL leaves including collapsed)
const categoryTotals = new Map<string, number>();
for (const leaf of allLeaves) {
categoryTotals.set(
leaf.mainCategory,
(categoryTotals.get(leaf.mainCategory) ?? 0) + leaf.value,
);
}
const sortedCategories = categoryOrder
? categoryOrder
.map(c => c.mainCategory)
.filter(name => categoryTotals.has(name))
.concat(
[...categoryTotals.keys()]
.filter(name => !categoryOrder.some(c => c.mainCategory === name))
.sort(
(a, b) =>
(categoryTotals.get(b) ?? 0) - (categoryTotals.get(a) ?? 0),
),
)
: [...categoryTotals.keys()].sort(
(a, b) => (categoryTotals.get(b) ?? 0) - (categoryTotals.get(a) ?? 0),
);
// Phase 4 — Build nodes/links
const nodes: SankeyNode[] = [
{ name: rootNodeName, toBudget: toBudgetAmount },
];
const links: SankeyLink[] = [];
const catNodeIndexMap = new Map<string, number>();
// Add all category nodes first (needed for global sort so indices are known)
for (const catName of sortedCategories) {
nodes.push({ name: catName });
catNodeIndexMap.set(catName, nodes.length - 1);
links.push({
source: 0,
target: nodes.length - 1,
value: categoryTotals.get(catName) ?? 0,
});
}
if (groupSort === 'global') {
// All visible categories sorted by value globally
const allVisibleLeaves = allLeaves
.filter(l => l.visible)
.sort((a, b) => b.value - a.value);
for (const leaf of allVisibleLeaves) {
const catIdx = catNodeIndexMap.get(leaf.mainCategory) ?? 0;
nodes.push({ name: leaf.group, isNegative: leaf.isNegative });
links.push({
source: catIdx,
target: nodes.length - 1,
value: leaf.value,
isNegative: leaf.isNegative,
});
}
// per-group Other nodes (globalOther=false only)
if (!globalOther) {
for (const catName of sortedCategories) {
const bucket = perCategoryOther.get(catName);
if (bucket) {
const catIdx = catNodeIndexMap.get(catName) ?? 0;
nodes.push({ name: 'Other' });
links.push({
source: catIdx,
target: nodes.length - 1,
value: bucket.total,
tooltipInfo: [...bucket.entries].sort((a, b) => b.value - a.value),
});
}
}
}
} else {
// per-group sort or budget-order: each category's categories sorted independently
for (const catName of sortedCategories) {
const catIdx = catNodeIndexMap.get(catName) ?? 0;
const subcatOrder = categoryOrder?.find(
c => c.mainCategory === catName,
)?.categories;
const visibleLeaves = allLeaves
.filter(l => l.mainCategory === catName && l.visible)
.sort((a, b) => {
if (subcatOrder) {
const ai = subcatOrder.indexOf(a.group);
const bi = subcatOrder.indexOf(b.group);
if (ai === -1 && bi === -1) return b.value - a.value;
if (ai === -1) return 1;
if (bi === -1) return -1;
return ai - bi;
}
return b.value - a.value;
});
for (const leaf of visibleLeaves) {
nodes.push({ name: leaf.group, isNegative: leaf.isNegative });
links.push({
source: catIdx,
target: nodes.length - 1,
value: leaf.value,
isNegative: leaf.isNegative,
});
}
// per-group Other node (globalOther=false only)
if (!globalOther) {
const bucket = perCategoryOther.get(catName);
if (bucket) {
nodes.push({ name: 'Other' });
links.push({
source: catIdx,
target: nodes.length - 1,
value: bucket.total,
tooltipInfo: [...bucket.entries].sort((a, b) => b.value - a.value),
});
}
}
}
}
// Global Other node (globalOther=true only)
if (globalOther && globalOtherBucket.total > 0) {
nodes.push({ name: 'Other' });
const globalOtherIdx = nodes.length - 1;
// Group entries by main category and emit one link per group
const byCategory = new Map<
string,
Array<{ name: string; value: number }>
>();
for (const entry of globalOtherBucket.entries) {
// Find which main category this group belongs to
const leaf = allLeaves.find(l => l.group === entry.name && !l.visible);
if (!leaf) continue;
const group = byCategory.get(leaf.mainCategory) ?? [];
group.push(entry);
byCategory.set(leaf.mainCategory, group);
}
for (const [catName, entries] of byCategory) {
const sourceCatIdx = catNodeIndexMap.get(catName);
if (sourceCatIdx === undefined) continue;
const groupTotal = entries.reduce((sum, e) => sum + e.value, 0);
links.push({
source: sourceCatIdx,
target: globalOtherIdx,
value: groupTotal,
tooltipInfo: [...entries].sort((a, b) => b.value - a.value),
});
}
}
return { nodes, links };
}
export function compactSankeyData(
data: SankeyData,
topN: number = 5,
): SankeyData {
const compactedData: SankeyData = { nodes: [], links: [] };
compactedData.nodes.push(data.nodes[0]); // root node
// Find all root→mainCategory links and sort by value descending
const rootLinks = data.links
.filter(link => link.source === 0)
.sort((a, b) => b.value - a.value);
const topLinks = rootLinks.slice(0, topN - 1);
const otherLinks = rootLinks.slice(topN - 1);
const otherTotal = otherLinks.reduce((sum, link) => sum + link.value, 0);
// Add top category nodes and their links from root
for (const link of topLinks) {
compactedData.nodes.push(data.nodes[link.target]);
compactedData.links.push({
source: 0,
target: compactedData.nodes.length - 1,
value: link.value,
});
}
// Lump remaining categories into a single "Other" node
if (otherTotal > 0) {
compactedData.nodes.push({ name: 'Other' });
compactedData.links.push({
source: 0,
target: compactedData.nodes.length - 1,
value: otherTotal,
tooltipInfo: otherLinks.map(link => ({
name: data.nodes[link.target].name,
value: link.value,
})),
});
}
return compactedData;
}

View File

@@ -196,7 +196,12 @@ export function ExperimentalFeatures() {
>
<Trans>Currency support</Trans>
</FeatureToggle>
<FeatureToggle
flag="sankeyReport"
feedbackLink="https://github.com/actualbudget/actual/issues/1919"
>
<Trans>Sankey report</Trans>
</FeatureToggle>
<FeatureToggle
flag="crossoverReport"
feedbackLink="https://github.com/actualbudget/actual/issues/6134"

View File

@@ -1367,6 +1367,7 @@ export function useTableNavigator<T extends TableItem>(
case 'ArrowUp':
case 'k':
if (e.target.tagName !== 'INPUT') {
e.preventDefault();
onMove('up');
}
break;
@@ -1374,6 +1375,7 @@ export function useTableNavigator<T extends TableItem>(
case 'ArrowDown':
case 'j':
if (e.target.tagName !== 'INPUT') {
e.preventDefault();
onMove('down');
}
break;

View File

@@ -12,6 +12,7 @@ const DEFAULT_FEATURE_FLAG_STATE: Record<FeatureFlag, boolean> = {
customThemes: false,
budgetAnalysisReport: false,
payeeLocations: false,
sankeyReport: false,
};
export function useFeatureFlag(name: FeatureFlag): boolean {

View File

@@ -64,7 +64,7 @@ Heads up! You probably don't want to hard-code the passwords like that, especial
If the serverURL is using [self-signed or custom CA certificates](../config/https.md), additional Node.js configuration will be needed for the connections to succeed.
The API communicates with the server using `node-fetch`, assigned to the `global.fetch` function. There are a few ways to get Node.js to trust the self-signed certificate.
The API communicates with the server using Node's built-in `fetch`. There are a few ways to get Node.js to trust the self-signed certificate.
- Option 1: Point environment variable [NODE_EXTRA_CA_CERTS](https://nodejs.org/api/cli.html#node_extra_ca_certsfile) to the path of a file containing the public certificate.
- Option 2: Set environment variable [NODE_TLS_REJECT_UNAUTHORIZED](https://nodejs.org/api/cli.html#node_tls_reject_unauthorizedvalue) to `0`. Not recommended if your program reaches out to any other endpoints other than the Actual server.

View File

@@ -1,7 +1,6 @@
# The Budget
This view lets you manage your budget for various months. You'll find more information about how to do budgeting with Actual
in the [Budgeting](/docs/budgeting/) part of this manual.
This view lets you manage your budget for various months. You'll find more information about how to do budgeting with Actual in the [Budgeting](/docs/budgeting/) part of this manual.
![](/img/a-tour-of-actual/tour-budget-overview.webp)
@@ -19,8 +18,7 @@ Based on this, you can then choose which months to show:
At the top of each month, you have a couple of choices in the user interface.
- Clicking on the note icon lets you add a note. Actual fully supports Markdown and the note will be
rendered according to your Markdown when the cursor is hovering over the note
- Clicking on the note icon lets you add a note. Actual fully supports Markdown and the note will be rendered according to your Markdown when the cursor is hovering over the note
- You can minimize the header by clicking on the chevrons (seen in the yellow box).
- Clicking on the three vertical dots lets you execute the following functions on that month's budget categories:
- Copy last month's budget.
@@ -35,22 +33,21 @@ When the top is minimized, you can still access the same functionality as when t
![](/img/a-tour-of-actual/tour-budget-top-minimized.webp)
Here's the rendered Markdown when you hover over the note.
![](/img/a-tour-of-actual/tour-budget-top-note-hover.webp)
## The Budget Table
### Left side - category section
The budget detail section lists all your categories and their grouping. The image below shows two expense category
groups, _Really Important_ and _Daily Expenses_, along with the income categories. You can minimize a category group, as seen
with _Daily expenses_.
The budget detail section lists all your categories and their grouping. The image below shows two expense category groups, _Usual Expenses_ and _Bills_, along with the income categories.
Clicking on the three vertical dots (in the yellow box) allows you to Toggle hidden categories or expand or collapse all category groups.
Clicking on the three vertical dots (in the yellow box) allows you to toggle hidden categories, expand all or collapse all category groups.
When you hover over a category group (outlined by the green box), you can add a note by clicking the note icon. As with the note icon in the top section,
we also have full Markdown support here. The dropdown will allow you to add a new category, toggle hide or show the _category group_, rename a group,
and delete the category group.
When you hover over a category group (outlined by the green box), you can add a new category to the group by clicking the + icon or add a note by clicking the note icon. All notes on the Budget page support full Markdown. The dropdown will allow you to toggle between hide or show the _category group_, rename or delete the group.
Let's look at a category (as seen in the purple box). We have the same functionality as on the group level: hide, rename and delete. And, of course, you
can also add a note here, with information specific to the category.
Categories (as seen in the purple box) have the same functionality as groups: hide, rename and delete. You can also add a note here, with information specific to the category.
![](/img/a-tour-of-actual/tour-budget-details.webp)
@@ -65,4 +62,4 @@ We have three columns under a month heading: _Budgeted_, _Spent_, and _Balance_.
- The _Balance_ is the difference between the _Budgeted_ and the _Spent_ columns + what was left over from the previous month (as a rule of thumb).
You work with the _Budgeted_ column to manipulate your budget: You can enter a number or use a dropdown which will populate the entry based on a
_copy of last month's budget_, or the previous 3 or 6-month average, and finally a yearly average.
_copy of last month's budget_, or the previous 3-month, 6-month or yearly average.

View File

@@ -1,10 +1,8 @@
# Payees Management
This view lets you manage your Payees. In the overview, you see which Payees
you have in your system and if they have any associated [rules](./rules).
This view lets you manage your Payees. In the overview, you see which Payees you have in your system and if they have any associated [rules](./rules).
From this view, you can delete and merge payees as you see fit. See the
[Payee section](/docs/transactions/payees) in the manual for more details.
From this view, you can delete and merge payees as you see fit. See the [Payee section](/docs/transactions/payees) in the manual for more details.
If you have any unused payees, they are readily available by clicking on the _Show n unused payee_.
@@ -12,10 +10,8 @@ If you have any unused payees, they are readily available by clicking on the _Sh
## Deleting or merging payees
Select the payees you want to merge or delete and choose the corresponding function in
the dropdown seen inside the red box.
Select the payees you want to merge or delete and choose the corresponding function in the dropdown seen inside the red box.
If you need to undo a merge or a delete operation, you can press <Key mod="Ctrl" k="Z" /> on a
Windows machine, or <Key mod="Cmd" k="Z" /> on a Mac.
If you need to undo a merge or a delete operation, you can press <Key mod="ctrl" fixed k="Z" /> on a Windows machine, or <Key mod="cmd" fixed k="Z" /> on a Mac.
![](/img/a-tour-of-actual/tour-payees-delete-merge.webp)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 32 KiB

View File

@@ -7,14 +7,8 @@
"scripts": {
"test": "vitest --run"
},
"dependencies": {
"requireindex": "^1.2.0"
},
"devDependencies": {
"eslint-vitest-rule-tester": "^3.1.0",
"loupe": "^3.2.1",
"strip-literal": "^3.1.0",
"tinyspy": "^4.0.4",
"vitest": "^4.1.0"
}
}

View File

@@ -90,11 +90,9 @@
"date-fns": "^4.1.0",
"handlebars": "^4.7.9",
"lru-cache": "^11.2.6",
"md5": "^2.3.0",
"memoize-one": "^6.0.0",
"mitt": "^3.0.1",
"promise-retry": "^2.0.1",
"slash": "5.1.0",
"typescript-strict-plugin": "^2.4.4",
"ua-parser-js": "^2.0.9",
"uuid": "^13.0.0"
@@ -107,7 +105,6 @@
"@types/emscripten": "^1.41.5",
"@types/jlongster__sql.js": "npm:@types/sql.js@latest",
"@types/node": "^22.19.15",
"@types/pegjs": "^0.10.6",
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
"assert": "^2.1.0",
"browserify-zlib": "^0.2.0",
@@ -129,7 +126,7 @@
"ts-node": "^10.9.2",
"util": "^0.12.5",
"vite": "^8.0.0",
"vite-plugin-node-polyfills": "^0.25.0",
"vite-plugin-node-polyfills": "^0.26.0",
"vite-plugin-peggy-loader": "^2.0.1",
"vitest": "^4.1.0",
"yargs": "^18.0.0"

View File

@@ -40,6 +40,7 @@ function isWidgetType(type: string): type is DashboardWidgetEntity['type'] {
'calendar-card',
'formula-card',
'custom-report',
'sankey-card',
].includes(type);
}

View File

@@ -1,6 +1,5 @@
// @ts-strict-ignore
import AdmZip from 'adm-zip';
import normalizePathSep from 'slash';
import { v4 as uuidv4 } from 'uuid';
import { logger } from '../../platform/server/log';
@@ -396,7 +395,7 @@ export async function doImport(data: YNAB4.YFull) {
}
export function getBudgetName(filepath) {
let unixFilepath = normalizePathSep(filepath);
let unixFilepath = filepath.replace(/\\/g, '/');
if (!/\.zip/.test(unixFilepath)) {
return null;

View File

@@ -1,5 +1,5 @@
// @ts-strict-ignore
import md5 from 'md5';
import { createHash } from 'node:crypto';
import { makeViews, schema, schemaConfig } from './aql';
import * as db from './db';
@@ -20,7 +20,7 @@ async function updateViews() {
const { value: hash } = row || {};
const views = makeViews(schema, schemaConfig);
const currentHash = md5(views);
const currentHash = createHash('md5').update(views).digest('hex');
if (hash !== currentHash) {
db.execQuery(views);

View File

@@ -201,6 +201,40 @@ describe('Transactions', () => {
expect(data.length).toBe(5);
});
test('partially updating a split parent preserves amount and does not set error', () => {
const transactions = [
makeTransaction({ amount: 2001 }),
...makeSplitTransaction({ id: 't1', amount: 2500 }, [
{ id: 't2', amount: 2000 },
{ id: 't3', amount: 500 },
]),
makeTransaction({ amount: 3002 }),
];
// Simulate a partial update (only `notes`) on the parent — this is
// how `api.updateTransaction(id, { notes: '...' })` calls it in
// `api.ts`: `updateTransaction(transactions, { id, ...fields })`.
const { data, diff } = updateTransaction(transactions, {
id: 't1',
notes: 'updated note',
} as TransactionEntity);
// The parent should get the updated notes without an error
const parent = data.find(d => d.id === 't1');
expect(parent?.notes).toBe('updated note');
expect(parent?.amount).toBe(2500);
expect(parent?.error).toBeNull();
// Children should be unchanged
expect(data.filter(t => t.parent_id === 't1').length).toBe(2);
expect(diff).toEqual({
added: [],
deleted: [],
updated: [expect.objectContaining({ id: 't1', notes: 'updated note' })],
});
});
test('deleting a split transaction works', () => {
const transactions = [
makeTransaction({ amount: 2001 }),

View File

@@ -262,7 +262,8 @@ export function updateTransaction(
) {
return replaceTransactions(transactions, transaction.id, trans => {
if (trans.is_parent) {
const parent = trans.id === transaction.id ? transaction : trans;
const parent =
trans.id === transaction.id ? { ...trans, ...transaction } : trans;
const originalSubtransactions =
parent.subtransactions ?? trans.subtransactions;
const sub = originalSubtransactions?.map(t => {

View File

@@ -114,13 +114,13 @@ type SpecializedWidget =
| MarkdownWidget
| SummaryWidget
| CalendarWidget
| FormulaWidget;
| FormulaWidget
| SankeyWidget;
export type DashboardWidgetEntity = SpecializedWidget | CustomReportWidget;
export type NewDashboardWidgetEntity = Omit<
DashboardWidgetEntity,
'id' | 'tombstone' | 'dashboard_page_id'
>;
// Exported/imported (json) widget definition
export type ExportImportCustomReportWidget = Omit<
CustomReportWidget,
@@ -197,3 +197,16 @@ export type FormulaWidget = AbstractWidget<
>;
} | null
>;
export type SankeyWidget = AbstractWidget<
'sankey-card',
{
name?: string;
conditions?: RuleConditionEntity[];
conditionsOp?: 'and' | 'or';
timeFrame?: TimeFrame;
mode?: 'budgeted' | 'spent';
topNcategories?: number;
categorySort?: 'per-group' | 'global' | 'budget-order';
} | null
>;

View File

@@ -7,7 +7,8 @@ export type FeatureFlag =
| 'crossoverReport'
| 'customThemes'
| 'budgetAnalysisReport'
| 'payeeLocations';
| 'payeeLocations'
| 'sankeyReport';
/**
* Cross-device preferences. These sync across devices when they are changed.

View File

@@ -71,6 +71,7 @@ export default defineConfig(({ mode }) => {
},
plugins: [
peggyLoader(),
// https://github.com/davidmyersdev/vite-plugin-node-polyfills/issues/142
nodePolyfills({
include: [
'process',

View File

@@ -0,0 +1,6 @@
---
category: Features
authors: [emiltb, andrewhumble]
---
Add Sankey diagram report with two view modes (spent and budgeted) to visualize money flow through categories

View File

@@ -0,0 +1,6 @@
---
category: Bugfixes
authors: [lwarrenthompson]
---
Fix `api.updateTransaction()` corrupting split parent transactions when doing partial updates

View File

@@ -0,0 +1,6 @@
---
category: Bugfixes
authors: [JSkinnerUK]
---
Standardise ledger scrolling when using keyboard shortcuts

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [matt-fidd]
---
Remove some unused/unnecessary dependencies

246
yarn.lock
View File

@@ -28,7 +28,6 @@ __metadata:
"@typescript/native-preview": "npm:^7.0.0-dev.20260309.1"
better-sqlite3: "npm:^12.6.2"
compare-versions: "npm:^6.1.1"
node-fetch: "npm:^3.3.2"
rollup-plugin-visualizer: "npm:^6.0.11"
typescript-strict-plugin: "npm:^2.4.4"
uuid: "npm:^13.0.0"
@@ -111,7 +110,6 @@ __metadata:
"@types/emscripten": "npm:^1.41.5"
"@types/jlongster__sql.js": "npm:@types/sql.js@latest"
"@types/node": "npm:^22.19.15"
"@types/pegjs": "npm:^0.10.6"
"@typescript/native-preview": "npm:^7.0.0-dev.20260309.1"
absurd-sql: "npm:0.0.54"
adm-zip: "npm:^0.5.16"
@@ -131,7 +129,6 @@ __metadata:
jest-diff: "npm:^30.2.0"
jsverify: "npm:^0.8.4"
lru-cache: "npm:^11.2.6"
md5: "npm:^2.3.0"
memoize-one: "npm:^6.0.0"
mitt: "npm:^3.0.1"
mockdate: "npm:^3.0.5"
@@ -140,7 +137,6 @@ __metadata:
peggy: "npm:5.1.0"
promise-retry: "npm:^2.0.1"
rollup-plugin-visualizer: "npm:^6.0.11"
slash: "npm:5.1.0"
stream-browserify: "npm:^3.0.0"
timers-browserify: "npm:^2.0.12"
ts-node: "npm:^10.9.2"
@@ -149,7 +145,7 @@ __metadata:
util: "npm:^0.12.5"
uuid: "npm:^13.0.0"
vite: "npm:^8.0.0"
vite-plugin-node-polyfills: "npm:^0.25.0"
vite-plugin-node-polyfills: "npm:^0.26.0"
vite-plugin-peggy-loader: "npm:^2.0.1"
vitest: "npm:^4.1.0"
yargs: "npm:^18.0.0"
@@ -278,10 +274,8 @@ __metadata:
jsdom: "npm:^27.4.0"
lodash: "npm:^4.17.23"
mdast-util-newline-to-break: "npm:^2.0.0"
memoize-one: "npm:^6.0.0"
pikaday: "npm:1.8.2"
promise-retry: "npm:^2.0.1"
prop-types: "npm:^15.8.1"
re-resizable: "npm:^6.11.2"
react: "npm:19.2.4"
react-aria: "npm:^3.46.0"
@@ -10139,13 +10133,6 @@ __metadata:
languageName: node
linkType: hard
"@types/pegjs@npm:^0.10.6":
version: 0.10.6
resolution: "@types/pegjs@npm:0.10.6"
checksum: 10/be219504714e219b37daee7ef3214b6876d98405cc56b2d084763134032fd46394c5d0e387216ee3e52bd519fe7341e25bdec855f2a911c49a593b21fd8ea4a6
languageName: node
linkType: hard
"@types/pikaday@npm:^1.7.10":
version: 1.7.10
resolution: "@types/pikaday@npm:1.7.10"
@@ -11390,7 +11377,6 @@ __metadata:
"@types/prompts": "npm:^2.4.9"
"@typescript/native-preview": "npm:^7.0.0-dev.20260309.1"
"@yarnpkg/types": "npm:^4.0.1"
baseline-browser-mapping: "npm:^2.10.0"
cross-env: "npm:^10.1.0"
eslint: "npm:^9.39.3"
eslint-plugin-perfectionist: "npm:^5.6.0"
@@ -11400,14 +11386,12 @@ __metadata:
lage: "npm:^2.14.19"
lint-staged: "npm:^16.3.2"
minimatch: "npm:^10.2.4"
node-jq: "npm:^6.3.1"
npm-run-all: "npm:^4.1.5"
oxfmt: "npm:^0.32.0"
oxlint: "npm:^1.51.0"
oxlint-tsgolint: "npm:^0.13.0"
p-limit: "npm:^7.3.0"
prompts: "npm:^2.4.2"
source-map-support: "npm:^0.5.21"
ts-node: "npm:^10.9.2"
typescript: "npm:^5.9.3"
languageName: unknown
@@ -12207,15 +12191,6 @@ __metadata:
languageName: node
linkType: hard
"baseline-browser-mapping@npm:^2.10.0":
version: 2.10.8
resolution: "baseline-browser-mapping@npm:2.10.8"
bin:
baseline-browser-mapping: dist/cli.cjs
checksum: 10/820972372c87c65c2e665134d70aa44d5722492fb907aa79170fec84086a75de4675f6a7b717cf0a31b4c4f71cd0289b056b71e32007de97a37973a501d31dcb
languageName: node
linkType: hard
"baseline-browser-mapping@npm:^2.8.19":
version: 2.9.14
resolution: "baseline-browser-mapping@npm:2.9.14"
@@ -12962,13 +12937,6 @@ __metadata:
languageName: node
linkType: hard
"charenc@npm:0.0.2":
version: 0.0.2
resolution: "charenc@npm:0.0.2"
checksum: 10/81dcadbe57e861d527faf6dd3855dc857395a1c4d6781f4847288ab23cffb7b3ee80d57c15bba7252ffe3e5e8019db767757ee7975663ad2ca0939bb8fcaf2e5
languageName: node
linkType: hard
"check-error@npm:^2.1.1":
version: 2.1.3
resolution: "check-error@npm:2.1.3"
@@ -14044,13 +14012,6 @@ __metadata:
languageName: node
linkType: hard
"crypt@npm:0.0.2":
version: 0.0.2
resolution: "crypt@npm:0.0.2"
checksum: 10/2c72768de3d28278c7c9ffd81a298b26f87ecdfe94415084f339e6632f089b43fe039f2c93f612bcb5ffe447238373d93b2e8c90894cba6cfb0ac7a74616f8b9
languageName: node
linkType: hard
"crypto-browserify@npm:^3.12.1":
version: 3.12.1
resolution: "crypto-browserify@npm:3.12.1"
@@ -14794,13 +14755,6 @@ __metadata:
languageName: node
linkType: hard
"data-uri-to-buffer@npm:^4.0.0":
version: 4.0.1
resolution: "data-uri-to-buffer@npm:4.0.1"
checksum: 10/0d0790b67ffec5302f204c2ccca4494f70b4e2d940fea3d36b09f0bb2b8539c2e86690429eb1f1dc4bcc9e4df0644193073e63d9ee48ac9fce79ec1506e4aa4c
languageName: node
linkType: hard
"data-urls@npm:^6.0.0":
version: 6.0.0
resolution: "data-urls@npm:6.0.0"
@@ -16167,10 +16121,6 @@ __metadata:
resolution: "eslint-plugin-actual@workspace:packages/eslint-plugin-actual"
dependencies:
eslint-vitest-rule-tester: "npm:^3.1.0"
loupe: "npm:^3.2.1"
requireindex: "npm:^1.2.0"
strip-literal: "npm:^3.1.0"
tinyspy: "npm:^4.0.4"
vitest: "npm:^4.1.0"
languageName: unknown
linkType: soft
@@ -16898,16 +16848,6 @@ __metadata:
languageName: node
linkType: hard
"fetch-blob@npm:^3.1.2, fetch-blob@npm:^3.1.4":
version: 3.2.0
resolution: "fetch-blob@npm:3.2.0"
dependencies:
node-domexception: "npm:^1.0.0"
web-streams-polyfill: "npm:^3.0.3"
checksum: 10/5264ecceb5fdc19eb51d1d0359921f12730941e333019e673e71eb73921146dceabcb0b8f534582be4497312d656508a439ad0f5edeec2b29ab2e10c72a1f86b
languageName: node
linkType: hard
"figures@npm:^3.2.0":
version: 3.2.0
resolution: "figures@npm:3.2.0"
@@ -17125,15 +17065,6 @@ __metadata:
languageName: node
linkType: hard
"formdata-polyfill@npm:^4.0.10":
version: 4.0.10
resolution: "formdata-polyfill@npm:4.0.10"
dependencies:
fetch-blob: "npm:^3.1.2"
checksum: 10/9b5001d2edef3c9449ac3f48bd4f8cc92e7d0f2e7c1a5c8ba555ad4e77535cc5cf621fabe49e97f304067037282dd9093b9160a3cb533e46420b446c4e6bc06f
languageName: node
linkType: hard
"formidable@npm:^3.5.4":
version: 3.5.4
resolution: "formidable@npm:3.5.4"
@@ -18955,13 +18886,6 @@ __metadata:
languageName: node
linkType: hard
"is-buffer@npm:~1.1.6":
version: 1.1.6
resolution: "is-buffer@npm:1.1.6"
checksum: 10/f63da109e74bbe8947036ed529d43e4ae0c5fcd0909921dce4917ad3ea212c6a87c29f525ba1d17c0858c18331cf1046d4fc69ef59ed26896b25c8288a627133
languageName: node
linkType: hard
"is-callable@npm:^1.2.7":
version: 1.2.7
resolution: "is-callable@npm:1.2.7"
@@ -19042,13 +18966,6 @@ __metadata:
languageName: node
linkType: hard
"is-extglob@npm:^1.0.0":
version: 1.0.0
resolution: "is-extglob@npm:1.0.0"
checksum: 10/5eea8517feeae5206547c0fc838c1416ec763b30093c286e1965a05f46b74a59ad391f912565f3b67c9c31cab4769ab9c35420e016b608acb47309be8d0d6e94
languageName: node
linkType: hard
"is-extglob@npm:^2.1.1":
version: 2.1.1
resolution: "is-extglob@npm:2.1.1"
@@ -19094,15 +19011,6 @@ __metadata:
languageName: node
linkType: hard
"is-glob@npm:^2.0.0":
version: 2.0.1
resolution: "is-glob@npm:2.0.1"
dependencies:
is-extglob: "npm:^1.0.0"
checksum: 10/089f5f93640072491396a5f075ce73e949a90f35832b782bc49a6b7637d58e392d53cb0b395e059ccab70fcb82ff35d183f6f9ebbcb43227a1e02e3fed5430c9
languageName: node
linkType: hard
"is-glob@npm:^4.0.0, is-glob@npm:^4.0.1, is-glob@npm:^4.0.3, is-glob@npm:~4.0.1":
version: 4.0.3
resolution: "is-glob@npm:4.0.3"
@@ -19147,15 +19055,6 @@ __metadata:
languageName: node
linkType: hard
"is-invalid-path@npm:^0.1.0":
version: 0.1.0
resolution: "is-invalid-path@npm:0.1.0"
dependencies:
is-glob: "npm:^2.0.0"
checksum: 10/184dd40d9c7a765506e4fdcd7e664f86de68a4d5d429964b160255fe40de1b4323d1b4e6ea76ff87debf788a330e4f27cb1dfe5fc2420405e1c8a16a6ed87092
languageName: node
linkType: hard
"is-map@npm:^2.0.3":
version: 2.0.3
resolution: "is-map@npm:2.0.3"
@@ -19339,13 +19238,6 @@ __metadata:
languageName: node
linkType: hard
"is-stream@npm:^3.0.0":
version: 3.0.0
resolution: "is-stream@npm:3.0.0"
checksum: 10/172093fe99119ffd07611ab6d1bcccfe8bc4aa80d864b15f43e63e54b7abc71e779acd69afdb854c4e2a67fdc16ae710e370eda40088d1cfc956a50ed82d8f16
languageName: node
linkType: hard
"is-string@npm:^1.1.1":
version: 1.1.1
resolution: "is-string@npm:1.1.1"
@@ -19397,15 +19289,6 @@ __metadata:
languageName: node
linkType: hard
"is-valid-path@npm:^0.1.1":
version: 0.1.1
resolution: "is-valid-path@npm:0.1.1"
dependencies:
is-invalid-path: "npm:^0.1.0"
checksum: 10/d6e716a4a999c75e32ff91ff1ea684fc9e69de05747ec4aaae049460beb971c79f474629dd87a5b4b662691f8323c1920f1b6f1dcdcb39b07082f0ff77b71da6
languageName: node
linkType: hard
"is-weakmap@npm:^2.0.2":
version: 2.0.2
resolution: "is-weakmap@npm:2.0.2"
@@ -19656,13 +19539,6 @@ __metadata:
languageName: node
linkType: hard
"js-tokens@npm:^9.0.1":
version: 9.0.1
resolution: "js-tokens@npm:9.0.1"
checksum: 10/3288ba73bb2023adf59501979fb4890feb6669cc167b13771b226814fde96a1583de3989249880e3f4d674040d1815685db9a9880db9153307480d39dc760365
languageName: node
linkType: hard
"js-yaml@npm:^3.13.1":
version: 3.14.2
resolution: "js-yaml@npm:3.14.2"
@@ -20458,7 +20334,7 @@ __metadata:
languageName: node
linkType: hard
"loupe@npm:^3.1.0, loupe@npm:^3.1.4, loupe@npm:^3.2.1":
"loupe@npm:^3.1.0, loupe@npm:^3.1.4":
version: 3.2.1
resolution: "loupe@npm:3.2.1"
checksum: 10/a4d78ec758aaa04e0e35d5cd1c15e970beb9cdbfd3d0f34f98b9bcda489f896a7190b3b6cc40b7a6dcb8e97e82e96eafaae10096aaa469804acdba6f7c2bde5f
@@ -20670,17 +20546,6 @@ __metadata:
languageName: node
linkType: hard
"md5@npm:^2.3.0":
version: 2.3.0
resolution: "md5@npm:2.3.0"
dependencies:
charenc: "npm:0.0.2"
crypt: "npm:0.0.2"
is-buffer: "npm:~1.1.6"
checksum: 10/88dce9fb8df1a084c2385726dcc18c7f54e0b64c261b5def7cdfe4928c4ee1cd68695c34108b4fab7ecceb05838c938aa411c6143df9fdc0026c4ddb4e4e72fa
languageName: node
linkType: hard
"mdast-util-directive@npm:^3.0.0":
version: 3.1.0
resolution: "mdast-util-directive@npm:3.1.0"
@@ -22161,13 +22026,6 @@ __metadata:
languageName: node
linkType: hard
"node-domexception@npm:^1.0.0":
version: 1.0.0
resolution: "node-domexception@npm:1.0.0"
checksum: 10/e332522f242348c511640c25a6fc7da4f30e09e580c70c6b13cb0be83c78c3e71c8d4665af2527e869fc96848924a4316ae7ec9014c091e2156f41739d4fa233
languageName: node
linkType: hard
"node-emoji@npm:^2.1.0":
version: 2.2.0
resolution: "node-emoji@npm:2.2.0"
@@ -22180,17 +22038,6 @@ __metadata:
languageName: node
linkType: hard
"node-fetch@npm:^3.3.2":
version: 3.3.2
resolution: "node-fetch@npm:3.3.2"
dependencies:
data-uri-to-buffer: "npm:^4.0.0"
fetch-blob: "npm:^3.1.4"
formdata-polyfill: "npm:^4.0.10"
checksum: 10/24207ca8c81231c7c59151840e3fded461d67a31cf3e3b3968e12201a42f89ce4a0b5fb7079b1fa0a4655957b1ca9257553200f03a9f668b45ebad265ca5593d
languageName: node
linkType: hard
"node-forge@npm:^1":
version: 1.4.0
resolution: "node-forge@npm:1.4.0"
@@ -22229,21 +22076,6 @@ __metadata:
languageName: node
linkType: hard
"node-jq@npm:^6.3.1":
version: 6.3.1
resolution: "node-jq@npm:6.3.1"
dependencies:
is-valid-path: "npm:^0.1.1"
strip-final-newline: "npm:^2.0.0"
tar: "npm:^7.4.0"
tempy: "npm:^3.1.0"
zod: "npm:^3.23.8"
bin:
node-jq: node-jq
checksum: 10/586854a607865c9e056e511265d708c7a69ccad05114a50aff54ab1536b95418ed4e0133351547be17c653509851636b4eb1fb49ffd85d1d0e894dddb7692361
languageName: node
linkType: hard
"node-releases@npm:^2.0.26":
version: 2.0.27
resolution: "node-releases@npm:2.0.27"
@@ -25832,13 +25664,6 @@ __metadata:
languageName: node
linkType: hard
"requireindex@npm:^1.2.0":
version: 1.2.0
resolution: "requireindex@npm:1.2.0"
checksum: 10/266d1cb31f6cbc4b6cf2e898f5bbc45581f7919bcf61bba5c45d0adb69b722b9ff5a13727be3350cde4520d7cd37f39df45d58a29854baaa4552cd6b05ae4a1a
languageName: node
linkType: hard
"requires-port@npm:^1.0.0":
version: 1.0.0
resolution: "requires-port@npm:1.0.0"
@@ -26954,13 +26779,6 @@ __metadata:
languageName: node
linkType: hard
"slash@npm:5.1.0":
version: 5.1.0
resolution: "slash@npm:5.1.0"
checksum: 10/2c41ec6fb1414cd9bba0fa6b1dd00e8be739e3fe85d079c69d4b09ca5f2f86eafd18d9ce611c0c0f686428638a36c272a6ac14799146a8295f259c10cc45cde4
languageName: node
linkType: hard
"slash@npm:^3.0.0":
version: 3.0.0
resolution: "slash@npm:3.0.0"
@@ -27091,7 +26909,7 @@ __metadata:
languageName: node
linkType: hard
"source-map-support@npm:^0.5.19, source-map-support@npm:^0.5.21, source-map-support@npm:~0.5.20":
"source-map-support@npm:^0.5.19, source-map-support@npm:~0.5.20":
version: 0.5.21
resolution: "source-map-support@npm:0.5.21"
dependencies:
@@ -27614,15 +27432,6 @@ __metadata:
languageName: node
linkType: hard
"strip-literal@npm:^3.1.0":
version: 3.1.0
resolution: "strip-literal@npm:3.1.0"
dependencies:
js-tokens: "npm:^9.0.1"
checksum: 10/6eb00906a1c343a1050579d1d6023e067a2d72152edb92e64cad49535115beb2e77905ace24aa459f29b66e75edba75ef9d8eca90575b0322640d64a5d37e131
languageName: node
linkType: hard
"style-mod@npm:^4.0.0, style-mod@npm:^4.1.0":
version: 4.1.2
resolution: "style-mod@npm:4.1.2"
@@ -27869,7 +27678,7 @@ __metadata:
languageName: node
linkType: hard
"tar@npm:^7.4.0, tar@npm:^7.4.3":
"tar@npm:^7.4.3":
version: 7.5.1
resolution: "tar@npm:7.5.1"
dependencies:
@@ -27898,13 +27707,6 @@ __metadata:
languageName: node
linkType: hard
"temp-dir@npm:^3.0.0":
version: 3.0.0
resolution: "temp-dir@npm:3.0.0"
checksum: 10/577211e995d1d584dd60f1469351d45e8a5b4524e4a9e42d3bdd12cfde1d0bb8f5898311bef24e02aaafb69514c1feb58c7b4c33dcec7129da3b0861a4ca935b
languageName: node
linkType: hard
"temp-file@npm:^3.4.0":
version: 3.4.0
resolution: "temp-file@npm:3.4.0"
@@ -27927,18 +27729,6 @@ __metadata:
languageName: node
linkType: hard
"tempy@npm:^3.1.0":
version: 3.1.0
resolution: "tempy@npm:3.1.0"
dependencies:
is-stream: "npm:^3.0.0"
temp-dir: "npm:^3.0.0"
type-fest: "npm:^2.12.2"
unique-string: "npm:^3.0.0"
checksum: 10/f5540bc24dcd9d41ab0b31e9eed73c3ef825080f1c8b1e854e4b73059155c889f72f5f7c15e8cd462d59aa10c9726e423c81d6a365d614b538c6cc78a1209cc6
languageName: node
linkType: hard
"terser-webpack-plugin@npm:^5.3.11, terser-webpack-plugin@npm:^5.3.9":
version: 5.3.14
resolution: "terser-webpack-plugin@npm:5.3.14"
@@ -28122,7 +27912,7 @@ __metadata:
languageName: node
linkType: hard
"tinyspy@npm:^4.0.3, tinyspy@npm:^4.0.4":
"tinyspy@npm:^4.0.3":
version: 4.0.4
resolution: "tinyspy@npm:4.0.4"
checksum: 10/858a99e3ded2fba8fe7c243099d9e58e926d6525af03d19cdf86c1a9a30398161fb830b4f77890d266bcc1c69df08fa6f4baf29d089385e4cdaa98d7b6296e7c
@@ -28424,7 +28214,7 @@ __metadata:
languageName: node
linkType: hard
"type-fest@npm:^2.12.2, type-fest@npm:^2.13.0, type-fest@npm:^2.5.0":
"type-fest@npm:^2.13.0, type-fest@npm:^2.5.0":
version: 2.19.0
resolution: "type-fest@npm:2.19.0"
checksum: 10/7bf9e8fdf34f92c8bb364c0af14ca875fac7e0183f2985498b77be129dc1b3b1ad0a6b3281580f19e48c6105c037fb966ad9934520c69c6434d17fd0af4eed78
@@ -29252,15 +29042,15 @@ __metadata:
languageName: node
linkType: hard
"vite-plugin-node-polyfills@npm:^0.25.0":
version: 0.25.0
resolution: "vite-plugin-node-polyfills@npm:0.25.0"
"vite-plugin-node-polyfills@npm:^0.26.0":
version: 0.26.0
resolution: "vite-plugin-node-polyfills@npm:0.26.0"
dependencies:
"@rollup/plugin-inject": "npm:^5.0.5"
node-stdlib-browser: "npm:^1.3.1"
peerDependencies:
vite: ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
checksum: 10/4e49d2a8143a60962559180f5aa2a8360041ed20f5782d3f8287eb7d70401f763b394caf494a7356f8dfd2806901afc6ea0a4ceb30451d846abc9ee3a508ffd6
vite: ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
checksum: 10/538076561ccfe16e6aa24f7fe9fdb86e0e23ac066fc42b4a6e8af491b2c5d7e3e4a5344694355015d684e2faa69f92e20978b1a1b944770e0d3b8acfea53cbe8
languageName: node
linkType: hard
@@ -29556,13 +29346,6 @@ __metadata:
languageName: node
linkType: hard
"web-streams-polyfill@npm:^3.0.3":
version: 3.3.3
resolution: "web-streams-polyfill@npm:3.3.3"
checksum: 10/8e7e13501b3834094a50abe7c0b6456155a55d7571312b89570012ef47ec2a46d766934768c50aabad10a9c30dd764a407623e8bfcc74fcb58495c29130edea9
languageName: node
linkType: hard
"webidl-conversions@npm:^4.0.2":
version: 4.0.2
resolution: "webidl-conversions@npm:4.0.2"
@@ -30460,13 +30243,6 @@ __metadata:
languageName: node
linkType: hard
"zod@npm:^3.23.8":
version: 3.25.76
resolution: "zod@npm:3.25.76"
checksum: 10/f0c963ec40cd96858451d1690404d603d36507c1fc9682f2dae59ab38b578687d542708a7fdbf645f77926f78c9ed558f57c3d3aa226c285f798df0c4da16995
languageName: node
linkType: hard
"zod@npm:^4.1.8":
version: 4.1.12
resolution: "zod@npm:4.1.12"