diff --git a/packages/desktop-client/e2e/reports.test.ts-snapshots/Reports-custom-reports-Switches-to-Line-Graph-and-checks-the-visuals-1-chromium-linux.png b/packages/desktop-client/e2e/reports.test.ts-snapshots/Reports-custom-reports-Switches-to-Line-Graph-and-checks-the-visuals-1-chromium-linux.png index 7a4d3a86e4..b06bcdd91f 100644 Binary files a/packages/desktop-client/e2e/reports.test.ts-snapshots/Reports-custom-reports-Switches-to-Line-Graph-and-checks-the-visuals-1-chromium-linux.png and b/packages/desktop-client/e2e/reports.test.ts-snapshots/Reports-custom-reports-Switches-to-Line-Graph-and-checks-the-visuals-1-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/reports.test.ts-snapshots/Reports-custom-reports-Validates-that-show-summary-button-shows-the-summary-1-chromium-linux.png b/packages/desktop-client/e2e/reports.test.ts-snapshots/Reports-custom-reports-Validates-that-show-summary-button-shows-the-summary-1-chromium-linux.png index 8a41549fc3..8de9c03236 100644 Binary files a/packages/desktop-client/e2e/reports.test.ts-snapshots/Reports-custom-reports-Validates-that-show-summary-button-shows-the-summary-1-chromium-linux.png and b/packages/desktop-client/e2e/reports.test.ts-snapshots/Reports-custom-reports-Validates-that-show-summary-button-shows-the-summary-1-chromium-linux.png differ diff --git a/packages/desktop-client/src/components/reports/graphs/SankeyGraph.tsx b/packages/desktop-client/src/components/reports/graphs/SankeyGraph.tsx index 1e3438f0f8..c18e0ed9fb 100644 --- a/packages/desktop-client/src/components/reports/graphs/SankeyGraph.tsx +++ b/packages/desktop-client/src/components/reports/graphs/SankeyGraph.tsx @@ -13,20 +13,22 @@ import { } from 'recharts'; import type { SankeyData } from 'recharts/types/chart/Sankey'; -import { getColorScale } from '#components/reports/chart-theme'; import { Container } from '#components/reports/Container'; import { useFormat } from '#hooks/useFormat'; import { usePrivacyMode } from '#hooks/usePrivacyMode'; type SankeyGraphNode = SankeyData['nodes'][number] & { - hasChildren?: boolean; - isCollapsed?: boolean; - toBudget?: number; - isNegative?: boolean; - actualValue?: number; + value: number; percentageLabel?: string; - targetLinks?: Array>; - sourceLinks?: Array>; + key: string; + color?: string; +}; + +type SankeyLinkPayload = { + source: SankeyGraphNode; + target: SankeyGraphNode; + value: number; + color?: string; }; type SankeyLinkProps = { @@ -38,16 +40,10 @@ type SankeyLinkProps = { targetControlX: number; linkWidth: number; index: number; - payload: { - source: SankeyGraphNode; - target: SankeyGraphNode; - value: number; - isNegative?: boolean; - }; + payload: SankeyLinkPayload; isHovered: boolean; onMouseEnter: () => void; onMouseLeave: () => void; - color: string; }; function SankeyLink({ @@ -62,9 +58,11 @@ function SankeyLink({ isHovered, onMouseEnter, onMouseLeave, - color, }: SankeyLinkProps) { - const linkColor = payload.isNegative ? theme.errorText : color; + if (payload.value <= 0) { + return null; + } + const linkColor = payload.color ?? theme.reportsGray; const strokeWidth = linkWidth; const strokeOpacity = isHovered ? 1 : 0.6; @@ -91,8 +89,8 @@ type SankeyNodeProps = { index: number; payload: SankeyGraphNode; containerWidth: number; - containerHeight: number; showPercentages?: boolean; + color?: string; }; function SankeyNode({ x, @@ -102,21 +100,17 @@ function SankeyNode({ index: _index, payload, containerWidth, - containerHeight, showPercentages, }: SankeyNodeProps) { const privacyMode = usePrivacyMode(); const format = useFormat(); + + if (payload.value <= 0) { + return null; + } const isOut = x + width + 6 > containerWidth; - const fillColor = payload.isNegative ? theme.errorText : theme.reportsBlue; - - const toBudget = payload.toBudget ?? 0; - const availableBelow = Math.max(0, containerHeight - 25 - (y + height)); - const proportionalHeight = - toBudget > 0 && payload.value ? height * (toBudget / payload.value) : 0; - const isClamped = proportionalHeight > availableBelow; - const toBudgetHeight = Math.min(proportionalHeight, availableBelow); + const fillColor = payload.color ?? theme.reportsBlue; const renderText = ( text: string, @@ -142,27 +136,6 @@ function SankeyNode({ return ( - {toBudgetHeight > 0 && - (isClamped ? ( - - ) : ( - - ))} {renderText(payload.name || '', height / 2)} {renderText( showPercentages && payload.percentageLabel @@ -173,24 +146,6 @@ function SankeyNode({ 0.5, privacyMode ? t('Redacted Script') : undefined, )} - {toBudgetHeight > 0 && - renderText( - format(toBudget, 'financial'), - toBudgetHeight / 2 + 13, - 11, - 0.5, - privacyMode ? t('Redacted Script') : undefined, - y + height, - )} - {toBudgetHeight > 0 && - renderText( - t('To budget'), - toBudgetHeight / 2, - 13, - 1, - undefined, - y + height, - )} ); } @@ -199,7 +154,6 @@ type SankeyGraphProps = { style?: CSSProperties; data: SankeyData; showTooltip?: boolean; - collapsedNodes?: string[]; showPercentages?: boolean; }; export function SankeyGraph({ @@ -212,19 +166,6 @@ export function SankeyGraph({ const format = useFormat(); const [hoveredLinkIndex, setHoveredLinkIndex] = useState(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 ( {(width, height) => ( @@ -235,7 +176,6 @@ export function SankeyGraph({ )} @@ -245,10 +185,6 @@ export function SankeyGraph({ isHovered={hoveredLinkIndex === props.index} onMouseEnter={() => setHoveredLinkIndex(props.index)} onMouseLeave={() => setHoveredLinkIndex(null)} - color={ - sourceColorMap.get(props.payload.source.name) ?? - theme.reportsGray - } /> )} sort={false} diff --git a/packages/desktop-client/src/components/reports/reports/Sankey.tsx b/packages/desktop-client/src/components/reports/reports/Sankey.tsx index 98cca58f91..207b52e561 100644 --- a/packages/desktop-client/src/components/reports/reports/Sankey.tsx +++ b/packages/desktop-client/src/components/reports/reports/Sankey.tsx @@ -4,7 +4,13 @@ import { useParams } from 'react-router'; import { Button } from '@actual-app/components/button'; import { useResponsive } from '@actual-app/components/hooks/useResponsive'; -import { SvgArrowDown, SvgList } from '@actual-app/components/icons/v1'; +import { + SvgArrowDown, + SvgCheveronRight, + SvgLayers, + SvgList, + SvgRefresh, +} from '@actual-app/components/icons/v1'; import { Menu } from '@actual-app/components/menu'; import { Paragraph } from '@actual-app/components/paragraph'; import { Popover } from '@actual-app/components/popover'; @@ -32,8 +38,9 @@ import { LoadingIndicator } from '#components/reports/LoadingIndicator'; import { ModeButton } from '#components/reports/ModeButton'; import { calculateTimeRange } from '#components/reports/reportRanges'; import { + GRAPH_LAYER_ORDER, + GraphLayers, createSpreadsheet as sankeySpreadsheet, - withPercentageLabels, } from '#components/reports/spreadsheets/sankey-spreadsheet'; import { useReport } from '#components/reports/useReport'; import { fromDateRepr } from '#components/reports/util'; @@ -166,6 +173,91 @@ function CategorySortSelector({ value, onChange }: CategorySortSelectorProps) { ); } +type LayerSelectorProps = { + direction: 'from' | 'to'; + value: GraphLayers; + otherLayer: GraphLayers | undefined; + onChange: (layer: GraphLayers) => void; + graphMode: GraphMode; +}; + +function LayerSelector({ + direction, + value, + otherLayer, + onChange, + graphMode, +}: LayerSelectorProps) { + const { t } = useTranslation(); + const triggerRef = useRef(null); + const [isOpen, setIsOpen] = useState(false); + + const LAYER_LABELS: Record = { + [GraphLayers.IncomePayee]: t('Payee'), + [GraphLayers.IncomeCategory]: t('Income category'), + [GraphLayers.Account]: t('Account'), + [GraphLayers.Budget]: t('Budget'), + [GraphLayers.CategoryGroup]: t('Category group'), + [GraphLayers.Category]: t('Category'), + }; + + // Filter available layers based on graph mode + const availableLayers: readonly GraphLayers[] = + graphMode === 'budgeted' + ? GRAPH_LAYER_ORDER.filter( + layer => layer !== GraphLayers.IncomePayee, // IncomePayee not available in budgeted + ) + : GRAPH_LAYER_ORDER.filter( + layer => layer !== GraphLayers.Budget, // Budget not available in spent + ); + + const otherIndex = + otherLayer !== undefined && availableLayers.includes(otherLayer) + ? availableLayers.indexOf(otherLayer) + : direction === 'from' + ? availableLayers.length - 1 + : 0; + + const menuItems = + direction === 'from' + ? availableLayers.slice(0, otherIndex) + : availableLayers.slice(otherIndex + 1); + + const translatedDirection = direction === 'from' ? t('from') : t('to'); + + return ( + <> + + setIsOpen(false)} + > + { + onChange(item as GraphLayers); + setIsOpen(false); + }} + items={menuItems.map(layer => ({ + name: layer, + text: LAYER_LABELS[layer], + }))} + /> + + + ); +} + type GraphModeSelectorProps = { mode: GraphMode; onChange: (mode: GraphMode) => void; @@ -296,6 +388,62 @@ function SankeyInner({ widget }: SankeyInnerProps) { widget?.meta?.showPercentages ?? false, ); + // Determine default layer based on mode + const defaultLayerFrom = (mode: GraphMode) => + mode === 'budgeted' ? GraphLayers.IncomeCategory : GraphLayers.IncomePayee; + + const [layerFrom, setLayerFrom] = useState(() => { + const metaLayer = widget?.meta?.layerFrom as GraphLayers | undefined; + if (metaLayer) { + // Validate that the layer is valid for the current mode + const mode = widget?.meta?.mode ?? 'spent'; + if (mode === 'budgeted' && metaLayer === GraphLayers.IncomePayee) { + return defaultLayerFrom('budgeted'); + } + if (mode === 'spent' && metaLayer === GraphLayers.Budget) { + return defaultLayerFrom('spent'); + } + return metaLayer; + } + return defaultLayerFrom(widget?.meta?.mode ?? 'spent'); + }); + + const [layerTo, setLayerTo] = useState(() => { + const metaLayer = widget?.meta?.layerTo as GraphLayers | undefined; + if (metaLayer) { + // Validate that the layer is valid for the current mode + const mode = widget?.meta?.mode ?? 'spent'; + if (mode === 'budgeted' && metaLayer === GraphLayers.IncomePayee) { + return GraphLayers.Category; + } + if (mode === 'spent' && metaLayer === GraphLayers.Budget) { + return GraphLayers.Category; + } + return metaLayer; + } + return GraphLayers.Category; + }); + + // Reset invalid layer selections when switching modes + useEffect(() => { + const availableLayers = + graphMode === 'budgeted' + ? (GRAPH_LAYER_ORDER.filter( + layer => layer !== GraphLayers.IncomePayee, + ) as GraphLayers[]) + : (GRAPH_LAYER_ORDER.filter( + layer => layer !== GraphLayers.Budget, + ) as GraphLayers[]); + + const fromIndex = availableLayers.indexOf(layerFrom); + const toIndex = availableLayers.indexOf(layerTo); + + if (fromIndex === -1 || toIndex === -1 || fromIndex >= toIndex) { + setLayerFrom(defaultLayerFrom(graphMode)); + setLayerTo(GraphLayers.Category); + } + }, [graphMode, layerFrom, layerTo]); + const { data: { grouped: groupedCategories = [] } = { grouped: [] } } = useCategories(); @@ -313,6 +461,8 @@ function SankeyInner({ widget }: SankeyInnerProps) { graphMode, topNcategories, categorySort, + layerFrom, + layerTo, ); }, [ datesInitialized, @@ -324,6 +474,8 @@ function SankeyInner({ widget }: SankeyInnerProps) { graphMode, topNcategories, categorySort, + layerFrom, + layerTo, ]); const defaultGetData = async ( @@ -418,6 +570,8 @@ function SankeyInner({ widget }: SankeyInnerProps) { topNcategories, categorySort, showPercentages, + layerFrom, + layerTo, timeFrame: { start, end, @@ -549,6 +703,46 @@ function SankeyInner({ widget }: SankeyInnerProps) { value={categorySort} onChange={setCategorySort} /> + + + + + + } > @@ -606,11 +800,7 @@ function SankeyInner({ widget }: SankeyInnerProps) { {data && data.links && data.links.length > 0 ? ( ) : ( diff --git a/packages/desktop-client/src/components/reports/reports/SankeyCard.tsx b/packages/desktop-client/src/components/reports/reports/SankeyCard.tsx index d2e306ab39..87e2b31f83 100644 --- a/packages/desktop-client/src/components/reports/reports/SankeyCard.tsx +++ b/packages/desktop-client/src/components/reports/reports/SankeyCard.tsx @@ -14,9 +14,9 @@ import { ReportCard } from '#components/reports/ReportCard'; import { ReportCardName } from '#components/reports/ReportCardName'; import { calculateTimeRange } from '#components/reports/reportRanges'; import { - compactSankeyData, + GraphLayers, + // compactSankeyData, createSpreadsheet as sankeySpreadsheet, - withPercentageLabels, } from '#components/reports/spreadsheets/sankey-spreadsheet'; import { useDashboardWidgetCopyMenu } from '#components/reports/useDashboardWidgetCopyMenu'; import { useReport } from '#components/reports/useReport'; @@ -56,6 +56,13 @@ export function SankeyCard({ setCardHeight(rect.height); }); + const HEADER_HEIGHT = 82; + const PX_PER_NODE = 50; + const topN = Math.max( + 2, + Math.floor((cardHeight - HEADER_HEIGHT) / PX_PER_NODE), + ); + const params = useMemo( () => sankeySpreadsheet( @@ -65,22 +72,25 @@ export function SankeyCard({ meta?.conditions ?? [], meta?.conditionsOp ?? 'and', mode, + topN, + meta?.categorySort, + GraphLayers.IncomePayee, + GraphLayers.CategoryGroup, ), - [start, end, groupedCategories, meta?.conditions, meta?.conditionsOp, mode], + [ + start, + end, + groupedCategories, + meta?.conditions, + meta?.conditionsOp, + mode, + topN, + meta?.categorySort, + ], ); const data = useReport('sankey', params); - const HEADER_HEIGHT = 82; - const PX_PER_NODE = 50; - const topN = Math.max( - 2, - Math.floor((cardHeight - HEADER_HEIGHT) / PX_PER_NODE), - ); - - const compactData = useMemo( - () => (data ? compactSankeyData(data, topN) : null), - [data, topN], - ); + const compactData = useMemo(() => data, [data]); const startDate = d.parseISO(start); const endDate = d.parseISO(end); @@ -154,11 +164,7 @@ export function SankeyCard({ {compactData ? ( ; + forNextMonth: number; + startMonth: string; + endMonth: string; }; type SankeyNode = { name: string; - toBudget?: number; - isNegative?: boolean; percentageLabel?: string; + key: string; + color?: string; }; type SankeyLink = { source: number; target: number; value: number; - isNegative?: boolean; tooltipInfo?: Array<{ name: string; value: number }>; + color?: string; }; type SankeyData = { @@ -58,13 +68,285 @@ type SankeyData = { }; type CategoryEntry = { - mainCategory: string; - group: string; + categoryGroup: string; + categoryGroupId: string; + category: string; + categoryId: string; value: number; - isNegative?: boolean; + isIncome: boolean; + accountName?: string; + accountId?: string; + payeeName?: string; + payeeId?: string; }; -type CategoryOrder = Array<{ mainCategory: string; categories: string[] }>; +type SortMode = 'per-group' | 'global' | 'budget-order'; + +type NodeKey = string; +type NodeData = { + to: Map; + value?: number; + type: string; + name?: string; + labelKey?: string; + labelParams?: Record; + isOverbudgeted?: boolean; + tooltipInfo?: Array<{ name: string; value: number }>; + percentageLabel?: string; + color?: string; +}; +type Graph = Map; + +const SpecialNodeKeys = { + ToBudget: 'to_budget', + Budgeted: 'budgeted', + LastMonthOverspent: 'last_month_overspent', + ForNextMonth: 'for_next_month', + FromPrevMonth: 'from_previous_month', + AvailableIncome: 'available_income', + GlobalOther: 'GLOBAL__OTHER_BUCKET', + OtherSuffix: '__OTHER_BUCKET', + HiddenSuffix: '__HIDDEN', +} as const; +type SpecialNodeKeys = (typeof SpecialNodeKeys)[keyof typeof SpecialNodeKeys]; + +export const GraphLayers = { + IncomePayee: 'payee', + IncomeCategory: 'income_category', + Account: 'account', + Budget: 'budget', + CategoryGroup: 'category_group', + Category: 'category', +} as const; +export type GraphLayers = (typeof GraphLayers)[keyof typeof GraphLayers]; + +export const GRAPH_LAYER_ORDER = [ + GraphLayers.IncomePayee, + GraphLayers.IncomeCategory, + GraphLayers.Account, + GraphLayers.Budget, + GraphLayers.CategoryGroup, + GraphLayers.Category, +] as const; + +function isGraphLayer(value: unknown): value is GraphLayers { + return ( + typeof value === 'string' && + (Object.values(GraphLayers) as string[]).includes(value) + ); +} + +export function createSpreadsheet( + start: string, + end: string, + categories: CategoryGroupEntity[], + conditions: RuleConditionEntity[] = [], + conditionsOp: 'and' | 'or' = 'and', + mode: 'budgeted' | 'spent' = 'spent', + topNcategories: number = 15, + categorySort: SortMode = 'per-group', + layerFrom: GraphLayers, + layerTo: GraphLayers, +) { + return async ( + spreadsheet: ReturnType, + setData: (data: ReturnType) => void, + ) => { + let data: CategoryEntry[] = []; + let aggregated: AggregatedBudget | undefined; + if (mode === 'budgeted') { + ({ data, aggregated } = await createBudgetSpreadsheet( + start, + end, + conditions, + conditionsOp, + )()); + } else if (mode === 'spent') { + data = await createTransactionsSpreadsheet( + start, + end, + categories, + conditions, + conditionsOp, + )(); + } + processGraphData( + data, + topNcategories, + categories, + categorySort, + setData, + layerFrom, + layerTo, + aggregated, + ); + }; +} + +export function createBudgetSpreadsheet( + start: string, + end: string, + conditions: RuleConditionEntity[] = [], + conditionsOp: 'and' | 'or' = 'and', +) { + return async () => { + 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, + ), + ); + + const accumulate_months = monthResponses.reduce( + (acc, response, index) => { + if (index === monthResponses.length - 1) { + acc.toBudget = response.toBudget; + } + if (index === 0) { + acc.fromPreviousMonth = response.fromLastMonth; + } + acc.lastMonthOverspent += response.lastMonthOverspent; + + 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); + existingCat.received = + (existingCat.received ?? 0) + (cat.received ?? 0); + } + } + + return acc; + }, + { + toBudget: 0, + fromPreviousMonth: 0, + lastMonthOverspent: 0, + categoryGroupsMap: new Map(), + }, + ); + + const categoryGroups = Array.from( + accumulate_months.categoryGroupsMap.values(), + ); + + const filteredCategoryGroups = filterCategoryGroups( + categoryGroups, + conditions, + conditionsOp, + ); + + const categoryData: CategoryEntry[] = filteredCategoryGroups + .flatMap(group => + group.categories.map(cat => { + const rawValue = group.is_income + ? (cat.received ?? 0) + : (cat.budgeted ?? 0); + return { + categoryGroup: group.name, + categoryGroupId: group.id, + category: cat.name, + categoryId: cat.id, + isIncome: group.is_income ? rawValue > 0 : rawValue < 0, + value: Math.abs(rawValue), + }; + }), + ) + .filter(entry => entry.value > 0); + + const nextMonthResponse = (await send('api/budget-month', { + month: monthUtils.nextMonth(end), + })) as unknown as BudgetMonthResponse; + + const aggregated: AggregatedBudget = { + toBudget: accumulate_months.toBudget, + forNextMonth: + (nextMonthResponse.fromLastMonth ?? 0) - accumulate_months.toBudget, + fromPreviousMonth: accumulate_months.fromPreviousMonth, + lastMonthOverspent: accumulate_months.lastMonthOverspent, + categoryGroupsMap: accumulate_months.categoryGroupsMap, + startMonth: start, + endMonth: end, + }; + + return { data: categoryData, aggregated }; + }; +} + +export function createTransactionsSpreadsheet( + start: string, + end: string, + categories: CategoryGroupEntity[], + conditions: RuleConditionEntity[] = [], + conditionsOp: 'and' | 'or' = 'and', +) { + return async () => { + // 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, + ); + + return categoryData; + }; +} + +function processGraphData( + categoryData: CategoryEntry[], + topNcategories: number, + categories: CategoryGroupEntity[], + categorySort: SortMode, + setData: (data: ReturnType) => void, + layerFrom: GraphLayers, + layerTo: GraphLayers, + aggregated?: AggregatedBudget, +) { + let graph: Graph; + if (aggregated) { + graph = createBudgetGraph(categoryData, aggregated); + } else { + graph = createTransactionsGraph(categoryData); + } + groupOtherCategories(graph, topNcategories, categorySort); + const sortedGraph = sortGraph(graph, categorySort, categories); + addPercentageLabels(sortedGraph); + addColors(sortedGraph); + filterGraphByLayers(sortedGraph, layerFrom, layerTo); + cleanUpNodes(sortedGraph); + setData(convertToSankeyData(sortedGraph)); +} // Filter budget category groups to only those matching the user's conditions. // Budget data is fetched unconditionally from api/budget-month, so we must @@ -76,10 +358,10 @@ function filterCategoryGroups( conditionsOp: 'and' | 'or', ): BudgetMonthGroup[] { const categoryConditions = conditions.filter( - cond => cond.field === 'category', + cond => cond.field === GraphLayers.Category, ); const categoryGroupConditions = conditions.filter( - cond => cond.field === 'category_group', + cond => cond.field === GraphLayers.CategoryGroup, ); if (categoryConditions.length === 0 && categoryGroupConditions.length === 0) { @@ -92,7 +374,10 @@ function filterCategoryGroups( cond: RuleConditionEntity, ): boolean => { const value = cond.value; - const op = cond.op as string; + if (typeof cond.op !== 'string') { + throw new Error('Invalid op'); + } + const op = cond.op; if (op === 'is') return id === value; if (op === 'isNot') return id !== value; if (op === 'oneOf') return Array.isArray(value) && value.includes(id); @@ -111,14 +396,12 @@ function filterCategoryGroups( } if (op === 'matches') { if (typeof value !== 'string') return false; + if (value.length > 256) 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); + ? new RegExp(value.slice(1, value.lastIndexOf('/')), 'i') + : new RegExp(value, 'i'); return regex.test(name); } catch { return false; @@ -163,612 +446,855 @@ function filterCategoryGroups( .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, - setData: (data: ReturnType) => 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, - setData: (data: ReturnType) => 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, - ), - ); - - const aggregated = monthResponses.reduce( - (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(), - }, - ); - - 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, - setData: (data: ReturnType) => 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[], + categoryGroups: CategoryGroupEntity[], conditionsOpKey: string = '$and', filters: unknown[] = [], start: string, end: string, ): Promise { const nested = await Promise.all( - categories.map(async (mainCategory: CategoryGroupEntity) => { + categoryGroups.map(async (categoryGroup: 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' }), + (categoryGroup.categories || []).map(async category => { + const results = await aqlQuery( + q('transactions') + .filter({ [conditionsOpKey]: filters }) + .filter({ + $and: [ + { date: { $gte: monthUtils.firstDayOfMonth(start) } }, + { date: { $lte: monthUtils.lastDayOfMonth(end) } }, + ], + }) + .filter({ category: category.id }) + .groupBy( + categoryGroup.is_income + ? [ + { $id: '$category' }, + { $id: '$account' }, + { $id: '$payee' }, + ] + : [{ $id: '$category' }, { $id: '$account' }], + ) + .select([ + { accountId: { $id: '$account.id' } }, + { accountName: { $id: '$account.name' } }, + { amount: { $sum: '$amount' } }, + { payeeId: { $id: '$payee.id' } }, + { payeeName: { $id: '$payee.name' } }, + ]), + ); + return results.data + .filter( + (row: { amount?: number }) => + categoryGroup.is_income || (row.amount ?? 0) < 0, + ) + .map( + (row: { + amount?: number; + accountName?: string; + accountId?: string; + payeeName?: string; + payeeId?: string; + }) => + ({ + categoryGroup: categoryGroup.name, + categoryGroupId: categoryGroup.id, + category: category.name, + categoryId: category.id, + value: Math.abs(row.amount ?? 0), + isIncome: categoryGroup.is_income ?? false, + accountName: row.accountName ?? '', + accountId: row.accountId ?? '', + payeeName: row.payeeName ?? '', + payeeId: row.payeeId ?? '', + }) satisfies CategoryEntry, ); - return { - mainCategory: mainCategory.name, - group: group.name, - value: results.data * -1, - } satisfies CategoryEntry; - }), + }), ); - return entries; + return entries.flat(); }), ); - return nested.flat(); + return nested.flat().filter(e => e.value > 0); } -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; - globalOtherBucket: OtherBucket; -}; - -function greedyReduceLeaves( - allLeaves: LeafState[], - topNcategories: number, - globalOther: boolean, -): GreedyReductionResult { - const perCategoryOther = new Map(); - 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( +function createBudgetGraph( 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, - })); + aggregated: AggregatedBudget, +): Graph { + const graph: Graph = new Map(); - // Phase 2 — Greedy reduction (collapse lowest-value leaves into Other buckets) - const { perCategoryOther, globalOtherBucket } = greedyReduceLeaves( - allLeaves, - topNcategories, - globalOther, + // Add initial budget nodes with no links + addNodeWithLabel( + graph, + SpecialNodeKeys.Budgeted, + GraphLayers.Budget, + 'Budgeted', + ); + addNodeWithLabel( + graph, + SpecialNodeKeys.AvailableIncome, + GraphLayers.Account, + 'Available funds', ); - // Phase 3 — Compute category totals (sum of ALL leaves including collapsed) - const categoryTotals = new Map(); - for (const leaf of allLeaves) { - categoryTotals.set( - leaf.mainCategory, - (categoryTotals.get(leaf.mainCategory) ?? 0) + leaf.value, + categoryData.forEach(entry => { + if (entry.isIncome) { + // Income category > Available income + addNode( + graph, + entry.categoryId, + GraphLayers.IncomeCategory, + entry.category, + ); + addValueToLink( + graph, + entry.categoryId, + SpecialNodeKeys.AvailableIncome, + entry.value, + ); + } else { + // Budgeted > Category group > Category + addNode( + graph, + entry.categoryGroupId, + GraphLayers.CategoryGroup, + entry.categoryGroup, + ); + addNode(graph, entry.categoryId, GraphLayers.Category, entry.category); + addValueToLink( + graph, + entry.categoryGroupId, + entry.categoryId, + entry.value, + ); + addValueToLink( + graph, + SpecialNodeKeys.Budgeted, + entry.categoryGroupId, + entry.value, + ); + addValueToLink( + graph, + SpecialNodeKeys.AvailableIncome, + SpecialNodeKeys.Budgeted, + entry.value, + ); + } + }); + + if (aggregated.toBudget > 0) { + addNodeWithLabel( + graph, + SpecialNodeKeys.ToBudget, + GraphLayers.Budget, + 'To budget', + ); + addValueToLink( + graph, + SpecialNodeKeys.AvailableIncome, + SpecialNodeKeys.ToBudget, + aggregated.toBudget, + ); + } else { + addNodeWithLabel( + graph, + SpecialNodeKeys.ToBudget, + GraphLayers.Budget, + 'Overbudgeted', + undefined, + true, + ); + addValueToLink( + graph, + SpecialNodeKeys.ToBudget, + SpecialNodeKeys.AvailableIncome, + Math.abs(aggregated.toBudget), ); } - 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), + addNodeWithLabel( + graph, + SpecialNodeKeys.FromPrevMonth, + GraphLayers.IncomeCategory, + 'From {{month}}', + { month: monthUtils.prevMonth(aggregated.startMonth) }, + ); + addValueToLink( + graph, + SpecialNodeKeys.FromPrevMonth, + SpecialNodeKeys.AvailableIncome, + aggregated.fromPreviousMonth, + ); + addNodeWithLabel( + graph, + SpecialNodeKeys.ForNextMonth, + GraphLayers.Budget, + 'For {{month}}', + { month: monthUtils.nextMonth(aggregated.endMonth) }, + ); + addValueToLink( + graph, + SpecialNodeKeys.AvailableIncome, + SpecialNodeKeys.ForNextMonth, + aggregated.forNextMonth, + ); + addNodeWithLabel( + graph, + SpecialNodeKeys.LastMonthOverspent, + GraphLayers.Budget, + 'Overspent', + ); + addValueToLink( + graph, + SpecialNodeKeys.AvailableIncome, + SpecialNodeKeys.LastMonthOverspent, + Math.abs(aggregated.lastMonthOverspent), + ); + + // Add extra synthetic links to position nodes at the right layers. + // If the nodes don't exist, a link will not be created, so this is not seen in the graph. + addValueToLink( + graph, + SpecialNodeKeys.ToBudget, + 'to_budget' + SpecialNodeKeys.HiddenSuffix, + -1, + ); + addValueToLink( + graph, + SpecialNodeKeys.ForNextMonth, + 'next_month' + SpecialNodeKeys.HiddenSuffix, + -1, + ); + addValueToLink( + graph, + SpecialNodeKeys.LastMonthOverspent, + 'overspent' + SpecialNodeKeys.HiddenSuffix, + -1, + ); + + return graph; +} + +function createTransactionsGraph(categoryData: CategoryEntry[]): Graph { + const graph: Graph = new Map(); + + categoryData.forEach(entry => { + if (entry.accountId && entry.accountName && entry.categoryId) { + if (entry.isIncome && entry.payeeId) { + // Payee > Income category > Account + addNode( + graph, + entry.categoryId, + GraphLayers.IncomeCategory, + entry.category, + ); + addNode(graph, entry.accountId, GraphLayers.Account, entry.accountName); + addNode(graph, entry.payeeId, GraphLayers.IncomePayee, entry.payeeName); + addValueToLink(graph, entry.categoryId, entry.accountId, entry.value); + addValueToLink(graph, entry.payeeId, entry.categoryId, entry.value); + } else { + // Account > Category group > Category + addNode(graph, entry.accountId, GraphLayers.Account, entry.accountName); + addNode( + graph, + entry.categoryGroupId, + GraphLayers.CategoryGroup, + entry.categoryGroup, + ); + addNode(graph, entry.categoryId, GraphLayers.Category, entry.category); + addValueToLink( + graph, + entry.accountId, + entry.categoryGroupId, + entry.value, + ); + addValueToLink( + graph, + entry.categoryGroupId, + entry.categoryId, + entry.value, + ); + } + } + }); + + graph.forEach((data, key) => { + if ( + data.type === GraphLayers.Account && + getLayer(graph, key) === 0 && + nodesInLayer(graph, GraphLayers.IncomePayee).length > 0 + ) { + // If an account node has no parents (i.e. money was spent from the account, but no money added in the timeframe), + // connect it to a synthetic node to ensure it appears in the graph at the right layer. + addNode( + graph, + key + '_payee' + SpecialNodeKeys.HiddenSuffix, + GraphLayers.IncomePayee, + '', + ); + addNode( + graph, + key + '_account' + SpecialNodeKeys.HiddenSuffix, + GraphLayers.Account, + '', + ); + addValueToLink( + graph, + key + '_payee' + SpecialNodeKeys.HiddenSuffix, + key + '_account' + SpecialNodeKeys.HiddenSuffix, + -1, + ); + addValueToLink( + graph, + key + '_account' + SpecialNodeKeys.HiddenSuffix, + key, + -1, + ); + } + }); + + return graph; +} + +function addNode(graph: Graph, key: NodeKey, type: GraphLayers, name?: string) { + if (!graph.has(key)) { + graph.set(key, { + to: new Map(), + type, + name, + }); + } +} + +function addNodeWithLabel( + graph: Graph, + key: NodeKey, + type: GraphLayers, + labelKey: string, + labelParams?: Record, + isOverbudgeted?: boolean, +) { + if (!graph.has(key)) { + graph.set(key, { + to: new Map(), + type, + labelKey, + labelParams, + isOverbudgeted, + }); + } +} + +function addValueToLink( + graph: Graph, + from: NodeKey, + to: NodeKey, + value: number, +) { + const fromNode = graph.get(from); + if (fromNode) { + fromNode.to.set(to, (fromNode.to.get(to) ?? 0) + value); + } +} + +function getLayer(graph: Graph, key: NodeKey): number { + // Find parent nodes for the given key + const parents: NodeKey[] = []; + for (const [parentKey, data] of graph) { + if (data.to.has(key)) { + parents.push(parentKey); + } + } + if (parents.length === 0) { + // No parents: this is a root node, layer 0 + return 0; + } + // Otherwise, 1 + max parent's layer + return 1 + Math.max(...parents.map(parentKey => getLayer(graph, parentKey))); +} + +function groupOtherCategories( + graph: Graph, + topN: number, + categorySort: SortMode = 'per-group', +) { + // For each category group, find the top N categories by total value and group the rest into "Other" + const deletedNodes = new Map(); + + let categoryNodes = nodesInLayer(graph, GraphLayers.Category).filter( + s => !s.endsWith(SpecialNodeKeys.OtherSuffix), + ); + while (categoryNodes.length > topN) { + const categoryNodeSet = new Set(categoryNodes); + const values = new Map(); + graph.forEach(data => { + data.to.forEach((v, k) => { + if (categoryNodeSet.has(k)) values.set(k, (values.get(k) ?? 0) + v); + }); + }); + let categoryToDelete: NodeKey | undefined; + let min = Infinity; + for (const k of categoryNodes) { + const val = values.get(k) ?? 0; + if (val < min) { + min = val; + categoryToDelete = k; + } + } + + if (categoryToDelete === undefined) break; // safety + + const categoryGroupResult = getCategoryGroup(graph, categoryToDelete); + if (!categoryGroupResult) { + console.error( + `Failed to find category group for category: ${categoryToDelete}`, + ); + continue; + } + + const deletedCategoryGroupKey = categoryGroupResult[0]; + const nodeData = graph.get(categoryToDelete); + if (!nodeData) { + console.error( + `Failed to find node data for category: ${categoryToDelete}`, + ); + continue; + } + + const deletedCategoryGroup = deletedNodes.get(deletedCategoryGroupKey); + if (!deletedCategoryGroup) { + deletedNodes.set(deletedCategoryGroupKey, [ + { key: categoryToDelete, data: nodeData }, + ]); + } else { + deletedCategoryGroup.push({ key: categoryToDelete, data: nodeData }); + } + + moveToOther(graph, categoryToDelete, categorySort === 'global'); + graph.delete(categoryToDelete); + + categoryNodes = nodesInLayer(graph, GraphLayers.Category).filter( + s => !s.endsWith(SpecialNodeKeys.OtherSuffix), + ); + } + + promoteOtherBack(graph, deletedNodes, categorySort === 'global'); +} + +function nodesInLayer(graph: Graph, layer: GraphLayers): NodeKey[] { + return Array.from(graph) + .filter(([, data]) => data.type === layer) + .map(([key]) => key); +} + +function moveToOther(graph: Graph, key: NodeKey, globalOther: boolean = false) { + const categoryGroup = getCategoryGroup(graph, key); + if (!categoryGroup) { + console.error(`moveToOther: Failed to find category group for key: ${key}`); + return; + } + + const categoryGroupKey = categoryGroup[0]; + const categoryGroupData = categoryGroup[1]; + const categoryValue = categoryGroupData.to.get(key); + + if (categoryValue === undefined) { + console.error( + `moveToOther: No link value found from group ${categoryGroupKey} to ${key}`, + ); + return; + } + + let otherGroupKey: NodeKey; + if (globalOther) { + otherGroupKey = SpecialNodeKeys.GlobalOther; + } else { + otherGroupKey = categoryGroupKey + SpecialNodeKeys.OtherSuffix; + } + + addNodeWithLabel(graph, otherGroupKey, GraphLayers.Category, 'Other'); + addValueToLink(graph, categoryGroupKey, otherGroupKey, categoryValue); + addTooltipInfo(graph, categoryGroupKey, key, categoryValue); + deleteLink(graph, categoryGroupKey, key); +} + +function addTooltipInfo( + graph: Graph, + from: NodeKey, + to: NodeKey, + value: number, +) { + const fromNode = graph.get(from); + if (!fromNode) return; + fromNode.tooltipInfo = fromNode.tooltipInfo ?? []; + fromNode.tooltipInfo.push({ name: graph.get(to)?.name ?? to, value }); +} + +function getCategoryGroup(graph: Graph, key: NodeKey) { + return Array.from(graph).filter( + ([, data]) => data.to.has(key) && data.type === GraphLayers.CategoryGroup, + )[0]; +} + +function deleteLink(graph: Graph, from: NodeKey, to: NodeKey) { + const fromNode = graph.get(from); + if (fromNode) { + fromNode.to.delete(to); + } +} + +function promoteOtherBack( + graph: Graph, + deletedNodes: Map, + globalOther: boolean = false, +) { + // If an Other node only contains one category, we revert it to an ordinary node + let otherGroupKey: NodeKey; + deletedNodes.forEach((data, key) => { + if (data.length === 1) { + if (globalOther) { + otherGroupKey = SpecialNodeKeys.GlobalOther; + } else { + otherGroupKey = key ? key + SpecialNodeKeys.OtherSuffix : 'other'; + } + addNode(graph, data[0].key, GraphLayers.Category, data[0].data.name); + const fromNode = graph.get(key); + const linkValue = fromNode?.to.get(otherGroupKey); + if (linkValue !== undefined) { + addValueToLink(graph, key, data[0].key, linkValue); + } + deleteLink(graph, key, otherGroupKey); + } + }); +} + +function sortGraph( + graph: Graph, + categorySort: SortMode = 'per-group', + categories: CategoryGroupEntity[], +): Graph { + let sortedEntries: Array<[string, NodeData]>; + if (categorySort === 'global') { + sortedEntries = Array.from(graph.entries()).sort( + ([keyA], [keyB]) => getNodeValue(graph, keyB) - getNodeValue(graph, keyA), + ); + moveNodeToEnd(sortedEntries, SpecialNodeKeys.GlobalOther); + } else if (categorySort === 'per-group') { + const categoryGroups = nodesInLayer(graph, GraphLayers.CategoryGroup); + sortedEntries = Array.from(graph.entries()).sort( + ([keyA], [keyB]) => getNodeValue(graph, keyB) - getNodeValue(graph, keyA), + ); + + categoryGroups.forEach(groupKey => { + const group = graph.get(groupKey); + if (!group) return; + const groupToKeys = Array.from(group.to.keys()); + + const groupOtherKey = groupToKeys.find(k => + k.endsWith(SpecialNodeKeys.OtherSuffix), + ); + const childKeys = groupToKeys.filter(k => k !== groupOtherKey); + + const childEntries = childKeys + .map(key => sortedEntries.find(([entryKey]) => entryKey === key)) + .filter((entry): entry is [string, NodeData] => entry !== undefined); + childEntries.sort( + ([a], [b]) => getNodeValue(graph, b) - getNodeValue(graph, a), ); - // Phase 4 — Build nodes/links - const nodes: SankeyNode[] = [ - { name: rootNodeName, toBudget: toBudgetAmount }, - ]; - const links: SankeyLink[] = []; - const catNodeIndexMap = new Map(); + const otherEntry = groupOtherKey + ? sortedEntries.find(([entryKey]) => entryKey === groupOtherKey) + : undefined; - // 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, - }); - } + // Remove these children ("Other" too) from their current places + sortedEntries = sortedEntries.filter( + ([entryKey]) => + !childKeys.includes(entryKey) && entryKey !== groupOtherKey, + ); - 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), - }); + // Insert after group node + const groupIndex = sortedEntries.findIndex( + ([entryKey]) => entryKey === groupKey, + ); + if (groupIndex !== -1) { + sortedEntries.splice(groupIndex + 1, 0, ...childEntries); + if (otherEntry) { + sortedEntries.splice( + groupIndex + 1 + childEntries.length, + 0, + otherEntry, + ); } } + }); + } else { + const used = new Set(); + sortedEntries = []; + + // 1. Add entries by category group and subcategory order + categories.forEach(group => { + const groupNode = graph.get(group.id); + if (groupNode) { + sortedEntries.push([group.id, groupNode]); + used.add(group.id); + } + + if (group.categories && group.categories.length) { + group.categories.forEach(cat => { + const categoryNode = graph.get(cat.id); + if (categoryNode) { + sortedEntries.push([cat.id, categoryNode]); + used.add(cat.id); + } + }); + } + + const otherKey = `${group.id}${SpecialNodeKeys.OtherSuffix}`; + const otherNode = graph.get(otherKey); + if (otherNode) { + sortedEntries.push([otherKey, otherNode]); + used.add(otherKey); + } + }); + + // 2. Add all remaining entries that weren't used (preserving graph order) + for (const [key, data] of graph) { + if (!used.has(key)) { + sortedEntries.push([key, data]); + } + } + } + + // We always want these nodes displayed at the bottom of their layers, so its safe to just move them to the end + moveNodeToEnd(sortedEntries, SpecialNodeKeys.ToBudget); + moveNodeToEnd(sortedEntries, SpecialNodeKeys.LastMonthOverspent); + moveNodeToEnd(sortedEntries, SpecialNodeKeys.ForNextMonth); + moveNodeToEnd(sortedEntries, SpecialNodeKeys.FromPrevMonth); + return new Map(sortedEntries); +} + +function moveNodeToEnd(entries: Array<[string, NodeData]>, key: NodeKey) { + const nodeIndex = entries.findIndex(([nodekey]) => nodekey === key); + if (nodeIndex !== -1) { + const [entry] = entries.splice(nodeIndex, 1); + entries.push(entry); + } +} + +function getNodeValue(graph: Graph, key: NodeKey): number { + let nodeValue: number = 0; + + if (getLayer(graph, key) === 0) { + // Look at outgoing links for root nodes + const node = graph.get(key); + if (node) { + node.to.forEach(value => { + nodeValue += value ?? 0; + }); } } else { - // per-group sort or budget-order: each category's categories sorted independently - for (const catName of sortedCategories) { - const catIdx = catNodeIndexMap.get(catName) ?? 0; + graph.forEach(data => { + nodeValue += data.to.get(key) ?? 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, + // If node is in reality a root node, masked behind hidden nodes + if (nodeValue < 0) { + nodeValue = 0; + const node = graph.get(key); + if (node) { + node.to.forEach(value => { + nodeValue += value; }); } + } + } + return nodeValue; +} - // 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), - }); +function addPercentageLabels(graph: Graph): void { + const layerSums = new Map(); + + // First pass: Calculate layer sums + graph.forEach((_: NodeData, key: NodeKey) => { + const layer = getLayer(graph, key); + const nodeValue = getNodeValue(graph, key); + layerSums.set(layer, (layerSums.get(layer) ?? 0) + nodeValue); + }); + + // Second pass: Assign percentage label to each node + graph.forEach((data: NodeData, key: NodeKey) => { + const layer = getLayer(graph, key); + const nodeValue = getNodeValue(graph, key); + const layerTotal = layerSums.get(layer) ?? 1; + const percentage = layerTotal ? (nodeValue / layerTotal) * 100 : 0; + data.percentageLabel = `${percentage.toFixed(1)}%`; + }); +} + +function addColors(graph: Graph) { + const colors = getColorScale('qualitative'); + const keys = [...graph.keys()].sort(); + + keys.forEach((key, i) => { + const node = graph.get(key); + if (node) node.color = colors[i % colors.length]; + }); + + const node = graph.get(SpecialNodeKeys.ToBudget); + if (node && node.isOverbudgeted) { + setColor(graph, SpecialNodeKeys.ToBudget, theme.toBudgetNegative); + } else { + setColor(graph, SpecialNodeKeys.ToBudget, theme.toBudgetPositive); + } + setColor(graph, SpecialNodeKeys.LastMonthOverspent, theme.toBudgetNegative); + setColor(graph, SpecialNodeKeys.FromPrevMonth, theme.reportsGray); + setColor(graph, SpecialNodeKeys.ForNextMonth, theme.reportsGray); + setColor(graph, SpecialNodeKeys.Budgeted, theme.reportsBlue); + setColor(graph, SpecialNodeKeys.AvailableIncome, theme.reportsBlue); +} + +function setColor(graph: Graph, key: NodeKey, color: string) { + const node = graph.get(key); + if (node) { + node.color = color; + } +} + +function filterGraphByLayers( + graph: Graph, + layerFrom: GraphLayers, + layerTo: GraphLayers, +): void { + const layerIndices = new Map(); + GRAPH_LAYER_ORDER.forEach((layer, index) => { + layerIndices.set(layer, index); + }); + + const fromIndex = layerIndices.get(layerFrom); + const toIndex = layerIndices.get(layerTo); + + if (fromIndex && toIndex) { + const keysToDelete: NodeKey[] = []; + graph.forEach((data, key) => { + const nodeLayerIndex = layerIndices.get(data.type); + if (nodeLayerIndex !== undefined) { + if (nodeLayerIndex < fromIndex || nodeLayerIndex > toIndex) { + keysToDelete.push(key); } } - } + }); + + keysToDelete.forEach(key => graph.delete(key)); } - - // 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; -} - -export function withPercentageLabels(data: SankeyData): SankeyData { - // Assign a layer depth to each node, starting from the root (node 0 = depth 0). - const depth = new Array(data.nodes.length).fill(-1); - depth[0] = 0; - let changed = true; - while (changed) { - changed = false; - for (const link of data.links) { - const src = link.source; - const tgt = link.target; - if (depth[src] >= 0 && depth[tgt] < 0) { - depth[tgt] = depth[src] + 1; - changed = true; +function cleanUpNodes(graph: Graph) { + // 1. Remove all `.to` links with value === 0 + for (const [, node] of graph) { + for (const [target, value] of node.to) { + if (value === 0) { + node.to.delete(target); } } } - // Each node's value is the sum of its incoming links. - // The root has no incoming links, so use its outgoing links instead. - const nodeValue = (i: number) => - data.links - .filter(l => (depth[i] === 0 ? l.source === i : l.target === i)) - .reduce((sum, l) => sum + l.value, 0); + // 2. Find all nodes that are targets of remaining links ("has incoming link") + const hasIncoming = new Set(); + for (const [, node] of graph) { + for (const target of node.to.keys()) { + hasIncoming.add(target); + } + } - // Compute the total value per layer so percentages within each layer sum to 100%. - const layerTotal = new Map(); - data.nodes.forEach((_, i) => { - const d = depth[i]; - if (d >= 0) layerTotal.set(d, (layerTotal.get(d) ?? 0) + nodeValue(i)); - }); + // 3. Collect keys to remove (no incoming links and no outgoing links) + const toDelete: NodeKey[] = []; + for (const [key, node] of graph) { + const hasOutgoing = node.to.size > 0; + const incoming = hasIncoming.has(key); + if (!hasOutgoing && !incoming) { + toDelete.push(key); + } + } - const nodes = data.nodes.map((node, i) => { - const total = layerTotal.get(depth[i]) ?? 0; - return { - ...node, - percentageLabel: - total === 0 ? '0%' : `${((nodeValue(i) / total) * 100).toFixed(1)}%`, - }; - }); - - return { ...data, nodes }; + // 4. Remove these nodes from the graph + for (const key of toDelete) { + graph.delete(key); + } +} + +function convertToSankeyData(graph: Graph): SankeyData { + const nodes = Array.from(graph, ([key, data]) => ({ + key, + name: data.labelKey + ? t(data.labelKey, data.labelParams) + : (data.name ?? key), + percentageLabel: data.percentageLabel ?? '', + color: data.color ?? undefined, + })); + const links = Array.from(graph).flatMap(([key, data]) => + Array.from(data.to, ([targetKey, value]) => { + let tooltipInfo: Array<{ name: string; value: number }> = []; + if (data.tooltipInfo && targetKey.endsWith(SpecialNodeKeys.OtherSuffix)) { + tooltipInfo = data.tooltipInfo; + tooltipInfo.sort((a, b) => b.value - a.value); + } + + let color: string | undefined; + const sourceLayersWithOwnColor: readonly GraphLayers[] = [ + GraphLayers.IncomePayee, + GraphLayers.IncomeCategory, + GraphLayers.Account, + GraphLayers.CategoryGroup, + ]; + const targetLayersWithTargetColor: readonly GraphLayers[] = [ + GraphLayers.Category, + GraphLayers.Budget, + ]; + + if ( + isGraphLayer(data.type) && + sourceLayersWithOwnColor.includes(data.type) + ) { + color = data.color; + } else if ( + isGraphLayer(data.type) && + targetLayersWithTargetColor.includes(data.type) + ) { + const targetNode = graph.get(targetKey); + color = targetNode ? targetNode.color : undefined; + } + + // Specific color overrides + if (targetKey === SpecialNodeKeys.LastMonthOverspent) { + color = graph.get(SpecialNodeKeys.LastMonthOverspent)?.color; + } + if (targetKey === SpecialNodeKeys.ToBudget) { + color = graph.get(SpecialNodeKeys.ToBudget)?.color; + } + if (targetKey === SpecialNodeKeys.ForNextMonth) { + color = graph.get(SpecialNodeKeys.ForNextMonth)?.color; + } + if ( + targetKey === SpecialNodeKeys.AvailableIncome && + data.isOverbudgeted + ) { + color = data.color; + } + + return { + source: nodes.findIndex(n => n.key === key) ?? -1, + target: nodes.findIndex(n => n.key === targetKey) ?? -1, + value, + tooltipInfo, + color: color ?? undefined, + }; + }), + ); + + return { + nodes, + links, + }; } diff --git a/packages/loot-core/src/types/models/dashboard.ts b/packages/loot-core/src/types/models/dashboard.ts index 95bc5bac75..af79e7ace4 100644 --- a/packages/loot-core/src/types/models/dashboard.ts +++ b/packages/loot-core/src/types/models/dashboard.ts @@ -223,5 +223,7 @@ export type SankeyWidget = AbstractWidget< topNcategories?: number; categorySort?: 'per-group' | 'global' | 'budget-order'; showPercentages?: boolean; + layerFrom?: string; + layerTo?: string; } | null >; diff --git a/upcoming-release-notes/7582.md b/upcoming-release-notes/7582.md new file mode 100644 index 0000000000..e7fe3194e1 --- /dev/null +++ b/upcoming-release-notes/7582.md @@ -0,0 +1,6 @@ +--- +category: Enhancements +authors: [emiltb] +--- + +Optimize Sankey chart datamodel to include income sources, allow layer filtering and better budget handling