From e335aad4e567a0413eaa160f2e1e1324f65c5c0d Mon Sep 17 00:00:00 2001 From: Matiss Janis Aboltins Date: Fri, 13 Sep 2024 22:39:13 +0100 Subject: [PATCH 01/13] :sparkles: (dashboards) ability to save filters & timeframes on spending widgets --- .../src/components/reports/DateRange.tsx | 6 + .../src/components/reports/Header.tsx | 2 +- .../src/components/reports/Overview.tsx | 1 + .../src/components/reports/ReportRouter.tsx | 1 + .../reports/graphs/SpendingGraph.tsx | 2 +- .../components/reports/reports/Spending.tsx | 263 +++++++++++------- .../reports/reports/SpendingCard.tsx | 70 ++--- .../loot-core/src/types/models/dashboard.d.ts | 8 +- .../loot-core/src/types/models/reports.d.ts | 2 - packages/loot-core/src/types/prefs.d.ts | 6 - upcoming-release-notes/3432.md | 6 + 11 files changed, 203 insertions(+), 164 deletions(-) create mode 100644 upcoming-release-notes/3432.md diff --git a/packages/desktop-client/src/components/reports/DateRange.tsx b/packages/desktop-client/src/components/reports/DateRange.tsx index faefe46e962..7b4e75b5be5 100644 --- a/packages/desktop-client/src/components/reports/DateRange.tsx +++ b/packages/desktop-client/src/components/reports/DateRange.tsx @@ -62,6 +62,12 @@ export function DateRange({ start, end, type }: DateRangeProps): ReactElement { : d.format(endDate, 'MMM yyyy')} ); + } else if (['budget', 'average'].includes(type || '')) { + content = ( +
+ Compare {d.format(startDate, 'MMM yyyy')} to {type} +
+ ); } else { content = d.format(endDate, 'MMMM yyyy'); } diff --git a/packages/desktop-client/src/components/reports/Header.tsx b/packages/desktop-client/src/components/reports/Header.tsx index 1eb9fe0ab52..481e8b3ce0b 100644 --- a/packages/desktop-client/src/components/reports/Header.tsx +++ b/packages/desktop-client/src/components/reports/Header.tsx @@ -73,7 +73,7 @@ export function Header({ flexShrink: 0, }} > - {!['/reports/custom', '/reports/spending'].includes(path) && ( + {!['/reports/custom'].includes(path) && ( ) : item.type === 'spending-card' ? ( onMetaChange(item, newMeta)} diff --git a/packages/desktop-client/src/components/reports/ReportRouter.tsx b/packages/desktop-client/src/components/reports/ReportRouter.tsx index 08dfa109e89..71719624faf 100644 --- a/packages/desktop-client/src/components/reports/ReportRouter.tsx +++ b/packages/desktop-client/src/components/reports/ReportRouter.tsx @@ -17,6 +17,7 @@ export function ReportRouter() { } /> } /> } /> + } /> ); } diff --git a/packages/desktop-client/src/components/reports/graphs/SpendingGraph.tsx b/packages/desktop-client/src/components/reports/graphs/SpendingGraph.tsx index 4c6660e40cb..66950adbaec 100644 --- a/packages/desktop-client/src/components/reports/graphs/SpendingGraph.tsx +++ b/packages/desktop-client/src/components/reports/graphs/SpendingGraph.tsx @@ -138,7 +138,7 @@ export function SpendingGraph({ const privacyMode = usePrivacyMode(); const balanceTypeOp = 'cumulative'; - const selection = mode === 'singleMonth' ? compareTo : mode; + const selection = mode === 'single-month' ? compareTo : mode; const thisMonthMax = data.intervalData.reduce((a, b) => a.months[compare][balanceTypeOp] < b.months[compare][balanceTypeOp] ? a : b, diff --git a/packages/desktop-client/src/components/reports/reports/Spending.tsx b/packages/desktop-client/src/components/reports/reports/Spending.tsx index ec6c8100850..a974d2374cd 100644 --- a/packages/desktop-client/src/components/reports/reports/Spending.tsx +++ b/packages/desktop-client/src/components/reports/reports/Spending.tsx @@ -1,14 +1,19 @@ import React, { useState, useMemo, useEffect } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import { useDispatch } from 'react-redux'; +import { useParams } from 'react-router-dom'; import * as d from 'date-fns'; +import { addNotification } from 'loot-core/client/actions'; +import { useWidget } from 'loot-core/client/data-hooks/widget'; 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 SpendingWidget } from 'loot-core/types/models'; import { type RuleConditionEntity } from 'loot-core/types/models/rule'; import { useFilters } from '../../../hooks/useFilters'; -import { useLocalPref } from '../../../hooks/useLocalPref'; import { useNavigate } from '../../../hooks/useNavigate'; import { useResponsive } from '../../../ResponsiveProvider'; import { theme, styles } from '../../../style'; @@ -28,11 +33,33 @@ import { PrivacyFilter } from '../../PrivacyFilter'; import { SpendingGraph } from '../graphs/SpendingGraph'; import { LoadingIndicator } from '../LoadingIndicator'; import { ModeButton } from '../ModeButton'; +import { calculateTimeRange } from '../reportRanges'; import { createSpendingSpreadsheet } from '../spreadsheets/spending-spreadsheet'; import { useReport } from '../useReport'; import { fromDateRepr } from '../util'; export function Spending() { + const params = useParams(); + const { data: widget, isLoading } = useWidget( + params.id ?? '', + 'spending-card', + ); + + if (isLoading) { + return ; + } + + return ; +} + +type SpendingInternalProps = { + widget: SpendingWidget; +}; + +function SpendingInternal({ widget }: SpendingInternalProps) { + const dispatch = useDispatch(); + const { t } = useTranslation(); + const { conditions, conditionsOp, @@ -40,42 +67,24 @@ export function Spending() { onDelete: onDeleteFilter, onUpdate: onUpdateFilter, onConditionsOpChange, - } = useFilters(); + } = useFilters( + widget?.meta?.conditions, + widget?.meta?.conditionsOp, + ); const emptyIntervals: { name: string; pretty: string }[] = []; const [allIntervals, setAllIntervals] = useState(emptyIntervals); - const [spendingReportFilter = '', setSpendingReportFilter] = useLocalPref( - 'spendingReportFilter', + const initialReportMode = widget?.meta?.mode ?? 'single-month'; + const [initialStart, initialEnd, initialMode] = calculateTimeRange( + widget?.meta?.timeFrame, ); - const [spendingReportMode = 'singleMonth', setSpendingReportMode] = - useLocalPref('spendingReportMode'); - const [ - spendingReportCompare = monthUtils.currentMonth(), - setSpendingReportCompare, - ] = useLocalPref('spendingReportCompare'); - const [ - spendingReportCompareTo = monthUtils.currentMonth(), - setSpendingReportCompareTo, - ] = useLocalPref('spendingReportCompareTo'); + const [start, setStart] = useState(initialStart); + const [end, setEnd] = useState(initialEnd); + const [mode, setMode] = useState(initialMode); - const isDateValid = monthUtils.parseDate(spendingReportCompare); const [dataCheck, setDataCheck] = useState(false); - 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 && - spendingReportMode === mode && - spendingReportCompare === compare && - spendingReportCompareTo === compareTo; + const [reportMode, setReportMode] = useState(initialReportMode); useEffect(() => { async function run() { @@ -104,12 +113,7 @@ export function Spending() { setAllIntervals(allMonths); } run(); - const checkFilter = - spendingReportFilter && JSON.parse(spendingReportFilter); - if (checkFilter.conditions) { - onApplyFilter(checkFilter); - } - }, [onApplyFilter, spendingReportFilter]); + }, []); const getGraphData = useMemo(() => { setDataCheck(false); @@ -117,39 +121,50 @@ export function Spending() { conditions, conditionsOp, setDataCheck, - compare, - compareTo, + compare: start, + compareTo: end, }); - }, [conditions, conditionsOp, compare, compareTo]); + }, [conditions, conditionsOp, start, end]); const data = useReport('default', getGraphData); const navigate = useNavigate(); const { isNarrowWidth } = useResponsive(); - if (!data) { - return null; - } - - const saveFilter = () => { - setSpendingReportFilter( - JSON.stringify({ - conditionsOp, + async function onSaveWidget() { + await send('dashboard-update-widget', { + id: widget?.id, + meta: { + ...(widget.meta ?? {}), conditions, + conditionsOp, + timeFrame: { + start, + end, + mode, + }, + mode: reportMode, + }, + }); + dispatch( + addNotification({ + type: 'message', + message: t('Dashboard widget successfully saved.'), }), ); - setSpendingReportMode(mode); - setSpendingReportCompare(compare); - setSpendingReportCompareTo(compareTo); - }; + } + + if (!data) { + return null; + } const showAverage = - data.intervalData[27].months[monthUtils.subMonths(compare, 3)] && + data.intervalData[27].months[monthUtils.subMonths(start, 3)] && Math.abs( - data.intervalData[27].months[monthUtils.subMonths(compare, 3)].cumulative, + data.intervalData[27].months[monthUtils.subMonths(start, 3)].cumulative, ) > 0; const todayDay = - compare !== monthUtils.currentMonth() + start !== monthUtils.currentMonth() ? 27 : monthUtils.getDay(monthUtils.currentDay()) - 1 >= 28 ? 27 @@ -157,20 +172,23 @@ export function Spending() { const showCompareTo = Math.abs(data.intervalData[27].compareTo) > 0; const showCompare = - compare === monthUtils.currentMonth() || + start === monthUtils.currentMonth() || Math.abs(data.intervalData[27].compare) > 0; + + const title = widget?.meta?.name ?? t('Monthly Spending'); + return ( navigate('/reports')} /> } /> ) : ( - + ) } padding={0} @@ -198,14 +216,27 @@ export function Spending() { paddingRight: 5, }} > - Compare + Compare { - setCompareTo(e); - }} + value={end} + onChange={setEnd} options={allIntervals.map(({ name, pretty }) => [name, pretty])} - disabled={mode !== 'singleMonth'} + disabled={reportMode !== 'single-month'} /> {!isNarrowWidth && ( @@ -244,31 +273,42 @@ export function Spending() { }} > setMode('singleMonth')} + onSelect={() => { + setMode('static'); + setReportMode('single-month'); + }} > - Single month + Single month setMode('budget')} + selected={reportMode === 'budget'} + onSelect={() => { + setMode('sliding-window'); + setStart(monthUtils.currentMonth()); + setReportMode('budget'); + }} style={{ backgroundColor: 'inherit', }} > - Budgeted + Budgeted setMode('average')} + selected={reportMode === 'average'} + onSelect={() => { + setMode('sliding-window'); + setStart(monthUtils.currentMonth()); + setReportMode('average'); + }} style={{ backgroundColor: 'inherit', }} > - Average + Average {!isNarrowWidth && ( @@ -299,7 +339,11 @@ export function Spending() { Save compare and filter options} + content={ + + Save compare and filter options + + } style={{ ...styles.tooltip, lineHeight: 1.5, @@ -312,10 +356,9 @@ export function Spending() { style={{ marginLeft: 10, }} - onPress={saveFilter} - isDisabled={filterSaved} + onPress={onSaveWidget} > - {filterSaved ? 'Saved' : 'Save'} + Save @@ -388,8 +431,8 @@ export function Spending() { style={{ marginBottom: 5, minWidth: 210 }} left={ - Spent {monthUtils.format(compare, 'MMM, yyyy')} - {compare === monthUtils.currentMonth() && ' MTD'}: + Spent {monthUtils.format(start, 'MMM, yyyy')} + {start === monthUtils.currentMonth() && ' MTD'}: } right={ @@ -403,12 +446,12 @@ export function Spending() { } /> )} - {mode === 'singleMonth' && ( + {reportMode === 'single-month' && ( - Spent {monthUtils.format(compareTo, 'MMM, yyyy')}: + Spent {monthUtils.format(end, 'MMM, yyyy')}: } right={ @@ -428,7 +471,7 @@ export function Spending() { left={ Budgeted - {compare === monthUtils.currentMonth() && ' MTD'}: + {start === monthUtils.currentMonth() && ' MTD'}: } right={ @@ -447,7 +490,7 @@ export function Spending() { left={ Spent Average - {compare === monthUtils.currentMonth() && ' MTD'}: + {start === monthUtils.currentMonth() && ' MTD'}: } right={ @@ -464,39 +507,43 @@ export function Spending() { {!showCompare || - (mode === 'singleMonth' && !showCompareTo) || - (mode === 'average' && !showAverage) ? ( + (reportMode === 'single-month' && !showCompareTo) || + (reportMode === 'average' && !showAverage) ? ( -

Additional data required to generate graph

- - Currently, there is insufficient data to display selected - information regarding your spending. Please adjust selection - options to enable graph visualization. - + +

Additional data required to generate graph

+ + Currently, there is insufficient data to display selected + information regarding your spending. Please adjust + selection options to enable graph visualization. + +
) : dataCheck ? ( ) : ( - + )} {showAverage && ( - - - How are “Average” and “Spent Average MTD” calculated? - - - - They are both the average cumulative spending by day for the - three months before the selected “compare” month. - + + + + How are “Average” and “Spent Average MTD” calculated? + + + + They are both the average cumulative spending by day for + the three months before the selected “compare” month. + + )} diff --git a/packages/desktop-client/src/components/reports/reports/SpendingCard.tsx b/packages/desktop-client/src/components/reports/reports/SpendingCard.tsx index dd3cffe0699..d98b86934ed 100644 --- a/packages/desktop-client/src/components/reports/reports/SpendingCard.tsx +++ b/packages/desktop-client/src/components/reports/reports/SpendingCard.tsx @@ -6,7 +6,6 @@ import { amountToCurrency } from 'loot-core/src/shared/util'; import { type SpendingWidget } from 'loot-core/src/types/models'; import { useFeatureFlag } from '../../../hooks/useFeatureFlag'; -import { useLocalPref } from '../../../hooks/useLocalPref'; import { styles } from '../../../style/styles'; import { theme } from '../../../style/theme'; import { Block } from '../../common/Block'; @@ -17,12 +16,14 @@ import { SpendingGraph } from '../graphs/SpendingGraph'; import { LoadingIndicator } from '../LoadingIndicator'; import { ReportCard } from '../ReportCard'; import { ReportCardName } from '../ReportCardName'; +import { calculateTimeRange } from '../reportRanges'; import { createSpendingSpreadsheet } from '../spreadsheets/spending-spreadsheet'; import { useReport } from '../useReport'; import { MissingReportCard } from './MissingReportCard'; type SpendingCardProps = { + widgetId: string; isEditing?: boolean; meta?: SpendingWidget['meta']; onMetaChange: (newMeta: SpendingWidget['meta']) => void; @@ -30,50 +31,36 @@ type SpendingCardProps = { }; export function SpendingCard({ + widgetId, isEditing, - meta, + meta = {}, onMetaChange, onRemove, }: SpendingCardProps) { + const isDashboardsFeatureEnabled = useFeatureFlag('dashboards'); const { t } = useTranslation(); + const [start, end] = calculateTimeRange(meta?.timeFrame); + const [isCardHovered, setIsCardHovered] = useState(false); - const [spendingReportFilter = ''] = useLocalPref('spendingReportFilter'); - const [spendingReportMode = 'singleMonth'] = - useLocalPref('spendingReportMode'); - const [spendingReportCompare = monthUtils.currentMonth()] = useLocalPref( - 'spendingReportCompare', - ); - const [spendingReportCompareTo = monthUtils.currentMonth()] = useLocalPref( - 'spendingReportCompareTo', - ); + const spendingReportMode = meta?.mode ?? 'single-month'; const [nameMenuOpen, setNameMenuOpen] = useState(false); const selection = - spendingReportMode === 'singleMonth' ? 'compareTo' : spendingReportMode; - const parseFilter = spendingReportFilter && JSON.parse(spendingReportFilter); - const isDateValid = monthUtils.parseDate(spendingReportCompare); + spendingReportMode === 'single-month' ? 'compareTo' : spendingReportMode; const getGraphData = useMemo(() => { return createSpendingSpreadsheet({ - conditions: parseFilter.conditions, - conditionsOp: parseFilter.conditionsOp, - compare: - isDateValid.toString() === 'Invalid Date' - ? monthUtils.currentMonth() - : spendingReportCompare, - compareTo: spendingReportCompareTo, + conditions: meta?.conditions, + conditionsOp: meta?.conditionsOp, + compare: start, + compareTo: end, }); - }, [ - parseFilter, - spendingReportCompare, - spendingReportCompareTo, - isDateValid, - ]); + }, [meta?.conditions, meta?.conditionsOp, start, end]); const data = useReport('default', getGraphData); const todayDay = - spendingReportCompare !== monthUtils.currentMonth() + start !== monthUtils.currentMonth() ? 27 : monthUtils.getDay(monthUtils.currentDay()) - 1 >= 28 ? 27 @@ -82,7 +69,6 @@ export function SpendingCard({ data && data.intervalData[todayDay][selection] - data.intervalData[todayDay].compare; - const showCompareTo = data && Math.abs(data.intervalData[27].compareTo) > 0; const spendingReportFeatureFlag = useFeatureFlag('spendingReport'); @@ -98,7 +84,11 @@ export function SpendingCard({ return ( setNameMenuOpen(false)} /> - + - {data && showCompareTo && ( + {data && ( )} - {!showCompareTo || isDateValid.toString() === 'Invalid Date' ? ( - -

- Additional data required to generate graph -

-
- ) : data ? ( + {data ? ( ) : ( diff --git a/packages/loot-core/src/types/models/dashboard.d.ts b/packages/loot-core/src/types/models/dashboard.d.ts index a2d7252b1b1..6baa8e4f578 100644 --- a/packages/loot-core/src/types/models/dashboard.d.ts +++ b/packages/loot-core/src/types/models/dashboard.d.ts @@ -41,7 +41,13 @@ export type CashFlowWidget = AbstractWidget< >; export type SpendingWidget = AbstractWidget< 'spending-card', - { name?: string } | null + { + name?: string; + conditions?: RuleConditionEntity[]; + conditionsOp?: 'and' | 'or'; + timeFrame?: TimeFrame; + mode?: 'single-month' | 'budget' | 'average'; + } | null >; export type CustomReportWidget = AbstractWidget< 'custom-report', diff --git a/packages/loot-core/src/types/models/reports.d.ts b/packages/loot-core/src/types/models/reports.d.ts index 465352a8e30..e59e655241d 100644 --- a/packages/loot-core/src/types/models/reports.d.ts +++ b/packages/loot-core/src/types/models/reports.d.ts @@ -30,8 +30,6 @@ export type balanceTypeOpType = | 'netAssets' | 'netDebts'; -export type spendingReportModeType = 'singleMonth' | 'average' | 'budget'; - export type SpendingMonthEntity = Record< string | number, { diff --git a/packages/loot-core/src/types/prefs.d.ts b/packages/loot-core/src/types/prefs.d.ts index b500745a78c..41879dd82c7 100644 --- a/packages/loot-core/src/types/prefs.d.ts +++ b/packages/loot-core/src/types/prefs.d.ts @@ -1,5 +1,3 @@ -import { spendingReportModeType } from './models/reports'; - export type FeatureFlag = | 'dashboards' | 'reportBudget' @@ -71,10 +69,6 @@ export type LocalPrefs = SyncedPrefs & reportsViewLegend: boolean; reportsViewSummary: boolean; reportsViewLabel: boolean; - spendingReportFilter: string; - spendingReportMode: spendingReportModeType; - spendingReportCompare: string; - spendingReportCompareTo: string; sidebarWidth: number; 'mobile.showSpentColumn': boolean; }>; diff --git a/upcoming-release-notes/3432.md b/upcoming-release-notes/3432.md new file mode 100644 index 00000000000..afdbed30aed --- /dev/null +++ b/upcoming-release-notes/3432.md @@ -0,0 +1,6 @@ +--- +category: Enhancements +authors: [MatissJanis] +--- + +Dashboards: ability to save filters & time-range on spending widgets. From d4c3a2d159d421d32dcbf39f33bebbc0146c5b4f Mon Sep 17 00:00:00 2001 From: Matiss Janis Aboltins Date: Fri, 13 Sep 2024 22:46:12 +0100 Subject: [PATCH 02/13] Patches --- .../components/reports/reports/Spending.tsx | 49 +++++++++++-------- 1 file changed, 28 insertions(+), 21 deletions(-) diff --git a/packages/desktop-client/src/components/reports/reports/Spending.tsx b/packages/desktop-client/src/components/reports/reports/Spending.tsx index a974d2374cd..2faa756f700 100644 --- a/packages/desktop-client/src/components/reports/reports/Spending.tsx +++ b/packages/desktop-client/src/components/reports/reports/Spending.tsx @@ -219,7 +219,11 @@ function SpendingInternal({ widget }: SpendingInternalProps) { Compare { - if (newStart === 'current-month') { - setMode('sliding-window'); - setStart(monthUtils.currentMonth()); - return; - } - setMode('static'); - setStart(newStart); - }} - options={[ - ...(reportMode === 'single-month' - ? [] - : [['current-month', t('Current Month')] as const]), - ...allIntervals.map( - ({ name, pretty }) => [name, pretty] as const, - ), - ]} + value={start} + onChange={setStart} + options={allIntervals.map( + ({ name, pretty }) => [name, pretty] as const, + )} + buttonStyle={{ width: 150 }} + popoverStyle={{ width: 150 }} /> - + to [name, pretty] as const, - )} - buttonStyle={{ width: 150 }} - popoverStyle={{ width: 150 }} - /> - - to - - { + const [newStart, newEnd] = validateStart( + allIntervals[allIntervals.length - 1].name, + newValue, + end, + ); + setStart(newStart); + setEnd(newEnd); + }} + options={allIntervals.map( + ({ name, pretty }) => [name, pretty] as const, + )} + buttonStyle={{ width: 150 }} + popoverStyle={{ width: 150 }} + /> + + to + + { - const [newStart, newEnd] = validateStart( - allIntervals[allIntervals.length - 1].name, - newValue, - end, - ); - setStart(newStart); - setEnd(newEnd); - }} + value={compare} + onChange={setCompare} options={allIntervals.map( ({ name, pretty }) => [name, pretty] as const, )} @@ -270,16 +258,8 @@ function SpendingInternal({ widget }: SpendingInternalProps) { to