diff --git a/packages/desktop-client/src/components/reports/DateRange.tsx b/packages/desktop-client/src/components/reports/DateRange.tsx index e2ac6d03ccd7f85642c8e22587ee66fe89d9d8fe..faefe46e9625b728aad90a7d00cc9060218b791f 100644 --- a/packages/desktop-client/src/components/reports/DateRange.tsx +++ b/packages/desktop-client/src/components/reports/DateRange.tsx @@ -10,6 +10,7 @@ import { Text } from '../common/Text'; type DateRangeProps = { start: string; end: string; + type?: string; }; function checkDate(date: string) { @@ -21,7 +22,7 @@ function checkDate(date: string) { } } -export function DateRange({ start, end }: DateRangeProps): ReactElement { +export function DateRange({ start, end, type }: DateRangeProps): ReactElement { const checkStart = checkDate(start); const checkEnd = checkDate(end); @@ -42,13 +43,23 @@ export function DateRange({ start, end }: DateRangeProps): ReactElement { if (startDate.getFullYear() !== endDate.getFullYear()) { content = ( <div> - {d.format(startDate, 'MMM yyyy')} - {d.format(endDate, 'MMM yyyy')} + {type && 'Compare '} + {d.format(startDate, 'MMM yyyy')} + {type ? ' to ' : ' - '} + {['budget', 'average'].includes(type || '') + ? type + : d.format(endDate, 'MMM yyyy')} </div> ); } else if (startDate.getMonth() !== endDate.getMonth()) { content = ( <div> - {d.format(startDate, 'MMM yyyy')} - {d.format(endDate, 'MMM yyyy')} + {type && 'Compare '} + {d.format(startDate, 'MMM yyyy')} + {type ? ' to ' : ' - '} + {['budget', 'average'].includes(type || '') + ? type + : d.format(endDate, 'MMM yyyy')} </div> ); } else { diff --git a/packages/desktop-client/src/components/reports/graphs/SpendingGraph.tsx b/packages/desktop-client/src/components/reports/graphs/SpendingGraph.tsx index a035c919b8bd6f36072378ef064b158b39287251..4c6660e40cb7758d16c46861069a03b4f6510227 100644 --- a/packages/desktop-client/src/components/reports/graphs/SpendingGraph.tsx +++ b/packages/desktop-client/src/components/reports/graphs/SpendingGraph.tsx @@ -13,7 +13,6 @@ import { ResponsiveContainer, } from 'recharts'; -import * as monthUtils from 'loot-core/src/shared/months'; import { amountToCurrency, amountToCurrencyNoDecimal, @@ -44,8 +43,6 @@ type CustomTooltipProps = { active?: boolean; payload?: PayloadItem[]; balanceTypeOp?: string; - thisMonth?: string; - lastYear?: string; selection?: string; compare?: string; }; @@ -54,18 +51,15 @@ const CustomTooltip = ({ active, payload, balanceTypeOp, - thisMonth, - lastYear, selection, compare, }: CustomTooltipProps) => { const { t } = useTranslation(); if (active && payload && payload.length) { - const comparison = - selection === 'average' - ? payload[0].payload[selection] * -1 - : payload[0].payload.months[selection].cumulative * -1; + const comparison = ['average', 'budget'].includes(selection) + ? payload[0].payload[selection] * -1 + : payload[0].payload.months[selection].cumulative * -1; return ( <div className={`${css({ @@ -88,13 +82,11 @@ const CustomTooltip = ({ </strong> </div> <div style={{ lineHeight: 1.5 }}> - {payload[0].payload.months[thisMonth].cumulative ? ( + {payload[0].payload.months[compare].cumulative ? ( <AlignedText - left={ - compare === 'thisMonth' ? t('This month:') : t('Last month:') - } + left={t('Compare:')} right={amountToCurrency( - payload[0].payload.months[thisMonth].cumulative * -1, + payload[0].payload.months[compare].cumulative * -1, )} /> ) : null} @@ -103,20 +95,18 @@ const CustomTooltip = ({ left={ selection === 'average' ? t('Average:') - : selection === lastYear - ? t('Last year:') - : compare === 'thisMonth' - ? t('Last month:') - : t('2 months ago:') + : selection === 'budget' + ? t('Budgeted:') + : t('To:') } right={amountToCurrency(comparison)} /> )} - {payload[0].payload.months[thisMonth].cumulative ? ( + {payload[0].payload.months[compare].cumulative ? ( <AlignedText left={t('Difference:')} right={amountToCurrency( - payload[0].payload.months[thisMonth].cumulative * -1 - + payload[0].payload.months[compare].cumulative * -1 - comparison, )} /> @@ -134,6 +124,7 @@ type SpendingGraphProps = { compact?: boolean; mode: string; compare: string; + compareTo: string; }; export function SpendingGraph({ @@ -142,51 +133,29 @@ export function SpendingGraph({ compact, mode, compare, + compareTo, }: SpendingGraphProps) { const privacyMode = usePrivacyMode(); const balanceTypeOp = 'cumulative'; - const thisMonth = monthUtils.subMonths( - monthUtils.currentMonth(), - compare === 'thisMonth' ? 0 : 1, - ); - const previousMonth = monthUtils.subMonths( - monthUtils.currentMonth(), - compare === 'thisMonth' ? 1 : 2, - ); - const lastYear = monthUtils.prevYear(thisMonth); - let selection; - switch (mode) { - case 'average': - selection = 'average'; - break; - case 'lastYear': - selection = lastYear; - break; - default: - selection = previousMonth; - break; - } + + const selection = mode === 'singleMonth' ? compareTo : mode; 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[selection][balanceTypeOp] < - b.months[selection][balanceTypeOp] - ? a - : b, - ).months[selection][balanceTypeOp]; + a.months[compare][balanceTypeOp] < b.months[compare][balanceTypeOp] ? a : b, + ).months[compare][balanceTypeOp]; + const selectionMax = ['average', 'budget'].includes(selection) + ? data.intervalData[27][selection] + : data.intervalData.reduce((a, b) => + a.months[selection][balanceTypeOp] < b.months[selection][balanceTypeOp] + ? a + : b, + ).months[selection][balanceTypeOp]; const maxYAxis = selectionMax > thisMonthMax; const dataMax = Math.max( - ...data.intervalData.map(i => i.months[thisMonth].cumulative), + ...data.intervalData.map(i => i.months[compare].cumulative), ); const dataMin = Math.min( - ...data.intervalData.map(i => i.months[thisMonth].cumulative), + ...data.intervalData.map(i => i.months[compare].cumulative), ); const tickFormatter = tick => { @@ -206,7 +175,7 @@ export function SpendingGraph({ }; const getVal = (obj, month) => { - if (month === 'average') { + if (['average', 'budget'].includes(month)) { return obj[month] && -1 * obj[month]; } else { return ( @@ -255,9 +224,7 @@ export function SpendingGraph({ )} {compact ? null : ( <YAxis - dataKey={val => - getVal(val, maxYAxis ? thisMonth : selection) - } + dataKey={val => getVal(val, maxYAxis ? compare : selection)} domain={[0, 'auto']} tickFormatter={tickFormatter} tick={{ fill: theme.pageText }} @@ -269,8 +236,6 @@ export function SpendingGraph({ content={ <CustomTooltip balanceTypeOp={balanceTypeOp} - thisMonth={thisMonth} - lastYear={lastYear} selection={selection} compare={compare} /> @@ -316,7 +281,7 @@ export function SpendingGraph({ r: 10, }} animationDuration={0} - dataKey={val => getVal(val, thisMonth)} + dataKey={val => getVal(val, compare)} stroke={`url(#stroke${balanceTypeOp})`} strokeWidth={3} fill={`url(#fill${balanceTypeOp})`} diff --git a/packages/desktop-client/src/components/reports/reports/Spending.tsx b/packages/desktop-client/src/components/reports/reports/Spending.tsx index 7301a109804312abdb96a0f7f3eb3b8af9081906..ec6c8100850db83b7c8f320375b662cdb9aad2f5 100644 --- a/packages/desktop-client/src/components/reports/reports/Spending.tsx +++ b/packages/desktop-client/src/components/reports/reports/Spending.tsx @@ -1,5 +1,8 @@ import React, { useState, useMemo, useEffect } from 'react'; +import * as d from 'date-fns'; + +import { send } from 'loot-core/src/platform/client/fetch'; 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'; @@ -27,6 +30,7 @@ import { LoadingIndicator } from '../LoadingIndicator'; import { ModeButton } from '../ModeButton'; import { createSpendingSpreadsheet } from '../spreadsheets/spending-spreadsheet'; import { useReport } from '../useReport'; +import { fromDateRepr } from '../util'; export function Spending() { const { @@ -38,26 +42,68 @@ export function Spending() { onConditionsOpChange, } = useFilters<RuleConditionEntity>(); + const emptyIntervals: { name: string; pretty: string }[] = []; + const [allIntervals, setAllIntervals] = useState(emptyIntervals); + const [spendingReportFilter = '', setSpendingReportFilter] = useLocalPref( 'spendingReportFilter', ); - const [spendingReportTime = 'lastMonth', setSpendingReportTime] = - useLocalPref('spendingReportTime'); - const [spendingReportCompare = 'thisMonth', setSpendingReportCompare] = - useLocalPref('spendingReportCompare'); + const [spendingReportMode = 'singleMonth', setSpendingReportMode] = + useLocalPref('spendingReportMode'); + const [ + spendingReportCompare = monthUtils.currentMonth(), + setSpendingReportCompare, + ] = useLocalPref('spendingReportCompare'); + const [ + spendingReportCompareTo = monthUtils.currentMonth(), + setSpendingReportCompareTo, + ] = useLocalPref('spendingReportCompareTo'); + const isDateValid = monthUtils.parseDate(spendingReportCompare); const [dataCheck, setDataCheck] = useState(false); - const [compare, setCompare] = useState(spendingReportCompare); - const [mode, setMode] = useState(spendingReportTime); + const [mode, setMode] = useState(spendingReportMode); + const [compare, setCompare] = useState( + isDateValid.toString() === 'Invalid Date' + ? monthUtils.currentMonth() + : spendingReportCompare, + ); + const [compareTo, setCompareTo] = useState(spendingReportCompareTo); const parseFilter = spendingReportFilter && JSON.parse(spendingReportFilter); const filterSaved = JSON.stringify(parseFilter.conditions) === JSON.stringify(conditions) && parseFilter.conditionsOp === conditionsOp && - spendingReportTime === mode && - spendingReportCompare === compare; + spendingReportMode === mode && + spendingReportCompare === compare && + spendingReportCompareTo === compareTo; useEffect(() => { + async function run() { + const trans = await send('get-earliest-transaction'); + + let earliestMonth = trans + ? monthUtils.monthFromDate(d.parseISO(fromDateRepr(trans.date))) + : monthUtils.currentMonth(); + + // Make sure the month selects are at least populates with a + // year's worth of months. We can undo this when we have fancier + // date selects. + const yearAgo = monthUtils.subMonths(monthUtils.currentMonth(), 12); + if (earliestMonth > yearAgo) { + earliestMonth = yearAgo; + } + + const allMonths = monthUtils + .rangeInclusive(earliestMonth, monthUtils.currentMonth()) + .map(month => ({ + name: month, + pretty: monthUtils.format(month, 'MMMM, yyyy'), + })) + .reverse(); + + setAllIntervals(allMonths); + } + run(); const checkFilter = spendingReportFilter && JSON.parse(spendingReportFilter); if (checkFilter.conditions) { @@ -72,8 +118,9 @@ export function Spending() { conditionsOp, setDataCheck, compare, + compareTo, }); - }, [conditions, conditionsOp, compare]); + }, [conditions, conditionsOp, compare, compareTo]); const data = useReport('default', getGraphData); const navigate = useNavigate(); @@ -90,32 +137,28 @@ export function Spending() { conditions, }), ); - setSpendingReportTime(mode); + setSpendingReportMode(mode); setSpendingReportCompare(compare); + setSpendingReportCompareTo(compareTo); }; const showAverage = + data.intervalData[27].months[monthUtils.subMonths(compare, 3)] && Math.abs( - data.intervalData[27].months[ - monthUtils.subMonths(monthUtils.currentDay(), 3) - ].cumulative, + data.intervalData[27].months[monthUtils.subMonths(compare, 3)].cumulative, ) > 0; const todayDay = - compare === 'lastMonth' + compare !== monthUtils.currentMonth() ? 27 : monthUtils.getDay(monthUtils.currentDay()) - 1 >= 28 ? 27 : monthUtils.getDay(monthUtils.currentDay()) - 1; - const showLastYear = - Math.abs( - data.intervalData[27][ - compare === 'thisMonth' ? 'lastYear' : 'lastYearPrevious' - ], - ) > 0; - const showPreviousMonth = - Math.abs(data.intervalData[27][spendingReportTime]) > 0; + const showCompareTo = Math.abs(data.intervalData[27].compareTo) > 0; + const showCompare = + compare === monthUtils.currentMonth() || + Math.abs(data.intervalData[27].compare) > 0; return ( <Page header={ @@ -161,23 +204,37 @@ export function Spending() { value={compare} onChange={e => { setCompare(e); - if (mode === 'lastMonth') setMode('twoMonthsPrevious'); - if (mode === 'twoMonthsPrevious') setMode('lastMonth'); }} - options={[ - ['thisMonth', 'this month'], - ['lastMonth', 'last month'], - ]} + options={allIntervals.map(({ name, pretty }) => [name, pretty])} /> <Text style={{ - paddingRight: 10, + paddingRight: 5, paddingLeft: 5, }} > - to the: + to </Text> + <Select + value={compareTo} + onChange={e => { + setCompareTo(e); + }} + options={allIntervals.map(({ name, pretty }) => [name, pretty])} + disabled={mode !== 'singleMonth'} + /> </View> + {!isNarrowWidth && ( + <View + style={{ + width: 1, + height: 30, + backgroundColor: theme.pillBorderDark, + marginRight: 15, + marginLeft: 10, + }} + /> + )} <View style={{ flexDirection: 'row', @@ -187,52 +244,42 @@ export function Spending() { }} > <ModeButton - selected={['lastMonth', 'twoMonthsPrevious'].includes(mode)} + selected={mode === 'singleMonth'} style={{ backgroundColor: 'inherit', }} - onSelect={() => - setMode( - compare === 'thisMonth' ? 'lastMonth' : 'twoMonthsPrevious', - ) - } + onSelect={() => setMode('singleMonth')} > - Previous month + Single month + </ModeButton> + <ModeButton + selected={mode === 'budget'} + onSelect={() => setMode('budget')} + style={{ + backgroundColor: 'inherit', + }} + > + Budgeted + </ModeButton> + <ModeButton + selected={mode === 'average'} + onSelect={() => setMode('average')} + style={{ + backgroundColor: 'inherit', + }} + > + Average </ModeButton> - {showLastYear && ( - <ModeButton - selected={mode === 'lastYear'} - onSelect={() => setMode('lastYear')} - style={{ - backgroundColor: 'inherit', - }} - > - Last year - </ModeButton> - )} - {showAverage && ( - <ModeButton - selected={mode === 'average'} - onSelect={() => setMode('average')} - style={{ - backgroundColor: 'inherit', - }} - > - Average - </ModeButton> - )} </View> {!isNarrowWidth && ( - <> - <View - style={{ - width: 1, - height: 30, - backgroundColor: theme.pillBorderDark, - marginRight: 10, - }} - />{' '} - </> + <View + style={{ + width: 1, + height: 30, + backgroundColor: theme.pillBorderDark, + marginRight: 10, + }} + /> )} <View style={{ @@ -251,7 +298,7 @@ export function Spending() { /> <View style={{ flex: 1 }} /> <Tooltip - placement="bottom start" + placement="top end" content={<Text>Save compare and filter options</Text>} style={{ ...styles.tooltip, @@ -335,67 +382,72 @@ export function Spending() { color: theme.pageText, }} > - {showPreviousMonth && ( - <View> + <View> + {showCompareTo && ( <AlignedText style={{ marginBottom: 5, minWidth: 210 }} left={ <Block> - Spent{' '} - {compare === 'thisMonth' ? 'MTD' : 'Last Month'}: + Spent {monthUtils.format(compare, 'MMM, yyyy')} + {compare === monthUtils.currentMonth() && ' MTD'}: </Block> } right={ <Text style={{ fontWeight: 600 }}> <PrivacyFilter blurIntensity={5}> {amountToCurrency( - Math.abs( - data.intervalData[todayDay][ - compare === 'thisMonth' - ? 'thisMonth' - : 'lastMonth' - ], - ), + Math.abs(data.intervalData[todayDay].compare), )} </PrivacyFilter> </Text> } /> + )} + {mode === 'singleMonth' && ( <AlignedText style={{ marginBottom: 5, minWidth: 210 }} left={ <Block> - Spent{' '} - {compare === 'thisMonth' - ? ' Last MTD' - : '2 Months Ago'} - : + Spent {monthUtils.format(compareTo, 'MMM, yyyy')}: </Block> } right={ <Text style={{ fontWeight: 600 }}> <PrivacyFilter blurIntensity={5}> {amountToCurrency( - Math.abs( - data.intervalData[todayDay][ - compare === 'thisMonth' - ? 'lastMonth' - : 'twoMonthsPrevious' - ], - ), + Math.abs(data.intervalData[todayDay].compareTo), )} </PrivacyFilter> </Text> } /> - </View> - )} + )} + </View> + <AlignedText + style={{ marginBottom: 5, minWidth: 210 }} + left={ + <Block> + Budgeted + {compare === monthUtils.currentMonth() && ' MTD'}: + </Block> + } + right={ + <Text style={{ fontWeight: 600 }}> + <PrivacyFilter blurIntensity={5}> + {amountToCurrency( + Math.abs(data.intervalData[todayDay].budget), + )} + </PrivacyFilter> + </Text> + } + /> {showAverage && ( <AlignedText style={{ marginBottom: 5, minWidth: 210 }} left={ <Block> - Spent Average{compare === 'thisMonth' && ' MTD'}: + Spent Average + {compare === monthUtils.currentMonth() && ' MTD'}: </Block> } right={ @@ -411,13 +463,15 @@ export function Spending() { )} </View> </View> - {!showPreviousMonth ? ( + {!showCompare || + (mode === 'singleMonth' && !showCompareTo) || + (mode === 'average' && !showAverage) ? ( <View style={{ marginTop: 20 }}> <h1>Additional data required to generate graph</h1> <Paragraph> - Currently, there is insufficient data to display any - information regarding your spending. Please input - transactions from last month to enable graph visualization. + Currently, there is insufficient data to display selected + information regarding your spending. Please adjust selection + options to enable graph visualization. </Paragraph> </View> ) : dataCheck ? ( @@ -427,6 +481,7 @@ export function Spending() { data={data} mode={mode} compare={compare} + compareTo={compareTo} /> ) : ( <LoadingIndicator message="Loading report..." /> @@ -440,7 +495,7 @@ export function Spending() { </Paragraph> <Paragraph> They are both the average cumulative spending by day for the - last three months. + three months before the selected “compare†month. </Paragraph> </View> )} diff --git a/packages/desktop-client/src/components/reports/reports/SpendingCard.tsx b/packages/desktop-client/src/components/reports/reports/SpendingCard.tsx index 3e99ac1edf93f3de571e825bd3b792a798945a22..dd3cffe0699d8ebe193f1ea86bc36c6514dc4bca 100644 --- a/packages/desktop-client/src/components/reports/reports/SpendingCard.tsx +++ b/packages/desktop-client/src/components/reports/reports/SpendingCard.tsx @@ -39,34 +39,50 @@ export function SpendingCard({ const [isCardHovered, setIsCardHovered] = useState(false); const [spendingReportFilter = ''] = useLocalPref('spendingReportFilter'); - const [spendingReportTime = 'lastMonth'] = useLocalPref('spendingReportTime'); - const [spendingReportCompare = 'thisMonth'] = useLocalPref( + const [spendingReportMode = 'singleMonth'] = + useLocalPref('spendingReportMode'); + const [spendingReportCompare = monthUtils.currentMonth()] = useLocalPref( 'spendingReportCompare', ); + const [spendingReportCompareTo = monthUtils.currentMonth()] = useLocalPref( + 'spendingReportCompareTo', + ); const [nameMenuOpen, setNameMenuOpen] = useState(false); + const selection = + spendingReportMode === 'singleMonth' ? 'compareTo' : spendingReportMode; const parseFilter = spendingReportFilter && JSON.parse(spendingReportFilter); + const isDateValid = monthUtils.parseDate(spendingReportCompare); const getGraphData = useMemo(() => { return createSpendingSpreadsheet({ conditions: parseFilter.conditions, conditionsOp: parseFilter.conditionsOp, - compare: spendingReportCompare, + compare: + isDateValid.toString() === 'Invalid Date' + ? monthUtils.currentMonth() + : spendingReportCompare, + compareTo: spendingReportCompareTo, }); - }, [parseFilter, spendingReportCompare]); + }, [ + parseFilter, + spendingReportCompare, + spendingReportCompareTo, + isDateValid, + ]); const data = useReport('default', getGraphData); const todayDay = - spendingReportCompare === 'lastMonth' + spendingReportCompare !== monthUtils.currentMonth() ? 27 : monthUtils.getDay(monthUtils.currentDay()) - 1 >= 28 ? 27 : monthUtils.getDay(monthUtils.currentDay()) - 1; const difference = data && - data.intervalData[todayDay][spendingReportTime] - - data.intervalData[todayDay][spendingReportCompare]; - const showLastMonth = data && Math.abs(data.intervalData[27].lastMonth) > 0; + data.intervalData[todayDay][selection] - + data.intervalData[todayDay].compare; + const showCompareTo = data && Math.abs(data.intervalData[27].compareTo) > 0; const spendingReportFeatureFlag = useFeatureFlag('spendingReport'); @@ -126,11 +142,12 @@ export function SpendingCard({ onClose={() => setNameMenuOpen(false)} /> <DateRange - start={monthUtils.addMonths(monthUtils.currentMonth(), 1)} - end={monthUtils.addMonths(monthUtils.currentMonth(), 1)} + start={spendingReportCompare} + end={spendingReportCompareTo} + type={spendingReportMode} /> </View> - {data && showLastMonth && ( + {data && showCompareTo && ( <View style={{ textAlign: 'right' }}> <Block style={{ @@ -153,7 +170,7 @@ export function SpendingCard({ </View> )} </View> - {!showLastMonth ? ( + {!showCompareTo || isDateValid.toString() === 'Invalid Date' ? ( <View style={{ padding: 5 }}> <p style={{ margin: 0, textAlign: 'center' }}> <Trans>Additional data required to generate graph</Trans> @@ -164,8 +181,9 @@ export function SpendingCard({ style={{ flex: 1 }} compact={true} data={data} - mode={spendingReportTime} + mode={spendingReportMode} compare={spendingReportCompare} + compareTo={spendingReportCompareTo} /> ) : ( <LoadingIndicator message={t('Loading report...')} /> diff --git a/packages/desktop-client/src/components/reports/spreadsheets/spending-spreadsheet.ts b/packages/desktop-client/src/components/reports/spreadsheets/spending-spreadsheet.ts index 7324405d20d37186d04ce09985d62e9b1f17f503..0ffa6d7422ed9a58422a480e39011ab016c8e556 100644 --- a/packages/desktop-client/src/components/reports/spreadsheets/spending-spreadsheet.ts +++ b/packages/desktop-client/src/components/reports/spreadsheets/spending-spreadsheet.ts @@ -5,6 +5,7 @@ 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 { q } from 'loot-core/src/shared/query'; import { integerToAmount } from 'loot-core/src/shared/util'; import { type RuleConditionEntity } from 'loot-core/src/types/models'; import { @@ -12,8 +13,6 @@ import { type SpendingEntity, } from 'loot-core/src/types/models/reports'; -import { getSpecificRange } from '../reportRanges'; - import { makeQuery } from './makeQuery'; type createSpendingSpreadsheetProps = { @@ -21,6 +20,7 @@ type createSpendingSpreadsheetProps = { conditionsOp?: string; setDataCheck?: (value: boolean) => void; compare?: string; + compareTo?: string; }; export function createSpendingSpreadsheet({ @@ -28,22 +28,17 @@ export function createSpendingSpreadsheet({ conditionsOp, setDataCheck, compare, + compareTo, }: createSpendingSpreadsheetProps) { - const thisMonth = monthUtils.subMonths( - monthUtils.currentMonth(), - compare === 'thisMonth' ? 0 : 1, - ); - const [startDate, endDate] = getSpecificRange( - compare === 'thisMonth' ? 3 : 4, - null, - 'Months', - ); - const [lastYearStartDate, lastYearEndDate] = getSpecificRange( - 13, - 1, - 'Months', - ); + const startDate = monthUtils.subMonths(compare, 3) + '-01'; + const endDate = monthUtils.getMonthEnd(compare + '-01'); + const startDateTo = compareTo + '-01'; + const endDateTo = monthUtils.getMonthEnd(compareTo + '-01'); const interval = 'Daily'; + const compareInterval = monthUtils.dayRangeInclusive( + compare + '-01', + endDate, + ); return async ( spreadsheet: ReturnType<typeof useSpreadsheet>, @@ -58,7 +53,7 @@ export function createSpendingSpreadsheet({ runQuery( makeQuery( 'assets', - lastYearStartDate, + startDate, endDate, interval, conditionsOpKey, @@ -68,7 +63,7 @@ export function createSpendingSpreadsheet({ runQuery( makeQuery( 'debts', - lastYearStartDate, + startDate, endDate, interval, conditionsOpKey, @@ -77,36 +72,84 @@ export function createSpendingSpreadsheet({ ).then(({ data }) => data), ]); + const [assetsTo, debtsTo] = await Promise.all([ + runQuery( + makeQuery( + 'assets', + startDateTo, + endDateTo, + interval, + conditionsOpKey, + filters, + ), + ).then(({ data }) => data), + runQuery( + makeQuery( + 'debts', + startDateTo, + endDateTo, + interval, + conditionsOpKey, + filters, + ), + ).then(({ data }) => data), + ]); + + const overlapAssets = + endDateTo < startDate || startDateTo > endDate ? assetsTo : []; + const overlapDebts = + endDateTo < startDate || startDateTo > endDate ? debtsTo : []; + + const combineAssets = [...assets, ...overlapAssets]; + const combineDebts = [...debts, ...overlapDebts]; + + const budgetMonth = parseInt(compare.replace('-', '')); + const [budgets] = await Promise.all([ + runQuery( + q('zero_budgets') + .filter({ + $and: [{ month: { $eq: budgetMonth } }], + }) + .filter({ + [conditionsOpKey]: filters.filter(filter => filter.category), + }) + .groupBy([{ $id: '$category' }]) + .select([ + { category: { $id: '$category' } }, + { amount: { $sum: '$amount' } }, + ]), + ).then(({ data }) => data), + ]); + + const dailyBudget = + budgets && + integerToAmount(budgets.reduce((a, v) => (a = a + v.amount), 0)) / + compareInterval.length; + const intervals = monthUtils.dayRangeInclusive(startDate, endDate); - intervals.push( - ...monthUtils.dayRangeInclusive(lastYearStartDate, lastYearEndDate), - ); + if (endDateTo < startDate || startDateTo > endDate) { + intervals.push(...monthUtils.dayRangeInclusive(startDateTo, endDateTo)); + } + const days = [...Array(29).keys()] .filter(f => f > 0) .map(n => n.toString().padStart(2, '0')); let totalAssets = 0; let totalDebts = 0; + let totalBudget = 0; - const months = monthUtils - .rangeInclusive(startDate, monthUtils.currentMonth() + '-01') - .map(month => { - return { month, perMonthAssets: 0, perMonthDebts: 0 }; - }); - - months.unshift({ - month: monthUtils.prevYear( - monthUtils.subMonths(monthUtils.currentMonth(), 1), - ), - perMonthAssets: 0, - perMonthDebts: 0, + const months = monthUtils.rangeInclusive(startDate, endDate).map(month => { + return { month, perMonthAssets: 0, perMonthDebts: 0 }; }); - months.unshift({ - month: monthUtils.prevYear(monthUtils.currentMonth()), - perMonthAssets: 0, - perMonthDebts: 0, - }); + if (endDateTo < startDate || startDateTo > endDate) { + months.unshift({ + month: compareTo, + perMonthAssets: 0, + perMonthDebts: 0, + }); + } const intervalData = days.map(day => { let averageSum = 0; @@ -124,13 +167,13 @@ export function createSpendingSpreadsheet({ month.month === monthUtils.getMonth(intervalItem) && day === offsetDay ) { - const intervalAssets = assets + const intervalAssets = combineAssets .filter(e => !e.categoryIncome && !e.accountOffBudget) .filter(asset => asset.date === intervalItem) .reduce((a, v) => (a = a + v.amount), 0); perIntervalAssets += intervalAssets; - const intervalDebts = debts + const intervalDebts = combineDebts .filter(e => !e.categoryIncome && !e.accountOffBudget) .filter(debt => debt.date === intervalItem) .reduce((a, v) => (a = a + v.amount), 0); @@ -142,6 +185,10 @@ export function createSpendingSpreadsheet({ let cumulativeAssets = 0; let cumulativeDebts = 0; + if (month.month === compare) { + totalBudget -= dailyBudget; + } + months.map(m => { if (m.month === month.month) { cumulativeAssets = m.perMonthAssets += perIntervalAssets; @@ -149,15 +196,7 @@ export function createSpendingSpreadsheet({ } return null; }); - if ( - month.month !== monthUtils.currentMonth() && - month.month !== thisMonth && - month.month !== monthUtils.prevYear(monthUtils.currentMonth()) && - month.month !== - monthUtils.prevYear( - monthUtils.subMonths(monthUtils.currentMonth(), 1), - ) - ) { + if (month.month >= startDate && month.month < compare) { if (day === '28') { if (monthUtils.getMonthEnd(intervalItem) === intervalItem) { averageSum += cumulativeAssets + cumulativeDebts; @@ -203,11 +242,9 @@ export function createSpendingSpreadsheet({ months: indexedData, day, average: integerToAmount(averageSum) / monthCount, - thisMonth: dayData[dayData.length - 1].cumulative, - lastMonth: dayData[dayData.length - 2].cumulative, - twoMonthsPrevious: dayData[dayData.length - 3].cumulative, - lastYear: dayData[0].cumulative, - lastYearPrevious: dayData[1].cumulative, + compare: dayData.filter(c => c.month === compare)[0].cumulative, + compareTo: dayData.filter(c => c.month === compareTo)[0].cumulative, + budget: totalBudget, }; }); diff --git a/packages/loot-core/src/types/models/reports.d.ts b/packages/loot-core/src/types/models/reports.d.ts index db9a798f1e707727ba63ff12173048a13d1480d7..465352a8e307356ab74f8ac41b057fddbec95423 100644 --- a/packages/loot-core/src/types/models/reports.d.ts +++ b/packages/loot-core/src/types/models/reports.d.ts @@ -30,13 +30,7 @@ export type balanceTypeOpType = | 'netAssets' | 'netDebts'; -export type spendingReportTimeType = - | 'average' - | 'thisMonth' - | 'lastMonth' - | 'twoMonthsPrevious' - | 'lastYear' - | 'lastYearPrevious'; +export type spendingReportModeType = 'singleMonth' | 'average' | 'budget'; export type SpendingMonthEntity = Record< string | number, @@ -61,11 +55,9 @@ export interface SpendingEntity { months: SpendingMonthEntity; day: string; average: number; - thisMonth: number; - lastMonth: number; - twoMonthsPrevious: number; - lastYear: number; - lastYearPrevious: number; + compare: number; + compareTo: number; + budget: number; }[]; startDate?: string; endDate?: string; diff --git a/packages/loot-core/src/types/prefs.d.ts b/packages/loot-core/src/types/prefs.d.ts index 132fde3a9944115c8b18fb3bcab4bd9f5ec7aeca..b500745a78c60f05a588c1c187be9c6d492ab3bb 100644 --- a/packages/loot-core/src/types/prefs.d.ts +++ b/packages/loot-core/src/types/prefs.d.ts @@ -1,4 +1,4 @@ -import { spendingReportTimeType } from './models/reports'; +import { spendingReportModeType } from './models/reports'; export type FeatureFlag = | 'dashboards' @@ -72,8 +72,9 @@ export type LocalPrefs = SyncedPrefs & reportsViewSummary: boolean; reportsViewLabel: boolean; spendingReportFilter: string; - spendingReportTime: spendingReportTimeType; - spendingReportCompare: spendingReportTimeType; + spendingReportMode: spendingReportModeType; + spendingReportCompare: string; + spendingReportCompareTo: string; sidebarWidth: number; 'mobile.showSpentColumn': boolean; }>; diff --git a/upcoming-release-notes/3380.md b/upcoming-release-notes/3380.md new file mode 100644 index 0000000000000000000000000000000000000000..d1d25f0e9287ad9d9116e1d3b5f1ef48f52d3514 --- /dev/null +++ b/upcoming-release-notes/3380.md @@ -0,0 +1,6 @@ +--- +category: Enhancements +authors: [carkom] +--- + +Fixing spending report header so that any month can be compared to any other month. Also adds budget as an optional comparison.