From 04bc0c3c64815ff3e980b996be39805b8b9a7386 Mon Sep 17 00:00:00 2001 From: Neil <55785687+carkom@users.noreply.github.com> Date: Fri, 3 May 2024 20:34:21 +0100 Subject: [PATCH] Monthly spending report (#2622) * monthly Spending * Add Average * notes * title * date filter * TS fixes and hide average when no data * fix average tooltip and relabel x-axis * Wording/verbiage * filters changes * feature flag * networth card fix --- .../src/components/reports/Header.jsx | 2 +- .../src/components/reports/Overview.jsx | 3 + .../src/components/reports/ReportRouter.jsx | 2 + .../reports/graphs/SpendingGraph.tsx | 303 ++++++++++++++++++ .../components/reports/reports/Spending.tsx | 286 +++++++++++++++++ .../reports/reports/SpendingCard.tsx | 86 +++++ .../spreadsheets/spending-spreadsheet.ts | 180 +++++++++++ .../src/components/settings/Experimental.tsx | 3 + .../src/hooks/useFeatureFlag.ts | 1 + .../loot-core/src/types/models/reports.d.ts | 33 ++ packages/loot-core/src/types/prefs.d.ts | 1 + upcoming-release-notes/2622.md | 6 + 12 files changed, 905 insertions(+), 1 deletion(-) create mode 100644 packages/desktop-client/src/components/reports/graphs/SpendingGraph.tsx create mode 100644 packages/desktop-client/src/components/reports/reports/Spending.tsx create mode 100644 packages/desktop-client/src/components/reports/reports/SpendingCard.tsx create mode 100644 packages/desktop-client/src/components/reports/spreadsheets/spending-spreadsheet.ts create mode 100644 upcoming-release-notes/2622.md diff --git a/packages/desktop-client/src/components/reports/Header.jsx b/packages/desktop-client/src/components/reports/Header.jsx index 3df44ca88..f9ad57bb9 100644 --- a/packages/desktop-client/src/components/reports/Header.jsx +++ b/packages/desktop-client/src/components/reports/Header.jsx @@ -57,7 +57,7 @@ export function Header({ </Link> <View style={styles.veryLargeText}>{title}</View> - {path !== '/reports/custom' && ( + {!['/reports/custom', '/reports/spending'].includes(path) && ( <View style={{ flexDirection: isNarrowWidth ? 'column' : 'row', diff --git a/packages/desktop-client/src/components/reports/Overview.jsx b/packages/desktop-client/src/components/reports/Overview.jsx index 2cefe41a6..06ecef0c8 100644 --- a/packages/desktop-client/src/components/reports/Overview.jsx +++ b/packages/desktop-client/src/components/reports/Overview.jsx @@ -15,6 +15,7 @@ import { View } from '../common/View'; import { CashFlowCard } from './reports/CashFlowCard'; import { CustomReportListCards } from './reports/CustomReportListCards'; import { NetWorthCard } from './reports/NetWorthCard'; +import { SpendingCard } from './reports/SpendingCard'; export function Overview() { const customReports = useReports(); @@ -24,6 +25,7 @@ export function Overview() { sessionStorage.setItem('url', location.pathname); const customReportsFeatureFlag = useFeatureFlag('customReports'); + const spendingReportFeatureFlag = useFeatureFlag('spendingReport'); const accounts = useAccounts(); return ( @@ -61,6 +63,7 @@ export function Overview() { > <NetWorthCard accounts={accounts} /> <CashFlowCard /> + {spendingReportFeatureFlag && <SpendingCard />} </View> {customReportsFeatureFlag && ( <CustomReportListCards reports={customReports} /> diff --git a/packages/desktop-client/src/components/reports/ReportRouter.jsx b/packages/desktop-client/src/components/reports/ReportRouter.jsx index 463e9484d..2fadf9f39 100644 --- a/packages/desktop-client/src/components/reports/ReportRouter.jsx +++ b/packages/desktop-client/src/components/reports/ReportRouter.jsx @@ -5,6 +5,7 @@ import { Overview } from './Overview'; import { CashFlow } from './reports/CashFlow'; import { CustomReport } from './reports/CustomReport'; import { NetWorth } from './reports/NetWorth'; +import { Spending } from './reports/Spending'; export function ReportRouter() { return ( @@ -13,6 +14,7 @@ export function ReportRouter() { <Route path="/net-worth" element={<NetWorth />} /> <Route path="/cash-flow" element={<CashFlow />} /> <Route path="/custom" element={<CustomReport />} /> + <Route path="/spending" element={<Spending />} /> </Routes> ); } diff --git a/packages/desktop-client/src/components/reports/graphs/SpendingGraph.tsx b/packages/desktop-client/src/components/reports/graphs/SpendingGraph.tsx new file mode 100644 index 000000000..e74f64630 --- /dev/null +++ b/packages/desktop-client/src/components/reports/graphs/SpendingGraph.tsx @@ -0,0 +1,303 @@ +// @ts-strict-ignore +import React from 'react'; + +import { css } from 'glamor'; +import { + AreaChart, + Area, + CartesianGrid, + XAxis, + YAxis, + Tooltip, + ResponsiveContainer, +} from 'recharts'; + +import * as monthUtils from 'loot-core/src/shared/months'; +import { + amountToCurrency, + amountToCurrencyNoDecimal, +} from 'loot-core/src/shared/util'; +import { type SpendingEntity } from 'loot-core/src/types/models/reports'; + +import { usePrivacyMode } from '../../../hooks/usePrivacyMode'; +import { theme } from '../../../style'; +import { type CSSProperties } from '../../../style'; +import { AlignedText } from '../../common/AlignedText'; +import { Container } from '../Container'; +import { numberFormatterTooltip } from '../numberFormatter'; + +type PayloadItem = { + value: number; + payload: { + totalAssets: number | string; + totalDebts: number | string; + totalTotals: number | string; + day: string; + months: { + date: string; + cumulative: number | string; + }; + }; +}; + +type CustomTooltipProps = { + active?: boolean; + payload?: PayloadItem[]; + balanceTypeOp?: string; + thisMonth?: string; + selection?: string; +}; + +const CustomTooltip = ({ + active, + payload, + balanceTypeOp, + thisMonth, + selection, +}: CustomTooltipProps) => { + if (active && payload && payload.length) { + const comparison = + selection === 'average' + ? payload[0].payload[selection] * -1 + : payload[0].payload.months[selection].cumulative * -1; + 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> + <div style={{ marginBottom: 10 }}> + <strong> + Day:{' '} + {Number(payload[0].payload.day) >= 28 + ? '28+' + : payload[0].payload.day} + </strong> + </div> + <div style={{ lineHeight: 1.5 }}> + {payload[0].payload.months[thisMonth].cumulative && ( + <AlignedText + left="This month:" + right={amountToCurrency( + payload[0].payload.months[thisMonth].cumulative * -1, + )} + /> + )} + {['cumulative'].includes(balanceTypeOp) && ( + <AlignedText + left={selection === 'average' ? 'Average' : 'Last month:'} + right={amountToCurrency(comparison)} + /> + )} + {payload[0].payload.months[thisMonth].cumulative && ( + <AlignedText + left="Difference:" + right={amountToCurrency( + payload[0].payload.months[thisMonth].cumulative * -1 - + comparison, + )} + /> + )} + </div> + </div> + </div> + ); + } +}; + +type SpendingGraphProps = { + style?: CSSProperties; + data: SpendingEntity; + compact?: boolean; + mode: string; +}; + +export function SpendingGraph({ + style, + data, + compact, + mode, +}: SpendingGraphProps) { + const privacyMode = usePrivacyMode(); + const balanceTypeOp = 'cumulative'; + const thisMonth = monthUtils.currentMonth(); + const lastMonth = monthUtils.subMonths(monthUtils.currentMonth(), 1); + const selection = mode.toLowerCase() === 'average' ? 'average' : lastMonth; + const thisMonthMax = data.intervalData.reduce((a, b) => + a.months[thisMonth][balanceTypeOp] < b.months[thisMonth][balanceTypeOp] + ? a + : b, + ).months[thisMonth][balanceTypeOp]; + const selectionMax = + selection === 'average' + ? data.intervalData[27].average + : data.intervalData.reduce((a, b) => + a.months[lastMonth][balanceTypeOp] < + b.months[lastMonth][balanceTypeOp] + ? a + : b, + ).months[lastMonth][balanceTypeOp]; + const maxYAxis = selectionMax > thisMonthMax; + const dataMax = Math.max(...data.intervalData.map(i => i[balanceTypeOp])); + const dataMin = Math.min(...data.intervalData.map(i => i[balanceTypeOp])); + + const tickFormatter = tick => { + if (!privacyMode) return `${amountToCurrencyNoDecimal(tick)}`; // Formats the tick values as strings with commas + return '...'; + }; + + const gradientOffset = () => { + if (dataMax <= 0) { + return 0; + } + if (dataMin >= 0) { + return 1; + } + + return dataMax / (dataMax - dataMin); + }; + + const off = gradientOffset(); + + const getVal = (obj, month) => { + if (month === 'average') { + return obj[month] && -1 * obj[month]; + } else { + return ( + obj.months[month][balanceTypeOp] && + -1 * obj.months[month][balanceTypeOp] + ); + } + }; + + const getDate = obj => { + return Number(obj.day) >= 28 ? '28+' : obj.day; + }; + + return ( + <Container + style={{ + ...style, + ...(compact && { height: 'auto' }), + }} + > + {(width, height) => + data.intervalData && ( + <ResponsiveContainer> + <div> + {!compact && <div style={{ marginTop: '15px' }} />} + <AreaChart + width={width} + height={height} + data={data.intervalData} + margin={{ + top: 0, + right: 0, + left: 0, + bottom: 0, + }} + > + {compact ? null : ( + <CartesianGrid strokeDasharray="3 3" vertical={false} /> + )} + {compact ? null : ( + <XAxis + dataKey={val => getDate(val)} + tick={{ fill: theme.pageText }} + tickLine={{ stroke: theme.pageText }} + /> + )} + {compact ? null : ( + <YAxis + dataKey={val => + getVal(val, maxYAxis ? thisMonth : selection) + } + domain={[0, 'auto']} + tickFormatter={tickFormatter} + tick={{ fill: theme.pageText }} + tickLine={{ stroke: theme.pageText }} + tickSize={0} + /> + )} + <Tooltip + content={ + <CustomTooltip + balanceTypeOp={balanceTypeOp} + thisMonth={thisMonth} + selection={selection} + /> + } + formatter={numberFormatterTooltip} + isAnimationActive={false} + /> + <defs> + <linearGradient + id={`fill${balanceTypeOp}`} + x1="0" + y1="0" + x2="0" + y2="1" + > + <stop + offset={off} + stopColor={theme.reportsGreen} + stopOpacity={0.2} + /> + </linearGradient> + <linearGradient + id={`stroke${balanceTypeOp}`} + x1="0" + y1="0" + x2="0" + y2="1" + > + <stop + offset={off} + stopColor={theme.reportsGreen} + stopOpacity={1} + /> + </linearGradient> + </defs> + + <Area + type="linear" + dot={false} + activeDot={{ + fill: theme.reportsGreen, + fillOpacity: 1, + r: 10, + }} + animationDuration={0} + dataKey={val => getVal(val, thisMonth)} + stroke={`url(#stroke${balanceTypeOp})`} + strokeWidth={3} + fill={`url(#fill${balanceTypeOp})`} + fillOpacity={1} + /> + <Area + type="linear" + dot={false} + activeDot={false} + animationDuration={0} + dataKey={val => getVal(val, selection)} + stroke="gray" + strokeDasharray="10 10" + strokeWidth={3} + fill="gray" + fillOpacity={0.2} + /> + </AreaChart> + </div> + </ResponsiveContainer> + ) + } + </Container> + ); +} diff --git a/packages/desktop-client/src/components/reports/reports/Spending.tsx b/packages/desktop-client/src/components/reports/reports/Spending.tsx new file mode 100644 index 000000000..c52921324 --- /dev/null +++ b/packages/desktop-client/src/components/reports/reports/Spending.tsx @@ -0,0 +1,286 @@ +import React, { useState, useMemo } from 'react'; + +import * as monthUtils from 'loot-core/src/shared/months'; +import { amountToCurrency } from 'loot-core/src/shared/util'; +import { type RuleConditionEntity } from 'loot-core/types/models/rule'; + +import { useCategories } from '../../../hooks/useCategories'; +import { useFilters } from '../../../hooks/useFilters'; +import { SvgArrowLeft } from '../../../icons/v1/ArrowLeft'; +import { theme, styles } from '../../../style'; +import { AlignedText } from '../../common/AlignedText'; +import { Block } from '../../common/Block'; +import { Link } from '../../common/Link'; +import { Paragraph } from '../../common/Paragraph'; +import { Text } from '../../common/Text'; +import { View } from '../../common/View'; +import { AppliedFilters } from '../../filters/AppliedFilters'; +import { FilterButton } from '../../filters/FiltersMenu'; +import { PrivacyFilter } from '../../PrivacyFilter'; +import { SpendingGraph } from '../graphs/SpendingGraph'; +import { LoadingIndicator } from '../LoadingIndicator'; +import { ModeButton } from '../ModeButton'; +import { createSpendingSpreadsheet } from '../spreadsheets/spending-spreadsheet'; +import { useReport } from '../useReport'; + +export function Spending() { + const categories = useCategories(); + + const { + filters, + conditionsOp, + onApply: onApplyFilter, + onDelete: onDeleteFilter, + onUpdate: onUpdateFilter, + onCondOpChange, + } = useFilters<RuleConditionEntity>(); + + const [dataCheck, setDataCheck] = useState(false); + const [mode, setMode] = useState('Last month'); + + const getGraphData = useMemo(() => { + setDataCheck(false); + return createSpendingSpreadsheet({ + categories, + conditions: filters, + conditionsOp, + setDataCheck, + }); + }, [categories, filters, conditionsOp]); + + const data = useReport('default', getGraphData); + + if (!data) { + return null; + } + const showAverage = + data.intervalData[27].months[ + monthUtils.subMonths(monthUtils.currentDay(), 3) + ].daily !== 0; + + return ( + <View style={{ ...styles.page, minWidth: 650, overflow: 'hidden' }}> + <View + style={{ + flexDirection: 'row', + flexShrink: 0, + }} + > + <View + style={{ + padding: 10, + paddingTop: 0, + flexShrink: 0, + }} + > + <Link + variant="button" + type="bare" + to="/reports" + style={{ marginBottom: '15', alignSelf: 'flex-start' }} + > + <SvgArrowLeft width={10} height={10} style={{ marginRight: 5 }} />{' '} + Back + </Link> + <Text style={{ ...styles.veryLargeText, marginBottom: 10 }}> + Monthly Spending + </Text> + + {filters && ( + <View style={{ flexDirection: 'row' }}> + <FilterButton + onApply={onApplyFilter} + compact={false} + hover={false} + exclude={['date']} + /> + <View style={{ flex: 1 }} /> + </View> + )} + </View> + </View> + <View + style={{ + display: 'flex', + flexDirection: 'row', + padding: 15, + paddingTop: 0, + flexGrow: 1, + }} + > + <View + style={{ + flexGrow: 1, + }} + > + {filters && filters.length > 0 && ( + <View + style={{ + marginBottom: 10, + marginLeft: 5, + flexShrink: 0, + flexDirection: 'row', + spacing: 2, + justifyContent: 'flex-start', + alignContent: 'flex-start', + }} + > + <AppliedFilters + filters={filters} + onUpdate={onUpdateFilter} + onDelete={onDeleteFilter} + conditionsOp={conditionsOp} + onCondOpChange={onCondOpChange} + /> + </View> + )} + <View + style={{ + backgroundColor: theme.tableBackground, + flexGrow: 1, + }} + > + <View + style={{ + flexDirection: 'column', + flexGrow: 1, + padding: 10, + paddingTop: 10, + }} + > + <View + style={{ + alignItems: 'flex-end', + paddingTop: 10, + }} + > + <View + style={{ + ...styles.mediumText, + fontWeight: 500, + marginBottom: 5, + }} + > + <AlignedText + left={<Block>Spent MTD:</Block>} + right={ + <Text> + <PrivacyFilter blurIntensity={5}> + {amountToCurrency( + Math.abs( + data.intervalData[ + monthUtils.getDay(monthUtils.currentDay()) >= 29 + ? 28 + : monthUtils.getDay(monthUtils.currentDay()) - + 1 + ].thisMonth, + ), + )} + </PrivacyFilter> + </Text> + } + /> + <AlignedText + left={<Block>Spent Last MTD:</Block>} + right={ + <Text> + <PrivacyFilter blurIntensity={5}> + {amountToCurrency( + Math.abs( + data.intervalData[ + monthUtils.getDay(monthUtils.currentDay()) >= 29 + ? 28 + : monthUtils.getDay(monthUtils.currentDay()) - + 1 + ].lastMonth, + ), + )} + </PrivacyFilter> + </Text> + } + /> + {showAverage && ( + <AlignedText + left={<Block>Spent Average MTD:</Block>} + right={ + <Text> + <PrivacyFilter blurIntensity={5}> + {amountToCurrency( + Math.abs( + data.intervalData[ + monthUtils.getDay(monthUtils.currentDay()) >= + 29 + ? 28 + : monthUtils.getDay( + monthUtils.currentDay(), + ) - 1 + ].average, + ), + )} + </PrivacyFilter> + </Text> + } + /> + )} + </View> + </View> + <View + style={{ + alignItems: 'center', + flexDirection: 'row', + }} + > + <Text + style={{ + paddingRight: 10, + }} + > + Compare this month to: + </Text> + <ModeButton + selected={mode === 'Last month'} + onSelect={() => setMode('Last month')} + > + Last month + </ModeButton> + {showAverage && ( + <ModeButton + selected={mode === 'Average'} + onSelect={() => setMode('Average')} + > + Average + </ModeButton> + )} + </View> + + {dataCheck ? ( + <SpendingGraph + style={{ flexGrow: 1 }} + compact={false} + data={data} + mode={mode} + /> + ) : ( + <LoadingIndicator message="Loading report..." /> + )} + + {showAverage && ( + <View style={{ marginTop: 30 }}> + <Paragraph> + <strong> + How are “Average†and “Spent Average MTD†calculated? + </strong> + </Paragraph> + <Paragraph> + They are both the average cumulative spending by day for the + last three months. + </Paragraph> + </View> + )} + </View> + </View> + </View> + </View> + </View> + ); +} diff --git a/packages/desktop-client/src/components/reports/reports/SpendingCard.tsx b/packages/desktop-client/src/components/reports/reports/SpendingCard.tsx new file mode 100644 index 000000000..cd6e5ae2e --- /dev/null +++ b/packages/desktop-client/src/components/reports/reports/SpendingCard.tsx @@ -0,0 +1,86 @@ +import React, { useState, useMemo } from 'react'; + +import * as monthUtils from 'loot-core/src/shared/months'; +import { amountToCurrency } from 'loot-core/src/shared/util'; + +import { useCategories } from '../../../hooks/useCategories'; +import { styles } from '../../../style/styles'; +import { Block } from '../../common/Block'; +import { View } from '../../common/View'; +import { PrivacyFilter } from '../../PrivacyFilter'; +import { DateRange } from '../DateRange'; +import { SpendingGraph } from '../graphs/SpendingGraph'; +import { LoadingIndicator } from '../LoadingIndicator'; +import { ReportCard } from '../ReportCard'; +import { createSpendingSpreadsheet } from '../spreadsheets/spending-spreadsheet'; +import { useReport } from '../useReport'; + +export function SpendingCard() { + const categories = useCategories(); + + const [isCardHovered, setIsCardHovered] = useState(false); + + const getGraphData = useMemo(() => { + return createSpendingSpreadsheet({ + categories, + }); + }, [categories]); + + const data = useReport('default', getGraphData); + const difference = + data && + data.intervalData[monthUtils.getDay(monthUtils.currentDay()) - 1].average - + data.intervalData[monthUtils.getDay(monthUtils.currentDay()) - 1] + .thisMonth; + + return ( + <ReportCard flex="1" to="/reports/spending"> + <View + style={{ flex: 1 }} + onPointerEnter={() => setIsCardHovered(true)} + onPointerLeave={() => setIsCardHovered(false)} + > + <View style={{ flexDirection: 'row', padding: 20 }}> + <View style={{ flex: 1 }}> + <Block + style={{ ...styles.mediumText, fontWeight: 500, marginBottom: 5 }} + role="heading" + > + Monthly Spending + </Block> + <DateRange + start={monthUtils.currentMonth()} + end={monthUtils.currentMonth()} + /> + </View> + {data && ( + <View style={{ textAlign: 'right' }}> + <Block + style={{ + ...styles.mediumText, + fontWeight: 500, + marginBottom: 5, + }} + > + <PrivacyFilter activationFilters={[!isCardHovered]}> + {data && amountToCurrency(difference)} + </PrivacyFilter> + </Block> + </View> + )} + </View> + + {data ? ( + <SpendingGraph + style={{ flexGrow: 1 }} + compact={true} + data={data} + mode="average" + /> + ) : ( + <LoadingIndicator /> + )} + </View> + </ReportCard> + ); +} diff --git a/packages/desktop-client/src/components/reports/spreadsheets/spending-spreadsheet.ts b/packages/desktop-client/src/components/reports/spreadsheets/spending-spreadsheet.ts new file mode 100644 index 000000000..4203f18cc --- /dev/null +++ b/packages/desktop-client/src/components/reports/spreadsheets/spending-spreadsheet.ts @@ -0,0 +1,180 @@ +// @ts-strict-ignore + +import { runQuery } from 'loot-core/src/client/query-helpers'; +import { type useSpreadsheet } from 'loot-core/src/client/SpreadsheetProvider'; +import { send } from 'loot-core/src/platform/client/fetch'; +import * as monthUtils from 'loot-core/src/shared/months'; +import { integerToAmount } from 'loot-core/src/shared/util'; +import { + type CategoryEntity, + type RuleConditionEntity, + type CategoryGroupEntity, +} from 'loot-core/src/types/models'; +import { + type SpendingMonthEntity, + type SpendingEntity, +} from 'loot-core/src/types/models/reports'; + +import { getSpecificRange } from '../reportRanges'; +import { index } from '../util'; + +import { makeQuery } from './makeQuery'; + +type createSpendingSpreadsheetProps = { + categories: { list: CategoryEntity[]; grouped: CategoryGroupEntity[] }; + conditions?: RuleConditionEntity[]; + conditionsOp?: string; + setDataCheck?: (value: boolean) => void; +}; + +export function createSpendingSpreadsheet({ + categories, + conditions = [], + conditionsOp, + setDataCheck, +}: createSpendingSpreadsheetProps) { + const [startDate, endDate] = getSpecificRange(3, null, 'Months'); + const interval = 'Daily'; + + return async ( + spreadsheet: ReturnType<typeof useSpreadsheet>, + setData: (data: SpendingEntity) => void, + ) => { + const { filters } = await send('make-filters-from-conditions', { + conditions: conditions.filter(cond => !cond.customName), + }); + const conditionsOpKey = conditionsOp === 'or' ? '$or' : '$and'; + + const [assets, debts] = await Promise.all([ + runQuery( + makeQuery( + 'assets', + startDate, + endDate, + interval, + categories.list, + conditionsOpKey, + filters, + ), + ).then(({ data }) => data), + runQuery( + makeQuery( + 'debts', + startDate, + endDate, + interval, + categories.list, + conditionsOpKey, + filters, + ), + ).then(({ data }) => data), + ]); + + const intervals = monthUtils.dayRangeInclusive(startDate, endDate); + const days = [...Array(29).keys()] + .filter(f => f > 0) + .map(n => n.toString().padStart(2, '0')); + + let totalAssets = 0; + let totalDebts = 0; + + const months = monthUtils + .rangeInclusive(startDate, monthUtils.currentMonth() + '-01') + .map(month => { + return { month, perMonthAssets: 0, perMonthDebts: 0 }; + }); + + const intervalData = days.map(day => { + let averageSum = 0; + let monthCount = 0; + const dayData = months.map(month => { + const data = intervals.reduce((arr, intervalItem) => { + const offsetDay = + Number(intervalItem.substring(8, 10)) >= 28 + ? '28' + : intervalItem.substring(8, 10); + let perIntervalAssets = 0; + let perIntervalDebts = 0; + + if ( + month.month === monthUtils.getMonth(intervalItem) && + day === offsetDay + ) { + const intervalAssets = assets + .filter(e => !e.categoryIncome) + .filter(asset => asset.date === intervalItem) + .reduce((a, v) => (a = a + v.amount), 0); + perIntervalAssets += intervalAssets; + + const intervalDebts = debts + .filter(e => !e.categoryIncome) + .filter(debt => debt.date === intervalItem) + .reduce((a, v) => (a = a + v.amount), 0); + perIntervalDebts += intervalDebts; + + totalAssets += perIntervalAssets; + totalDebts += perIntervalDebts; + + let cumulativeAssets = 0; + let cumulativeDebts = 0; + + months.map(m => { + if (m.month === month.month) { + cumulativeAssets = m.perMonthAssets += perIntervalAssets; + cumulativeDebts = m.perMonthDebts += perIntervalDebts; + } + return null; + }); + if (month.month !== monthUtils.currentMonth()) { + averageSum += cumulativeAssets + cumulativeDebts; + monthCount += 1; + } + + arr.push({ + date: intervalItem, + totalDebts: integerToAmount(perIntervalDebts), + totalAssets: integerToAmount(perIntervalAssets), + totalTotals: integerToAmount( + perIntervalDebts + perIntervalAssets, + ), + cumulative: + intervalItem <= monthUtils.currentDay() + ? integerToAmount(cumulativeDebts + cumulativeAssets) + : null, + }); + } + + return arr; + }, []); + const maxCumulative = data.reduce((a, b) => + a.cumulative < b.cumulative ? a : b, + ).cumulative; + + return { + date: data[0].date, + cumulative: maxCumulative, + daily: data[0].totalTotals, + month: month.month, + }; + }); + const indexedData: SpendingMonthEntity = index(dayData, 'month'); + return { + months: indexedData, + day, + average: integerToAmount(averageSum) / monthCount, + thisMonth: dayData[3].cumulative, + lastMonth: dayData[2].cumulative, + }; + }); + + setData({ + intervalData, + startDate, + endDate, + totalDebts: integerToAmount(totalDebts), + totalAssets: integerToAmount(totalAssets), + totalTotals: integerToAmount(totalAssets + totalDebts), + }); + setDataCheck?.(true); + }; +} diff --git a/packages/desktop-client/src/components/settings/Experimental.tsx b/packages/desktop-client/src/components/settings/Experimental.tsx index ef0a99b10..c0917d169 100644 --- a/packages/desktop-client/src/components/settings/Experimental.tsx +++ b/packages/desktop-client/src/components/settings/Experimental.tsx @@ -80,6 +80,9 @@ export function ExperimentalFeatures() { expanded ? ( <View style={{ gap: '1em' }}> <FeatureToggle flag="customReports">Custom reports</FeatureToggle> + <FeatureToggle flag="spendingReport"> + Monthly spending + </FeatureToggle> <ReportBudgetFeature /> diff --git a/packages/desktop-client/src/hooks/useFeatureFlag.ts b/packages/desktop-client/src/hooks/useFeatureFlag.ts index 467abc9ee..d041c17bf 100644 --- a/packages/desktop-client/src/hooks/useFeatureFlag.ts +++ b/packages/desktop-client/src/hooks/useFeatureFlag.ts @@ -7,6 +7,7 @@ const DEFAULT_FEATURE_FLAG_STATE: Record<FeatureFlag, boolean> = { reportBudget: false, goalTemplatesEnabled: false, customReports: false, + spendingReport: false, simpleFinSync: false, splitsInRules: false, }; diff --git a/packages/loot-core/src/types/models/reports.d.ts b/packages/loot-core/src/types/models/reports.d.ts index 19789df76..3379aeac3 100644 --- a/packages/loot-core/src/types/models/reports.d.ts +++ b/packages/loot-core/src/types/models/reports.d.ts @@ -24,6 +24,39 @@ export interface CustomReportEntity { tombstone?: boolean; } +export type SpendingMonthEntity = Record< + string | number, + { + cumulative: number; + daily: number; + date: string; + month: string; + } +>; + +export interface SpendingDataEntity { + date: string; + totalAssets: number; + totalDebts: number; + totalTotals: number; + cumulative: number; +} + +export interface SpendingEntity { + intervalData: { + months: SpendingMonthEntity; + day: string; + average: number; + thisMonth: number; + lastMonth: number; + }[]; + startDate?: string; + endDate?: string; + totalDebts: number; + totalAssets: number; + totalTotals: number; +} + export interface GroupedEntity { data?: DataEntity[]; intervalData: DataEntity[]; diff --git a/packages/loot-core/src/types/prefs.d.ts b/packages/loot-core/src/types/prefs.d.ts index 9989c0fe0..63e20d2cd 100644 --- a/packages/loot-core/src/types/prefs.d.ts +++ b/packages/loot-core/src/types/prefs.d.ts @@ -4,6 +4,7 @@ export type FeatureFlag = | 'reportBudget' | 'goalTemplatesEnabled' | 'customReports' + | 'spendingReport' | 'simpleFinSync' | 'splitsInRules'; diff --git a/upcoming-release-notes/2622.md b/upcoming-release-notes/2622.md new file mode 100644 index 000000000..f0595a33b --- /dev/null +++ b/upcoming-release-notes/2622.md @@ -0,0 +1,6 @@ +--- +category: Features +authors: [carkom] +--- + +Add a new monthly spending report to track MTD spending compared to previous months. -- GitLab