From f3451bfc2ea024830b00a86884f732af6abe9dd4 Mon Sep 17 00:00:00 2001 From: Matiss Janis Aboltins <matiss@mja.lv> Date: Tue, 24 Sep 2024 08:03:04 +0100 Subject: [PATCH] :sparkles: (dashboards) ability to save filters & timeframes on spending widgets (#3432) --- .../src/components/common/Select.tsx | 3 + .../src/components/reports/DateRange.tsx | 9 +- .../src/components/reports/Header.tsx | 2 +- .../src/components/reports/Overview.tsx | 1 + .../src/components/reports/ReportRouter.tsx | 1 + .../reports/graphs/SpendingGraph.tsx | 52 +- .../src/components/reports/reportRanges.ts | 44 +- .../components/reports/reports/Spending.tsx | 523 ++++++++++-------- .../reports/reports/SpendingCard.tsx | 70 +-- .../spreadsheets/spending-spreadsheet.ts | 3 - .../loot-core/src/types/models/dashboard.d.ts | 10 +- .../loot-core/src/types/models/reports.d.ts | 2 - packages/loot-core/src/types/prefs.d.ts | 6 - upcoming-release-notes/3432.md | 6 + 14 files changed, 412 insertions(+), 320 deletions(-) create mode 100644 upcoming-release-notes/3432.md diff --git a/packages/desktop-client/src/components/common/Select.tsx b/packages/desktop-client/src/components/common/Select.tsx index c9fc00b41..0ecfde802 100644 --- a/packages/desktop-client/src/components/common/Select.tsx +++ b/packages/desktop-client/src/components/common/Select.tsx @@ -28,6 +28,7 @@ type SelectProps<Value> = { disabled?: boolean; disabledKeys?: Value[]; style?: CSSProperties; + popoverStyle?: CSSProperties; }; /** @@ -53,6 +54,7 @@ export function Select<const Value = string>({ disabled = false, disabledKeys = [], style = {}, + popoverStyle = {}, }: SelectProps<Value>) { const targetOption = options .filter(isValueOption) @@ -108,6 +110,7 @@ export function Select<const Value = string>({ placement="bottom start" isOpen={isOpen} onOpenChange={() => setIsOpen(false)} + style={popoverStyle} > <Menu onMenuSelect={item => { diff --git a/packages/desktop-client/src/components/reports/DateRange.tsx b/packages/desktop-client/src/components/reports/DateRange.tsx index faefe46e9..6734a0b8e 100644 --- a/packages/desktop-client/src/components/reports/DateRange.tsx +++ b/packages/desktop-client/src/components/reports/DateRange.tsx @@ -40,7 +40,14 @@ export function DateRange({ start, end, type }: DateRangeProps): ReactElement { } let content: string | ReactElement; - if (startDate.getFullYear() !== endDate.getFullYear()) { + if (['budget', 'average'].includes(type || '')) { + content = ( + <div> + Compare {d.format(startDate, 'MMM yyyy')} to{' '} + {type === 'budget' ? 'budgeted' : 'average'} + </div> + ); + } else if (startDate.getFullYear() !== endDate.getFullYear()) { content = ( <div> {type && 'Compare '} diff --git a/packages/desktop-client/src/components/reports/Header.tsx b/packages/desktop-client/src/components/reports/Header.tsx index b1aad0805..d9322c227 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) && ( <View style={{ flexDirection: isNarrowWidth ? 'column' : 'row', diff --git a/packages/desktop-client/src/components/reports/Overview.tsx b/packages/desktop-client/src/components/reports/Overview.tsx index f6dfb8843..0f3c3b22b 100644 --- a/packages/desktop-client/src/components/reports/Overview.tsx +++ b/packages/desktop-client/src/components/reports/Overview.tsx @@ -545,6 +545,7 @@ export function Overview() { /> ) : item.type === 'spending-card' ? ( <SpendingCard + widgetId={item.i} isEditing={isEditing} meta={item.meta} onMetaChange={newMeta => onMetaChange(item, newMeta)} diff --git a/packages/desktop-client/src/components/reports/ReportRouter.tsx b/packages/desktop-client/src/components/reports/ReportRouter.tsx index 08dfa109e..71719624f 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() { <Route path="/cash-flow/:id" element={<CashFlow />} /> <Route path="/custom" element={<CustomReport />} /> <Route path="/spending" element={<Spending />} /> + <Route path="/spending/:id" 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 index 4c6660e40..b8c035291 100644 --- a/packages/desktop-client/src/components/reports/graphs/SpendingGraph.tsx +++ b/packages/desktop-client/src/components/reports/graphs/SpendingGraph.tsx @@ -1,5 +1,5 @@ // @ts-strict-ignore -import React from 'react'; +import React, { type ComponentProps } from 'react'; import { useTranslation } from 'react-i18next'; import { css } from 'glamor'; @@ -32,19 +32,22 @@ type PayloadItem = { totalDebts: number | string; totalTotals: number | string; day: string; - months: { - date: string; - cumulative: number | string; - }; + months: Record< + string, + { + date: string; + cumulative: number; + } + >; }; }; type CustomTooltipProps = { active?: boolean; payload?: PayloadItem[]; - balanceTypeOp?: string; - selection?: string; - compare?: string; + balanceTypeOp: 'cumulative'; + selection: string | 'budget' | 'average'; + compare: string; }; const CustomTooltip = ({ @@ -59,7 +62,7 @@ const CustomTooltip = ({ if (active && payload && payload.length) { const comparison = ['average', 'budget'].includes(selection) ? payload[0].payload[selection] * -1 - : payload[0].payload.months[selection].cumulative * -1; + : payload[0].payload.months[selection]?.cumulative * -1; return ( <div className={`${css({ @@ -82,11 +85,11 @@ const CustomTooltip = ({ </strong> </div> <div style={{ lineHeight: 1.5 }}> - {payload[0].payload.months[compare].cumulative ? ( + {payload[0].payload.months[compare]?.cumulative ? ( <AlignedText left={t('Compare:')} right={amountToCurrency( - payload[0].payload.months[compare].cumulative * -1, + payload[0].payload.months[compare]?.cumulative * -1, )} /> ) : null} @@ -102,11 +105,11 @@ const CustomTooltip = ({ right={amountToCurrency(comparison)} /> )} - {payload[0].payload.months[compare].cumulative ? ( + {payload[0].payload.months[compare]?.cumulative ? ( <AlignedText left={t('Difference:')} right={amountToCurrency( - payload[0].payload.months[compare].cumulative * -1 - + payload[0].payload.months[compare]?.cumulative * -1 - comparison, )} /> @@ -122,7 +125,7 @@ type SpendingGraphProps = { style?: CSSProperties; data: SpendingEntity; compact?: boolean; - mode: string; + mode: 'single-month' | 'budget' | 'average'; compare: string; compareTo: string; }; @@ -138,27 +141,30 @@ 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, - ).months[compare][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.months[selection]?.[balanceTypeOp] < + b.months[selection]?.[balanceTypeOp] ? a : b, - ).months[selection][balanceTypeOp]; + ).months[selection]?.[balanceTypeOp]; const maxYAxis = selectionMax > thisMonthMax; const dataMax = Math.max( - ...data.intervalData.map(i => i.months[compare].cumulative), + ...data.intervalData.map(i => i.months[compare]?.cumulative), ); const dataMin = Math.min( - ...data.intervalData.map(i => i.months[compare].cumulative), + ...data.intervalData.map(i => i.months[compare]?.cumulative), ); - const tickFormatter = tick => { + const tickFormatter: ComponentProps<typeof YAxis>['tickFormatter'] = tick => { if (!privacyMode) return `${amountToCurrencyNoDecimal(tick)}`; // Formats the tick values as strings with commas return '...'; }; @@ -179,7 +185,7 @@ export function SpendingGraph({ return obj[month] && -1 * obj[month]; } else { return ( - obj.months[month][balanceTypeOp] && + obj.months[month]?.[balanceTypeOp] && -1 * obj.months[month][balanceTypeOp] ); } diff --git a/packages/desktop-client/src/components/reports/reportRanges.ts b/packages/desktop-client/src/components/reports/reportRanges.ts index 7c3004894..1fa289b5f 100644 --- a/packages/desktop-client/src/components/reports/reportRanges.ts +++ b/packages/desktop-client/src/components/reports/reportRanges.ts @@ -166,7 +166,7 @@ export function getLatestRange(offset: number) { } export function calculateTimeRange( - timeFrame?: TimeFrame, + timeFrame?: Partial<TimeFrame>, defaultTimeFrame?: TimeFrame, ) { const start = @@ -181,8 +181,48 @@ export function calculateTimeRange( return getFullRange(start); } if (mode === 'sliding-window') { - return getLatestRange(monthUtils.differenceInCalendarMonths(end, start)); + const offset = monthUtils.differenceInCalendarMonths(end, start); + + if (start > end) { + return [ + monthUtils.currentMonth(), + monthUtils.subMonths(monthUtils.currentMonth(), -offset), + 'sliding-window', + ] as const; + } + + return getLatestRange(offset); } return [start, end, 'static'] as const; } + +export function calculateSpendingReportTimeRange({ + compare, + compareTo, + isLive = true, + mode = 'single-month', +}: { + compare?: string; + compareTo?: string; + isLive?: boolean; + mode?: 'budget' | 'average' | 'single-month'; +}): [string, string] { + if (['budget', 'average'].includes(mode) && isLive) { + return [monthUtils.currentMonth(), monthUtils.currentMonth()]; + } + + const [start, end] = calculateTimeRange( + { + start: compare, + end: compareTo, + mode: (isLive ?? true) ? 'sliding-window' : 'static', + }, + { + start: monthUtils.currentMonth(), + end: monthUtils.subMonths(monthUtils.currentMonth(), 1), + mode: 'sliding-window', + }, + ); + return [start, end]; +} diff --git a/packages/desktop-client/src/components/reports/reports/Spending.tsx b/packages/desktop-client/src/components/reports/reports/Spending.tsx index ec6c81008..4f5407a4a 100644 --- a/packages/desktop-client/src/components/reports/reports/Spending.tsx +++ b/packages/desktop-client/src/components/reports/reports/Spending.tsx @@ -1,14 +1,20 @@ 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 { useFeatureFlag } from '../../../hooks/useFeatureFlag'; 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 +34,34 @@ import { PrivacyFilter } from '../../PrivacyFilter'; import { SpendingGraph } from '../graphs/SpendingGraph'; import { LoadingIndicator } from '../LoadingIndicator'; import { ModeButton } from '../ModeButton'; +import { calculateSpendingReportTimeRange } 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<SpendingWidget>( + params.id ?? '', + 'spending-card', + ); + + if (isLoading) { + return <LoadingIndicator />; + } + + return <SpendingInternal widget={widget} />; +} + +type SpendingInternalProps = { + widget: SpendingWidget; +}; + +function SpendingInternal({ widget }: SpendingInternalProps) { + const isDashboardsFeatureEnabled = useFeatureFlag('dashboards'); + const dispatch = useDispatch(); + const { t } = useTranslation(); + const { conditions, conditionsOp, @@ -40,42 +69,23 @@ export function Spending() { onDelete: onDeleteFilter, onUpdate: onUpdateFilter, onConditionsOpChange, - } = useFilters<RuleConditionEntity>(); + } = useFilters<RuleConditionEntity>( + widget?.meta?.conditions, + widget?.meta?.conditionsOp, + ); const emptyIntervals: { name: string; pretty: string }[] = []; const [allIntervals, setAllIntervals] = useState(emptyIntervals); - const [spendingReportFilter = '', setSpendingReportFilter] = useLocalPref( - 'spendingReportFilter', - ); - 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 [mode, setMode] = useState(spendingReportMode); - const [compare, setCompare] = useState( - isDateValid.toString() === 'Invalid Date' - ? monthUtils.currentMonth() - : spendingReportCompare, + const initialReportMode = widget?.meta?.mode ?? 'single-month'; + const [initialCompare, initialCompareTo] = calculateSpendingReportTimeRange( + widget?.meta ?? {}, ); - const [compareTo, setCompareTo] = useState(spendingReportCompareTo); + const [compare, setCompare] = useState(initialCompare); + const [compareTo, setCompareTo] = useState(initialCompareTo); + const [isLive, setIsLive] = useState(widget?.meta?.isLive ?? true); - 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,43 +114,47 @@ export function Spending() { setAllIntervals(allMonths); } run(); - const checkFilter = - spendingReportFilter && JSON.parse(spendingReportFilter); - if (checkFilter.conditions) { - onApplyFilter(checkFilter); - } - }, [onApplyFilter, spendingReportFilter]); - - const getGraphData = useMemo(() => { - setDataCheck(false); - return createSpendingSpreadsheet({ - conditions, - conditionsOp, - setDataCheck, - compare, - compareTo, - }); - }, [conditions, conditionsOp, compare, compareTo]); + }, []); + + const getGraphData = useMemo( + () => + createSpendingSpreadsheet({ + conditions, + conditionsOp, + compare, + compareTo, + }), + [conditions, conditionsOp, compare, compareTo], + ); 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, + compare, + compareTo, + isLive, + 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)] && @@ -156,169 +170,230 @@ export function Spending() { : monthUtils.getDay(monthUtils.currentDay()) - 1; const showCompareTo = Math.abs(data.intervalData[27].compareTo) > 0; - const showCompare = - compare === monthUtils.currentMonth() || - Math.abs(data.intervalData[27].compare) > 0; + + const title = widget?.meta?.name ?? t('Monthly Spending'); + return ( <Page header={ isNarrowWidth ? ( <MobilePageHeader - title="Monthly Spending" + title={title} leftContent={ <MobileBackButton onPress={() => navigate('/reports')} /> } /> ) : ( - <PageHeader title="Monthly Spending" /> + <PageHeader title={title} /> ) } padding={0} > <View style={{ - flexDirection: isNarrowWidth ? 'column' : 'row', - alignItems: isNarrowWidth ? 'inherit' : 'center', - padding: 20, - paddingBottom: 0, + paddingLeft: 20, + paddingRight: 20, + paddingTop: 15, + paddingBottom: 20, flexShrink: 0, }} > - <View - style={{ - alignItems: 'center', - flexDirection: 'row', - marginRight: 5, - marginBottom: 5, - marginTop: 5, - }} - > - <Text - style={{ - paddingRight: 5, - }} - > - Compare - </Text> - <Select - value={compare} - onChange={e => { - setCompare(e); - }} - options={allIntervals.map(({ name, pretty }) => [name, pretty])} - /> - <Text - style={{ - paddingRight: 5, - paddingLeft: 5, - }} - > - 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', - marginRight: 5, - marginTop: 5, - marginBottom: 5, - }} - > - <ModeButton - selected={mode === 'singleMonth'} - style={{ - backgroundColor: 'inherit', - }} - onSelect={() => setMode('singleMonth')} - > - 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', + flexDirection: 'row', + alignItems: 'center', + flexShrink: 0, }} > - Average - </ModeButton> - </View> - {!isNarrowWidth && ( - <View - style={{ - width: 1, - height: 30, - backgroundColor: theme.pillBorderDark, - marginRight: 10, - }} - /> - )} - <View - style={{ - alignItems: 'center', - flexDirection: 'row', - marginBottom: 5, - marginTop: 5, - flex: 1, - }} - > - <FilterButton - onApply={onApplyFilter} - compact={isNarrowWidth} - hover={false} - exclude={['date']} - /> - <View style={{ flex: 1 }} /> - <Tooltip - placement="top end" - content={<Text>Save compare and filter options</Text>} - style={{ - ...styles.tooltip, - lineHeight: 1.5, - padding: '6px 10px', - marginLeft: 10, - }} - > - <Button - variant="primary" + {isDashboardsFeatureEnabled && ( + <> + <Button + variant={isLive ? 'primary' : 'normal'} + onPress={() => setIsLive(state => !state)} + > + {isLive ? t('Live') : t('Static')} + </Button> + + <View + style={{ + width: 1, + height: 28, + backgroundColor: theme.pillBorderDark, + marginRight: 10, + marginLeft: 10, + }} + /> + </> + )} + + <View + style={{ + alignItems: 'center', + flexDirection: 'row', + marginRight: 5, + gap: 5, + }} + > + <Text> + <Trans>Compare</Trans> + </Text> + <Select + value={compare} + onChange={setCompare} + options={allIntervals.map( + ({ name, pretty }) => [name, pretty] as const, + )} + style={{ width: 150 }} + popoverStyle={{ width: 150 }} + /> + <Text> + <Trans>to</Trans> + </Text> + <Select + value={reportMode === 'single-month' ? compareTo : 'label'} + onChange={setCompareTo} + options={ + reportMode === 'single-month' + ? allIntervals.map(({ name, pretty }) => [name, pretty]) + : [ + [ + 'label', + reportMode === 'budget' + ? t('Budgeted') + : t('Average spent'), + ], + ] + } + disabled={reportMode !== 'single-month'} + style={{ width: 150 }} + popoverStyle={{ width: 150 }} + /> + </View> + + <View style={{ + width: 1, + height: 28, + backgroundColor: theme.pillBorderDark, + marginRight: 15, marginLeft: 10, }} - onPress={saveFilter} - isDisabled={filterSaved} + /> + + <View + style={{ + flexDirection: 'row', + marginRight: 5, + }} > - {filterSaved ? 'Saved' : 'Save'} - </Button> - </Tooltip> - </View> + <ModeButton + selected={reportMode === 'single-month'} + style={{ + backgroundColor: 'inherit', + }} + onSelect={() => { + setReportMode('single-month'); + }} + > + <Trans>Single month</Trans> + </ModeButton> + <ModeButton + selected={reportMode === 'budget'} + onSelect={() => { + setReportMode('budget'); + }} + style={{ + backgroundColor: 'inherit', + }} + > + <Trans>Budgeted</Trans> + </ModeButton> + <ModeButton + selected={reportMode === 'average'} + onSelect={() => { + setReportMode('average'); + }} + style={{ + backgroundColor: 'inherit', + }} + > + <Trans>Average</Trans> + </ModeButton> + </View> + + <View + style={{ + width: 1, + height: 28, + backgroundColor: theme.pillBorderDark, + marginRight: 10, + }} + /> + + <View + style={{ + alignItems: 'center', + flexDirection: 'row', + flex: 1, + }} + > + <FilterButton + onApply={onApplyFilter} + compact={isNarrowWidth} + hover={false} + exclude={['date']} + /> + <View style={{ flex: 1 }} /> + + {widget && ( + <Tooltip + placement="top end" + content={ + <Text> + <Trans>Save compare and filter options</Trans> + </Text> + } + style={{ + ...styles.tooltip, + lineHeight: 1.5, + padding: '6px 10px', + marginLeft: 10, + }} + > + <Button + variant="primary" + style={{ + marginLeft: 10, + }} + onPress={onSaveWidget} + > + <Trans>Save</Trans> + </Button> + </Tooltip> + )} + </View> + </View> + )} + + {conditions && conditions.length > 0 && ( + <View + style={{ + marginTop: 5, + flexShrink: 0, + flexDirection: 'row', + spacing: 2, + }} + > + <AppliedFilters + conditions={conditions} + onUpdate={onUpdateFilter} + onDelete={onDeleteFilter} + conditionsOp={conditionsOp} + onConditionsOpChange={onConditionsOpChange} + /> + </View> + )} </View> <View style={{ @@ -333,25 +408,6 @@ export function Spending() { flexGrow: 1, }} > - {conditions && conditions.length > 0 && ( - <View - style={{ - marginBottom: 10, - marginLeft: 20, - flexShrink: 0, - flexDirection: 'row', - spacing: 2, - }} - > - <AppliedFilters - conditions={conditions} - onUpdate={onUpdateFilter} - onDelete={onDeleteFilter} - conditionsOp={conditionsOp} - onConditionsOpChange={onConditionsOpChange} - /> - </View> - )} <View style={{ backgroundColor: theme.tableBackground, @@ -403,7 +459,7 @@ export function Spending() { } /> )} - {mode === 'singleMonth' && ( + {reportMode === 'single-month' && ( <AlignedText style={{ marginBottom: 5, minWidth: 210 }} left={ @@ -463,40 +519,31 @@ export function Spending() { )} </View> </View> - {!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 selected - information regarding your spending. Please adjust selection - options to enable graph visualization. - </Paragraph> - </View> - ) : dataCheck ? ( + {data ? ( <SpendingGraph style={{ flexGrow: 1 }} compact={false} data={data} - mode={mode} + mode={reportMode} compare={compare} compareTo={compareTo} /> ) : ( - <LoadingIndicator message="Loading report..." /> + <LoadingIndicator message={t('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 - three months before the selected “compare†month. - </Paragraph> + <Trans> + <Paragraph> + <strong> + How are “Average†and “Spent Average MTD†calculated? + </strong> + </Paragraph> + <Paragraph> + They are both the average cumulative spending by day for + the three months before the selected “compare†month. + </Paragraph> + </Trans> </View> )} </View> diff --git a/packages/desktop-client/src/components/reports/reports/SpendingCard.tsx b/packages/desktop-client/src/components/reports/reports/SpendingCard.tsx index dd3cffe06..306d92dca 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 { calculateSpendingReportTimeRange } 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 [compare, compareTo] = calculateSpendingReportTimeRange(meta ?? {}); + 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, + compareTo, }); - }, [ - parseFilter, - spendingReportCompare, - spendingReportCompareTo, - isDateValid, - ]); + }, [meta?.conditions, meta?.conditionsOp, compare, compareTo]); const data = useReport('default', getGraphData); const todayDay = - spendingReportCompare !== monthUtils.currentMonth() + compare !== 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 ( <ReportCard isEditing={isEditing} - to="/reports/spending" + to={ + isDashboardsFeatureEnabled + ? `/reports/spending/${widgetId}` + : '/reports/spending' + } menuItems={[ { name: 'rename', @@ -142,12 +132,12 @@ export function SpendingCard({ onClose={() => setNameMenuOpen(false)} /> <DateRange - start={spendingReportCompare} - end={spendingReportCompareTo} + start={compare} + end={compareTo} type={spendingReportMode} /> </View> - {data && showCompareTo && ( + {data && ( <View style={{ textAlign: 'right' }}> <Block style={{ @@ -170,23 +160,17 @@ export function SpendingCard({ </View> )} </View> - {!showCompareTo || isDateValid.toString() === 'Invalid Date' ? ( - <View style={{ padding: 5 }}> - <p style={{ margin: 0, textAlign: 'center' }}> - <Trans>Additional data required to generate graph</Trans> - </p> - </View> - ) : data ? ( + {data ? ( <SpendingGraph style={{ flex: 1 }} compact={true} data={data} mode={spendingReportMode} - compare={spendingReportCompare} - compareTo={spendingReportCompareTo} + compare={compare} + compareTo={compareTo} /> ) : ( - <LoadingIndicator message={t('Loading report...')} /> + <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 index 0ffa6d742..70619281f 100644 --- a/packages/desktop-client/src/components/reports/spreadsheets/spending-spreadsheet.ts +++ b/packages/desktop-client/src/components/reports/spreadsheets/spending-spreadsheet.ts @@ -18,7 +18,6 @@ import { makeQuery } from './makeQuery'; type createSpendingSpreadsheetProps = { conditions?: RuleConditionEntity[]; conditionsOp?: string; - setDataCheck?: (value: boolean) => void; compare?: string; compareTo?: string; }; @@ -26,7 +25,6 @@ type createSpendingSpreadsheetProps = { export function createSpendingSpreadsheet({ conditions = [], conditionsOp, - setDataCheck, compare, compareTo, }: createSpendingSpreadsheetProps) { @@ -256,6 +254,5 @@ export function createSpendingSpreadsheet({ totalAssets: integerToAmount(totalAssets), totalTotals: integerToAmount(totalAssets + totalDebts), }); - setDataCheck?.(true); }; } diff --git a/packages/loot-core/src/types/models/dashboard.d.ts b/packages/loot-core/src/types/models/dashboard.d.ts index a2d7252b1..0b79913ea 100644 --- a/packages/loot-core/src/types/models/dashboard.d.ts +++ b/packages/loot-core/src/types/models/dashboard.d.ts @@ -41,7 +41,15 @@ export type CashFlowWidget = AbstractWidget< >; export type SpendingWidget = AbstractWidget< 'spending-card', - { name?: string } | null + { + name?: string; + conditions?: RuleConditionEntity[]; + conditionsOp?: 'and' | 'or'; + compare?: string; + compareTo?: string; + isLive?: boolean; + 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 465352a8e..e59e65524 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 984225da3..f10667467 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' @@ -65,10 +63,6 @@ export type LocalPrefs = Partial<{ 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 000000000..afdbed30a --- /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. -- GitLab