Compare commits
7 Commits
pr-7454
...
revert-728
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0412491bd1 | ||
|
|
2ccdf0f6e8 | ||
|
|
b96eec51ca | ||
|
|
e5bca59bc6 | ||
|
|
b756332583 | ||
|
|
e1a4fde2f3 | ||
|
|
af5c068991 |
6
.github/workflows/build.yml
vendored
@@ -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
|
||||
|
||||
4
.github/workflows/size-compare.yml
vendored
@@ -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
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
@@ -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
|
||||
|
||||

|
||||
|
||||
Here's the rendered Markdown when you hover over the note.
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
|
||||

|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 11 KiB |
BIN
packages/docs/static/img/a-tour-of-actual/tour-budget-top-note-hover.webp
vendored
Normal file
|
After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 32 KiB |
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -40,6 +40,7 @@ function isWidgetType(type: string): type is DashboardWidgetEntity['type'] {
|
||||
'calendar-card',
|
||||
'formula-card',
|
||||
'custom-report',
|
||||
'sankey-card',
|
||||
].includes(type);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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
|
||||
>;
|
||||
|
||||
@@ -7,7 +7,8 @@ export type FeatureFlag =
|
||||
| 'crossoverReport'
|
||||
| 'customThemes'
|
||||
| 'budgetAnalysisReport'
|
||||
| 'payeeLocations';
|
||||
| 'payeeLocations'
|
||||
| 'sankeyReport';
|
||||
|
||||
/**
|
||||
* Cross-device preferences. These sync across devices when they are changed.
|
||||
|
||||
@@ -71,6 +71,7 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
plugins: [
|
||||
peggyLoader(),
|
||||
// https://github.com/davidmyersdev/vite-plugin-node-polyfills/issues/142
|
||||
nodePolyfills({
|
||||
include: [
|
||||
'process',
|
||||
|
||||
6
upcoming-release-notes/7220.md
Normal 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
|
||||
6
upcoming-release-notes/7242.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Bugfixes
|
||||
authors: [lwarrenthompson]
|
||||
---
|
||||
|
||||
Fix `api.updateTransaction()` corrupting split parent transactions when doing partial updates
|
||||
6
upcoming-release-notes/7283.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Bugfixes
|
||||
authors: [JSkinnerUK]
|
||||
---
|
||||
|
||||
Standardise ledger scrolling when using keyboard shortcuts
|
||||
6
upcoming-release-notes/7350.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [matt-fidd]
|
||||
---
|
||||
|
||||
Remove some unused/unnecessary dependencies
|
||||
246
yarn.lock
@@ -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"
|
||||
|
||||