From c626fc2f17199a327c8dcd80e93c3549e8926240 Mon Sep 17 00:00:00 2001 From: lelemm Date: Thu, 21 Nov 2024 13:25:09 -0300 Subject: [PATCH] Summary report (#3792) * Summary card report * Apply suggestions from code rabbit * Apply suggestions from code review Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * MORE CODE RABBIT SUGGESTIONS * typecheck fix * change view form the details page * added privacy filter * Apply suggestions from code review Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * debounce * removed binary search and changed the summary page to not use the card component * Update packages/desktop-client/src/components/reports/spreadsheets/summary-spreadsheet.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * fix on recommended code rabbit commit * added some padding to number so it fits the window better for big numbers * accept infinite * feedback fixes * Update packages/desktop-client/src/components/reports/reports/SummaryCard.tsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * translations * fix on the save, linter and changed "include summary date range" to "all time divisor" * changed MD from enhancements to feature * typo * change card * typecheck * Update packages/desktop-client/src/components/reports/SummaryNumber.tsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * typecheck * changes to fit the number better * small fix * fix on filters * code review * revert code to check for height --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../src/components/reports/Overview.tsx | 14 +- .../src/components/reports/ReportRouter.tsx | 3 + .../src/components/reports/SummaryNumber.tsx | 148 +++++ .../components/reports/reports/Summary.tsx | 569 ++++++++++++++++++ .../reports/reports/SummaryCard.tsx | 148 +++++ .../spreadsheets/summary-spreadsheet.ts | 290 +++++++++ .../src/icons/v2/CloseParenthesis.svg | 9 + .../src/icons/v2/CloseParenthesis.tsx | 22 + .../src/icons/v2/OpenParenthesis.svg | 9 + .../src/icons/v2/OpenParenthesis.tsx | 22 + packages/desktop-client/src/icons/v2/Sum.svg | 11 + packages/desktop-client/src/icons/v2/Sum.tsx | 20 + .../loot-core/src/types/models/dashboard.d.ts | 29 +- upcoming-release-notes/3792.md | 6 + 14 files changed, 1298 insertions(+), 2 deletions(-) create mode 100644 packages/desktop-client/src/components/reports/SummaryNumber.tsx create mode 100644 packages/desktop-client/src/components/reports/reports/Summary.tsx create mode 100644 packages/desktop-client/src/components/reports/reports/SummaryCard.tsx create mode 100644 packages/desktop-client/src/components/reports/spreadsheets/summary-spreadsheet.ts create mode 100644 packages/desktop-client/src/icons/v2/CloseParenthesis.svg create mode 100644 packages/desktop-client/src/icons/v2/CloseParenthesis.tsx create mode 100644 packages/desktop-client/src/icons/v2/OpenParenthesis.svg create mode 100644 packages/desktop-client/src/icons/v2/OpenParenthesis.tsx create mode 100644 packages/desktop-client/src/icons/v2/Sum.svg create mode 100644 packages/desktop-client/src/icons/v2/Sum.tsx create mode 100644 upcoming-release-notes/3792.md diff --git a/packages/desktop-client/src/components/reports/Overview.tsx b/packages/desktop-client/src/components/reports/Overview.tsx index b82208d5695..ba32c5020e0 100644 --- a/packages/desktop-client/src/components/reports/Overview.tsx +++ b/packages/desktop-client/src/components/reports/Overview.tsx @@ -38,8 +38,8 @@ import { CustomReportListCards } from './reports/CustomReportListCards'; import { MarkdownCard } from './reports/MarkdownCard'; import { NetWorthCard } from './reports/NetWorthCard'; import { SpendingCard } from './reports/SpendingCard'; - import './overview.scss'; +import { SummaryCard } from './reports/SummaryCard'; const ResponsiveGridLayout = WidthProvider(Responsive); @@ -381,6 +381,10 @@ export function Overview() { name: 'markdown-card' as const, text: t('Text widget'), }, + { + name: 'summary-card' as const, + text: t('Summary card'), + }, { name: 'custom-report' as const, text: t('New custom report'), @@ -522,6 +526,14 @@ export function Overview() { report={customReportMap.get(item.meta.id)} onRemove={() => onRemoveWidget(item.i)} /> + ) : item.type === 'summary-card' ? ( + onMetaChange(item, newMeta)} + onRemove={() => onRemoveWidget(item.i)} + /> ) : null} ))} diff --git a/packages/desktop-client/src/components/reports/ReportRouter.tsx b/packages/desktop-client/src/components/reports/ReportRouter.tsx index 28a3f45e21f..7e98d4f4cb1 100644 --- a/packages/desktop-client/src/components/reports/ReportRouter.tsx +++ b/packages/desktop-client/src/components/reports/ReportRouter.tsx @@ -6,6 +6,7 @@ import { CashFlow } from './reports/CashFlow'; import { CustomReport } from './reports/CustomReport'; import { NetWorth } from './reports/NetWorth'; import { Spending } from './reports/Spending'; +import { Summary } from './reports/Summary'; export function ReportRouter() { return ( @@ -19,6 +20,8 @@ export function ReportRouter() { } /> } /> } /> + } /> + } /> ); } diff --git a/packages/desktop-client/src/components/reports/SummaryNumber.tsx b/packages/desktop-client/src/components/reports/SummaryNumber.tsx new file mode 100644 index 00000000000..5866b10c5af --- /dev/null +++ b/packages/desktop-client/src/components/reports/SummaryNumber.tsx @@ -0,0 +1,148 @@ +import React, { type Ref, useRef, useState } from 'react'; + +import { debounce } from 'debounce'; + +import { amountToCurrency } from 'loot-core/shared/util'; + +import { useMergedRefs } from '../../hooks/useMergedRefs'; +import { useResizeObserver } from '../../hooks/useResizeObserver'; +import { View } from '../common/View'; +import { PrivacyFilter } from '../PrivacyFilter'; + +import { chartTheme } from './chart-theme'; +import { LoadingIndicator } from './LoadingIndicator'; + +const FONT_SIZE_SCALE_FACTOR = 0.9; +const MAX_RECURSION_DEPTH = 10; + +type SummaryNumberProps = { + value: number; + animate?: boolean; + suffix?: string; + loading?: boolean; + initialFontSize?: number; + fontSizeChanged?: (fontSize: number) => void; +}; + +export function SummaryNumber({ + value, + animate = false, + suffix = '', + loading = true, + initialFontSize = 14, + fontSizeChanged, +}: SummaryNumberProps) { + const [fontSize, setFontSize] = useState(0); + const refDiv = useRef(null); + const offScreenRef = useRef(null); + + const adjustFontSizeBinary = (minFontSize: number, maxFontSize: number) => { + if (!offScreenRef.current || !refDiv.current) return; + + const offScreenDiv = offScreenRef.current; + const refDivCurrent = refDiv.current; + + const binarySearchFontSize = ( + min: number, + max: number, + depth: number = 0, + ) => { + if (depth >= MAX_RECURSION_DEPTH) { + setFontSize(min); + return; + } + + const testFontSize = (min + max) / 2; + offScreenDiv.style.fontSize = `${testFontSize}px`; + + requestAnimationFrame(() => { + const isOverflowing = + offScreenDiv.scrollWidth > refDivCurrent.clientWidth || + offScreenDiv.scrollHeight > refDivCurrent.clientHeight; + + if (isOverflowing) { + binarySearchFontSize(min, testFontSize, depth + 1); + } else { + const isUnderflowing = + offScreenDiv.scrollWidth <= + refDivCurrent.clientWidth * FONT_SIZE_SCALE_FACTOR || + offScreenDiv.scrollHeight <= + refDivCurrent.clientHeight * FONT_SIZE_SCALE_FACTOR; + + if (isUnderflowing && testFontSize < max) { + binarySearchFontSize(testFontSize, max, depth + 1); + } else { + setFontSize(testFontSize); + if (initialFontSize !== testFontSize && fontSizeChanged) { + fontSizeChanged(testFontSize); + } + } + } + }); + }; + + binarySearchFontSize(minFontSize, maxFontSize); + }; + + const handleResize = debounce(() => { + adjustFontSizeBinary(14, 200); + }, 250); + + const ref = useResizeObserver(handleResize); + const mergedRef = useMergedRefs(ref, refDiv); + + return ( + <> + {loading && } + {!loading && ( + <> +
+ + {amountToCurrency(Math.abs(value))} + {suffix} + +
+ + } + role="text" + aria-label={`${value < 0 ? 'Negative' : 'Positive'} amount: ${amountToCurrency(Math.abs(value))}${suffix}`} + style={{ + alignItems: 'center', + flexGrow: 1, + flexShrink: 1, + width: '100%', + height: '100%', + maxWidth: '100%', + fontSize: `${fontSize}px`, + lineHeight: 1, + padding: 8, + justifyContent: 'center', + transition: animate ? 'font-size 0.3s ease' : '', + color: value < 0 ? chartTheme.colors.red : chartTheme.colors.blue, + }} + > + + + + )} + + ); +} diff --git a/packages/desktop-client/src/components/reports/reports/Summary.tsx b/packages/desktop-client/src/components/reports/reports/Summary.tsx new file mode 100644 index 00000000000..95be1b5b465 --- /dev/null +++ b/packages/desktop-client/src/components/reports/reports/Summary.tsx @@ -0,0 +1,569 @@ +import React, { useState, useEffect, useMemo, type CSSProperties } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import { useDispatch } from 'react-redux'; +import { useParams } from 'react-router-dom'; + +import { parseISO } from 'date-fns'; + +import { useWidget } from 'loot-core/client/data-hooks/widget'; +import { send } from 'loot-core/platform/client/fetch'; +import { amountToCurrency } from 'loot-core/shared/util'; +import { addNotification } from 'loot-core/src/client/actions'; +import * as monthUtils from 'loot-core/src/shared/months'; +import { + type SummaryContent, + type SummaryWidget, + type TimeFrame, +} from 'loot-core/types/models'; + +import { useFilters } from '../../../hooks/useFilters'; +import { useNavigate } from '../../../hooks/useNavigate'; +import { SvgEquals } from '../../../icons/v1'; +import { SvgCloseParenthesis } from '../../../icons/v2/CloseParenthesis'; +import { SvgOpenParenthesis } from '../../../icons/v2/OpenParenthesis'; +import { SvgSum } from '../../../icons/v2/Sum'; +import { theme } from '../../../style'; +import { Button } from '../../common/Button2'; +import { Text } from '../../common/Text'; +import { View } from '../../common/View'; +import { EditablePageHeaderTitle } from '../../EditablePageHeaderTitle'; +import { AppliedFilters } from '../../filters/AppliedFilters'; +import { FilterButton } from '../../filters/FiltersMenu'; +import { Checkbox } from '../../forms'; +import { MobileBackButton } from '../../mobile/MobileBackButton'; +import { FieldSelect } from '../../modals/EditRuleModal'; +import { MobilePageHeader, Page, PageHeader } from '../../Page'; +import { PrivacyFilter } from '../../PrivacyFilter'; +import { useResponsive } from '../../responsive/ResponsiveProvider'; +import { chartTheme } from '../chart-theme'; +import { Header } from '../Header'; +import { LoadingIndicator } from '../LoadingIndicator'; +import { calculateTimeRange } from '../reportRanges'; +import { summarySpreadsheet } from '../spreadsheets/summary-spreadsheet'; +import { useReport } from '../useReport'; +import { fromDateRepr } from '../util'; + +export function Summary() { + const params = useParams(); + const { data: widget, isLoading } = useWidget( + params.id ?? '', + 'summary-card', + ); + + if (isLoading) { + return ; + } + + return ; +} + +type SummaryInnerProps = { + widget?: SummaryWidget; +}; + +type FilterObject = ReturnType; + +function SummaryInner({ widget }: SummaryInnerProps) { + const { t } = useTranslation(); + const [initialStart, initialEnd, initialMode] = calculateTimeRange( + widget?.meta?.timeFrame, + { + start: monthUtils.dayFromDate(monthUtils.currentMonth()), + end: monthUtils.currentDay(), + mode: 'full', + }, + ); + const [start, setStart] = useState(initialStart); + const [end, setEnd] = useState(initialEnd); + const [mode, setMode] = useState(initialMode); + + const dividendFilters: FilterObject = useFilters( + widget?.meta?.conditions ?? [], + widget?.meta?.conditionsOp ?? 'and', + ); + + const [content, setContent] = useState( + widget?.meta?.content + ? (() => { + try { + return JSON.parse(widget.meta.content); + } catch (error) { + console.error('Failed to parse widget meta content:', error); + return { + type: 'sum', + divisorAllTimeDateRange: false, + divisorConditions: [], + divisorConditionsOp: 'and', + }; + } + })() + : { + type: 'sum', + divisorAllTimeDateRange: false, + divisorConditions: [], + divisorConditionsOp: 'and', + }, + ); + + const divisorFilters = useFilters( + content.type === 'percentage' ? (content?.divisorConditions ?? []) : [], + content.type === 'percentage' + ? (content?.divisorConditionsOp ?? 'and') + : 'and', + ); + + const params = useMemo( + () => + summarySpreadsheet( + start, + end, + dividendFilters.conditions, + dividendFilters.conditionsOp, + content, + ), + [ + start, + end, + dividendFilters.conditions, + dividendFilters.conditionsOp, + content, + ], + ); + + const data = useReport('summary', params); + + useEffect(() => { + setContent(prev => ({ + ...prev, + divisorConditions: divisorFilters.conditions, + divisorConditionsOp: divisorFilters.conditionsOp, + })); + }, [divisorFilters.conditions, divisorFilters.conditionsOp]); + + const [allMonths, setAllMonths] = useState< + Array<{ + name: string; + pretty: string; + }> + >([]); + + useEffect(() => { + async function run() { + const trans = await send('get-earliest-transaction'); + const currentMonth = monthUtils.currentMonth(); + let earliestMonth = trans + ? monthUtils.monthFromDate(parseISO(fromDateRepr(trans.date))) + : 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(); + + setAllMonths(allMonths); + } + run(); + }, []); + + const dispatch = useDispatch(); + const navigate = useNavigate(); + const { isNarrowWidth } = useResponsive(); + const title = widget?.meta?.name || t('Summary'); + + const onSaveWidgetName = async (newName: string) => { + if (!widget) { + dispatch( + addNotification({ + type: 'error', + message: t('Cannot save: No widget available.'), + }), + ); + return; + } + + const name = newName || t('Summary'); + await send('dashboard-update-widget', { + id: widget.id, + meta: { + ...(widget.meta ?? {}), + name, + content: JSON.stringify(content), + }, + }); + }; + + function onChangeDates(start: string, end: string, mode: TimeFrame['mode']) { + setStart(start); + setEnd(end); + setMode(mode); + } + + async function onSaveWidget() { + if (!widget) { + dispatch( + addNotification({ + type: 'error', + message: t('Cannot save: No widget available.'), + }), + ); + return; + } + + await send('dashboard-update-widget', { + id: widget.id, + meta: { + ...(widget.meta ?? {}), + conditions: dividendFilters.conditions, + conditionsOp: dividendFilters.conditionsOp, + timeFrame: { + start, + end, + mode, + }, + content: JSON.stringify(content), + }, + }); + dispatch( + addNotification({ + type: 'message', + message: t('Dashboard widget successfully saved.'), + }), + ); + } + + return ( + navigate('/reports')} /> + } + /> + ) : ( + + ) : ( + title + ) + } + /> + ) + } + padding={0} + > +
+ {widget && ( + + )} +
+ + + {t('Show as')} + + setContent( + (prev: SummaryContent) => + ({ + ...prev, + type: newValue, + }) as SummaryContent, + ) + } + /> + + {content.type === 'percentage' && ( + + { + const currentValue = content.divisorAllTimeDateRange ?? false; + setContent(prev => ({ + ...prev, + divisorAllTimeDateRange: !currentValue, + })); + }} + />{' '} + {t('All time divisor')} + + )} + + + + + {content.type !== 'sum' && ( + <> + + + + + {amountToCurrency(data?.dividend ?? 0)} + + +
+ + + {amountToCurrency(data?.divisor ?? 0)} + + + + + )} + + + + {amountToCurrency(Math.abs(data?.total ?? 0))} + {content.type === 'percentage' ? '%' : ''} + + + + + + ); +} + +type OperatorProps = { + type: 'sum' | 'avgPerMonth' | 'avgPerTransact' | 'percentage'; + dividendFilterObject: FilterObject; + divisorFilterObject: FilterObject; + fromRange: string; + toRange: string; + showDivisorDateRange: boolean; +}; +function Operator({ + type, + dividendFilterObject, + divisorFilterObject, + fromRange, + toRange, + showDivisorDateRange, +}: OperatorProps) { + const { t } = useTranslation(); + + return ( + + + {type === 'percentage' && ( + <> +
+ + + )} + {type !== 'percentage' && type !== 'sum' && ( + <> +
+ + {type === 'avgPerMonth' + ? t('number of months') + : t('number of transactions')} + + + )} + + ); +} + +type SumWithRangeProps = { + from: string; + to: string; + containerStyle?: CSSProperties; + filterObject: FilterObject; +}; +function SumWithRange({ + from, + to, + containerStyle, + filterObject, +}: SumWithRangeProps) { + const { t } = useTranslation(); + + return ( + + + + {to} + + {from} + + + + + {(filterObject.conditions?.length ?? 0) === 0 ? ( + + {t('all transactions')} + + ) : ( + + )} + + + + + + + ); +} diff --git a/packages/desktop-client/src/components/reports/reports/SummaryCard.tsx b/packages/desktop-client/src/components/reports/reports/SummaryCard.tsx new file mode 100644 index 00000000000..a397b65bf29 --- /dev/null +++ b/packages/desktop-client/src/components/reports/reports/SummaryCard.tsx @@ -0,0 +1,148 @@ +import React, { useState, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import * as monthUtils from 'loot-core/src/shared/months'; +import { + type SummaryContent, + type SummaryWidget, +} from 'loot-core/types/models'; + +import { View } from '../../common/View'; +import { DateRange } from '../DateRange'; +import { LoadingIndicator } from '../LoadingIndicator'; +import { ReportCard } from '../ReportCard'; +import { ReportCardName } from '../ReportCardName'; +import { calculateTimeRange } from '../reportRanges'; +import { summarySpreadsheet } from '../spreadsheets/summary-spreadsheet'; +import { SummaryNumber } from '../SummaryNumber'; +import { useReport } from '../useReport'; + +type SummaryCardProps = { + widgetId: string; + isEditing?: boolean; + meta?: SummaryWidget['meta']; + onMetaChange: (newMeta: SummaryWidget['meta']) => void; + onRemove: () => void; +}; + +export function SummaryCard({ + widgetId, + isEditing, + meta = {}, + onMetaChange, + onRemove, +}: SummaryCardProps) { + const { t } = useTranslation(); + const [start, end] = calculateTimeRange(meta?.timeFrame, { + start: monthUtils.dayFromDate(monthUtils.currentMonth()), + end: monthUtils.currentDay(), + mode: 'full', + }); + + const content = useMemo( + () => + (meta?.content + ? (() => { + try { + return JSON.parse(meta.content); + } catch (error) { + console.error('Failed to parse meta.content:', error); + return { type: 'sum' }; + } + })() + : { type: 'sum' }) as SummaryContent, + [meta], + ); + + const params = useMemo( + () => + summarySpreadsheet( + start, + end, + meta?.conditions, + meta?.conditionsOp, + content, + ), + [start, end, meta?.conditions, meta?.conditionsOp, content], + ); + + const data = useReport('summary', params); + + const [nameMenuOpen, setNameMenuOpen] = useState(false); + + return ( + { + switch (item) { + case 'rename': + setNameMenuOpen(true); + break; + case 'remove': + onRemove(); + break; + default: + console.warn(`Unrecognized menu selection: ${item}`); + break; + } + }} + > + + + { + onMetaChange({ + ...meta, + content: JSON.stringify(content), + name: newName, + }); + setNameMenuOpen(false); + }} + onClose={() => setNameMenuOpen(false)} + /> + + + + {data ? ( + { + const newContent = { ...content, fontSize: newSize }; + onMetaChange({ + ...meta, + content: JSON.stringify(newContent), + }); + }} + animate={isEditing ?? false} + /> + ) : ( + + )} + + + + ); +} diff --git a/packages/desktop-client/src/components/reports/spreadsheets/summary-spreadsheet.ts b/packages/desktop-client/src/components/reports/spreadsheets/summary-spreadsheet.ts new file mode 100644 index 00000000000..a4200d1edee --- /dev/null +++ b/packages/desktop-client/src/components/reports/spreadsheets/summary-spreadsheet.ts @@ -0,0 +1,290 @@ +import * as d from 'date-fns'; + +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 { + type SummaryContent, + type RuleConditionEntity, +} from 'loot-core/types/models'; + +export function summarySpreadsheet( + start: string, + end: string, + conditions: RuleConditionEntity[] = [], + conditionsOp: 'and' | 'or' = 'and', + summaryContent: SummaryContent, +) { + return async ( + spreadsheet: ReturnType, + setData: (data: { + total: number; + divisor: number; + dividend: number; + fromRange: string; + toRange: string; + }) => void, + ) => { + let filters = []; + try { + const response = await send('make-filters-from-conditions', { + conditions: conditions.filter(cond => !cond.customName), + }); + filters = response.filters; + } catch (error) { + console.error('Error fetching filters:', error); + } + const conditionsOpKey = conditionsOp === 'or' ? '$or' : '$and'; + + let startDay: Date; + let endDay: Date; + try { + startDay = d.parse( + monthUtils.firstDayOfMonth(start), + 'yyyy-MM-dd', + new Date(), + ); + + endDay = d.parse( + monthUtils.lastDayOfMonth(end), + 'yyyy-MM-dd', + new Date(), + ); + } catch (error) { + console.error('Error parsing dates:', error); + throw new Error('Invalid date format provided'); + } + + if (!d.isValid(startDay) || !d.isValid(endDay)) { + throw new Error('Invalid date values provided'); + } + + if (d.isAfter(startDay, endDay)) { + throw new Error('Start date must be before or equal to end date.'); + } + + const getOneDatePerMonth = (start: Date, end: Date) => { + const months = []; + let currentDate = d.startOfMonth(start); + + while (!d.isSameMonth(currentDate, end)) { + months.push(currentDate); + currentDate = d.addMonths(currentDate, 1); + } + months.push(end); + + return months; + }; + + const makeRootQuery = () => + q('transactions') + .filter({ + $and: [ + { + date: { + $gte: d.format(startDay, 'yyyy-MM-dd'), + }, + }, + { + date: { + $lte: d.format(endDay, 'yyyy-MM-dd'), + }, + }, + ], + }) + .filter({ + [conditionsOpKey]: filters, + }) + .select([ + 'date', + { amount: { $sum: '$amount' } }, + { count: { $count: '*' } }, + ]); + + let query = makeRootQuery(); + + if (summaryContent.type === 'avgPerMonth') { + query = query.groupBy(['date']); + } + + let data; + try { + data = await runQuery(query); + } catch (error) { + console.error('Error executing query:', error); + return; + } + + const dateRanges = { + fromRange: d.format(startDay, 'MMM yy'), + toRange: d.format(endDay, 'MMM yy'), + }; + + switch (summaryContent.type) { + case 'sum': + setData({ + ...dateRanges, + total: (data.data[0]?.amount ?? 0) / 100, + dividend: (data.data[0]?.amount ?? 0) / 100, + divisor: 0, + }); + break; + + case 'avgPerTransact': + setData({ + ...dateRanges, + total: + ((data.data[0]?.count ?? 0) + ? (data.data[0]?.amount ?? 0) / data.data[0].count + : 0) / 100, + dividend: (data.data[0]?.amount ?? 0) / 100, + divisor: data.data[0].count, + }); + break; + + case 'avgPerMonth': { + const months = getOneDatePerMonth(startDay, endDay); + setData({ ...dateRanges, ...calculatePerMonth(data.data, months) }); + break; + } + + case 'percentage': + setData({ + ...dateRanges, + ...(await calculatePercentage( + data.data, + summaryContent, + startDay, + endDay, + )), + }); + break; + + default: + throw new Error(`Unsupported summary type`); + } + }; +} + +function calculatePerMonth( + data: Array<{ + date: string; + amount: number; + count: number; + }>, + months: Date[], +) { + if (!data.length || !months.length) { + return { total: 0, dividend: 0, divisor: 0 }; + } + + const monthlyData = data.reduce( + (acc, day) => { + const monthKey = d.format( + d.parse(day.date, 'yyyy-MM-dd', new Date()), + 'yyyy-MM', + ); + acc[monthKey] = (acc[monthKey] || 0) + day.amount; + return acc; + }, + {} as Record, + ); + + const monthsSum = months.map(m => ({ + amount: monthlyData[d.format(m, 'yyyy-MM')] || 0, + })); + + const totalAmount = monthsSum.reduce((sum, month) => sum + month.amount, 0); + const averageAmountPerMonth = totalAmount / months.length; + + return { + total: averageAmountPerMonth / 100, + dividend: totalAmount / 100, + divisor: months.length, + }; +} + +async function calculatePercentage( + data: Array<{ + amount: number; + }>, + summaryContent: SummaryContent, + startDay: Date, + endDay: Date, +) { + if (summaryContent.type !== 'percentage') { + return { + total: 0, + dividend: 0, + divisor: 0, + }; + } + + const conditionsOpKey = + summaryContent.divisorConditionsOp === 'or' ? '$or' : '$and'; + let filters = []; + try { + const response = await send('make-filters-from-conditions', { + conditions: summaryContent?.divisorConditions?.filter( + cond => !cond.customName, + ), + }); + filters = response.filters; + } catch (error) { + console.error('Error creating filters:', error); + return { + total: 0, + dividend: 0, + divisor: 0, + }; + } + + const makeDivisorQuery = () => + q('transactions') + .filter({ + [conditionsOpKey]: filters, + }) + .select([{ amount: { $sum: '$amount' } }]); + + let query = makeDivisorQuery(); + + if (!(summaryContent.divisorAllTimeDateRange ?? false)) { + query = query.filter({ + $and: [ + { + date: { + $gte: d.format(startDay, 'yyyy-MM-dd'), + }, + }, + { + date: { + $lte: d.format(endDay, 'yyyy-MM-dd'), + }, + }, + ], + }); + } + + let divisorData; + try { + divisorData = (await runQuery(query)) as { data: { amount: number }[] }; + } catch (error) { + console.error('Error executing divisor query:', error); + return { + total: 0, + dividend: 0, + divisor: 0, + }; + } + + const divisorValue = divisorData?.data?.[0]?.amount ?? 0; + + const dividend = data.reduce((prev, ac) => prev + (ac?.amount ?? 0), 0); + return { + total: Math.round(((dividend ?? 0) / (divisorValue ?? 1)) * 10000) / 100, + divisor: (divisorValue ?? 0) / 100, + dividend: (dividend ?? 0) / 100, + }; +} diff --git a/packages/desktop-client/src/icons/v2/CloseParenthesis.svg b/packages/desktop-client/src/icons/v2/CloseParenthesis.svg new file mode 100644 index 00000000000..090064fd4d1 --- /dev/null +++ b/packages/desktop-client/src/icons/v2/CloseParenthesis.svg @@ -0,0 +1,9 @@ + + + + + diff --git a/packages/desktop-client/src/icons/v2/CloseParenthesis.tsx b/packages/desktop-client/src/icons/v2/CloseParenthesis.tsx new file mode 100644 index 00000000000..f86d4d7be8f --- /dev/null +++ b/packages/desktop-client/src/icons/v2/CloseParenthesis.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; +import type { SVGProps } from 'react'; +export const SvgCloseParenthesis = (props: SVGProps) => ( + + + +); diff --git a/packages/desktop-client/src/icons/v2/OpenParenthesis.svg b/packages/desktop-client/src/icons/v2/OpenParenthesis.svg new file mode 100644 index 00000000000..65379bb8d5a --- /dev/null +++ b/packages/desktop-client/src/icons/v2/OpenParenthesis.svg @@ -0,0 +1,9 @@ + + + + + diff --git a/packages/desktop-client/src/icons/v2/OpenParenthesis.tsx b/packages/desktop-client/src/icons/v2/OpenParenthesis.tsx new file mode 100644 index 00000000000..be6f9f577cc --- /dev/null +++ b/packages/desktop-client/src/icons/v2/OpenParenthesis.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; +import type { SVGProps } from 'react'; +export const SvgOpenParenthesis = (props: SVGProps) => ( + + + +); diff --git a/packages/desktop-client/src/icons/v2/Sum.svg b/packages/desktop-client/src/icons/v2/Sum.svg new file mode 100644 index 00000000000..4e9dcc2227a --- /dev/null +++ b/packages/desktop-client/src/icons/v2/Sum.svg @@ -0,0 +1,11 @@ + + + + + + + diff --git a/packages/desktop-client/src/icons/v2/Sum.tsx b/packages/desktop-client/src/icons/v2/Sum.tsx new file mode 100644 index 00000000000..8028ef98834 --- /dev/null +++ b/packages/desktop-client/src/icons/v2/Sum.tsx @@ -0,0 +1,20 @@ +import * as React from 'react'; +import type { SVGProps } from 'react'; +export const SvgSum = (props: SVGProps) => ( + + + +); diff --git a/packages/loot-core/src/types/models/dashboard.d.ts b/packages/loot-core/src/types/models/dashboard.d.ts index 29a73e15efd..8092fb57acc 100644 --- a/packages/loot-core/src/types/models/dashboard.d.ts +++ b/packages/loot-core/src/types/models/dashboard.d.ts @@ -65,7 +65,8 @@ type SpecializedWidget = | NetWorthWidget | CashFlowWidget | SpendingWidget - | MarkdownWidget; + | MarkdownWidget + | SummaryWidget; export type Widget = SpecializedWidget | CustomReportWidget; export type NewWidget = Omit; @@ -88,3 +89,29 @@ export type ExportImportDashboard = { version: 1; widgets: ExportImportDashboardWidget[]; }; + +export type SummaryWidget = AbstractWidget< + 'summary-card', + { + name?: string; + conditions?: RuleConditionEntity[]; + conditionsOp?: 'and' | 'or'; + timeFrame?: TimeFrame; + content?: string; + } | null +>; + +export type BaseSummaryContent = { + type: 'sum' | 'avgPerMonth' | 'avgPerTransact'; + fontSize?: number; +}; + +export type PercentageSummaryContent = { + type: 'percentage'; + divisorConditions: RuleConditionEntity[]; + divisorConditionsOp: 'and' | 'or'; + divisorAllTimeDateRange?: boolean; + fontSize?: number; +}; + +export type SummaryContent = BaseSummaryContent | PercentageSummaryContent; diff --git a/upcoming-release-notes/3792.md b/upcoming-release-notes/3792.md new file mode 100644 index 00000000000..86f09da230f --- /dev/null +++ b/upcoming-release-notes/3792.md @@ -0,0 +1,6 @@ +--- +category: Features +authors: [lelemm] +--- + +Add a summary card to report dashboard