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>
This commit is contained in:
lelemm
2024-11-21 13:25:09 -03:00
committed by GitHub
parent f523d25052
commit c626fc2f17
14 changed files with 1298 additions and 2 deletions

View File

@@ -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' ? (
<SummaryCard
widgetId={item.i}
isEditing={isEditing}
meta={item.meta}
onMetaChange={newMeta => onMetaChange(item, newMeta)}
onRemove={() => onRemoveWidget(item.i)}
/>
) : null}
</div>
))}

View File

@@ -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() {
<Route path="/custom/:id" element={<CustomReport />} />
<Route path="/spending" element={<Spending />} />
<Route path="/spending/:id" element={<Spending />} />
<Route path="/summary" element={<Summary />} />
<Route path="/summary/:id" element={<Summary />} />
</Routes>
);
}

View File

@@ -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<number>(0);
const refDiv = useRef<HTMLDivElement>(null);
const offScreenRef = useRef<HTMLDivElement>(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 && <LoadingIndicator />}
{!loading && (
<>
<div
ref={offScreenRef}
style={{
position: 'fixed',
left: '-999px',
top: '-999px',
fontSize: `${initialFontSize}px`,
lineHeight: 1,
visibility: 'hidden',
whiteSpace: 'nowrap',
padding: 8,
}}
>
<PrivacyFilter>
{amountToCurrency(Math.abs(value))}
{suffix}
</PrivacyFilter>
</div>
<View
ref={mergedRef as Ref<HTMLDivElement>}
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,
}}
>
<span aria-hidden="true">
<PrivacyFilter>
{amountToCurrency(Math.abs(value))}
{suffix}
</PrivacyFilter>
</span>
</View>
</>
)}
</>
);
}

View File

@@ -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<SummaryWidget>(
params.id ?? '',
'summary-card',
);
if (isLoading) {
return <LoadingIndicator />;
}
return <SummaryInner widget={widget} />;
}
type SummaryInnerProps = {
widget?: SummaryWidget;
};
type FilterObject = ReturnType<typeof useFilters>;
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<SummaryContent>(
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 (
<Page
header={
isNarrowWidth ? (
<MobilePageHeader
title={title}
leftContent={
<MobileBackButton onPress={() => navigate('/reports')} />
}
/>
) : (
<PageHeader
title={
widget ? (
<EditablePageHeaderTitle
title={title}
onSave={onSaveWidgetName}
/>
) : (
title
)
}
/>
)
}
padding={0}
>
<Header
allMonths={allMonths}
start={start}
end={end}
mode={mode}
onChangeDates={onChangeDates}
onApply={dividendFilters.onApply}
onUpdateFilter={dividendFilters.onUpdate}
onDeleteFilter={dividendFilters.onDelete}
conditionsOp={dividendFilters.conditionsOp}
onConditionsOpChange={dividendFilters.onConditionsOpChange}
show1Month={true}
>
{widget && (
<Button variant="primary" onPress={onSaveWidget}>
<Trans>Save widget</Trans>
</Button>
)}
</Header>
<View
style={{
width: '100%',
background: theme.pageBackground,
}}
>
<View
style={{
width: '100%',
alignContent: 'center',
justifyContent: 'flex-start',
alignItems: 'center',
flexDirection: 'row',
padding: 16,
}}
>
<span style={{ marginRight: 4 }}>{t('Show as')}</span>
<FieldSelect
style={{ marginRight: 16 }}
fields={[
['sum', t('Sum')],
['avgPerMonth', t('Average per month')],
['avgPerTransact', t('Average per transaction')],
['percentage', t('Percentage')],
]}
value={content.type ?? 'sum'}
onChange={(
newValue: 'sum' | 'avgPerMonth' | 'avgPerTransact' | 'percentage',
) =>
setContent(
(prev: SummaryContent) =>
({
...prev,
type: newValue,
}) as SummaryContent,
)
}
/>
</View>
{content.type === 'percentage' && (
<View style={{ flexDirection: 'row', marginLeft: 16 }}>
<Checkbox
id="enabled-field"
checked={content.divisorAllTimeDateRange ?? false}
onChange={() => {
const currentValue = content.divisorAllTimeDateRange ?? false;
setContent(prev => ({
...prev,
divisorAllTimeDateRange: !currentValue,
}));
}}
/>{' '}
{t('All time divisor')}
</View>
)}
</View>
<View
style={{
background: theme.pageBackground,
padding: 20,
paddingTop: 0,
flexGrow: 1,
}}
>
<View
style={{
flexDirection: 'row',
justifyContent: 'center',
width: '100%',
alignItems: 'center',
}}
>
<Operator
type={content.type}
dividendFilterObject={dividendFilters}
divisorFilterObject={divisorFilters}
showDivisorDateRange={
content.type === 'percentage'
? !(content.divisorAllTimeDateRange ?? false)
: false
}
fromRange={data?.fromRange ?? ''}
toRange={data?.toRange ?? ''}
/>
{content.type !== 'sum' && (
<>
<SvgEquals width={50} style={{ marginLeft: 56 }} />
<View style={{ padding: 16 }}>
<Text
style={{
fontSize: '50px',
width: '100%',
textAlign: 'center',
}}
>
<PrivacyFilter>
{amountToCurrency(data?.dividend ?? 0)}
</PrivacyFilter>
</Text>
<div
style={{
width: '100%',
marginTop: 32,
marginBottom: 32,
borderTop: '2px solid',
borderBottom: '2px solid',
}}
/>
<Text
style={{
fontSize: '50px',
width: '100%',
textAlign: 'center',
}}
>
<PrivacyFilter>
{amountToCurrency(data?.divisor ?? 0)}
</PrivacyFilter>
</Text>
</View>
</>
)}
<SvgEquals width={50} style={{ marginLeft: 16 }} />
<View
style={{
flexGrow: 1,
textAlign: 'center',
width: '250px',
maxWidth: '250px',
justifyItems: 'center',
alignItems: 'center',
marginLeft: 16,
fontSize: '50px',
justifyContent: 'center',
color:
(data?.total ?? 0) < 0
? chartTheme.colors.red
: chartTheme.colors.blue,
}}
>
<PrivacyFilter>
{amountToCurrency(Math.abs(data?.total ?? 0))}
{content.type === 'percentage' ? '%' : ''}
</PrivacyFilter>
</View>
</View>
</View>
</Page>
);
}
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 (
<View>
<SumWithRange
from={fromRange}
to={toRange}
filterObject={dividendFilterObject}
/>
{type === 'percentage' && (
<>
<div
style={{
width: '100%',
marginTop: 32,
marginBottom: 32,
borderTop: '2px solid',
borderBottom: '2px solid',
}}
/>
<SumWithRange
from={!showDivisorDateRange ? '' : fromRange}
to={!showDivisorDateRange ? '' : toRange}
filterObject={divisorFilterObject}
/>
</>
)}
{type !== 'percentage' && type !== 'sum' && (
<>
<div
style={{
width: '100%',
marginTop: 32,
marginBottom: 32,
borderTop: '2px solid',
borderBottom: '2px solid',
}}
/>
<Text
style={{ fontSize: '32px', width: '100%', textAlign: 'center' }}
>
{type === 'avgPerMonth'
? t('number of months')
: t('number of transactions')}
</Text>
</>
)}
</View>
);
}
type SumWithRangeProps = {
from: string;
to: string;
containerStyle?: CSSProperties;
filterObject: FilterObject;
};
function SumWithRange({
from,
to,
containerStyle,
filterObject,
}: SumWithRangeProps) {
const { t } = useTranslation();
return (
<View
style={{
...containerStyle,
height: '100%',
flexDirection: 'row',
alignItems: 'center',
position: 'relative',
display: 'grid',
gridTemplateColumns: '70px 15px 1fr 15px',
}}
>
<View style={{ position: 'relative', height: '50px', marginRight: 50 }}>
<SvgSum width={50} height={50} />
<Text style={{ position: 'absolute', right: -30, top: -20 }}>{to}</Text>
<Text style={{ position: 'absolute', right: -30, bottom: -20 }}>
{from}
</Text>
</View>
<SvgOpenParenthesis width={15} style={{ height: '100%' }} />
<View style={{ marginLeft: 16, maxWidth: '220px', marginRight: 16 }}>
{(filterObject.conditions?.length ?? 0) === 0 ? (
<Text style={{ fontSize: '25px', color: theme.pageTextPositive }}>
{t('all transactions')}
</Text>
) : (
<AppliedFilters
conditions={filterObject.conditions}
onUpdate={filterObject.onUpdate}
onDelete={filterObject.onDelete}
conditionsOp={filterObject.conditionsOp}
onConditionsOpChange={filterObject.onConditionsOpChange}
/>
)}
</View>
<SvgCloseParenthesis width={15} style={{ height: '100%' }} />
<View style={{ position: 'absolute', top: -15, right: -55 }}>
<FilterButton
compact={false}
onApply={filterObject.onApply}
hover={false}
exclude={undefined}
/>
</View>
</View>
);
}

View File

@@ -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 (
<ReportCard
isEditing={isEditing}
to={`/reports/summary/${widgetId}`}
menuItems={[
{
name: 'rename',
text: t('Rename'),
},
{
name: 'remove',
text: t('Remove'),
},
]}
onMenuSelect={item => {
switch (item) {
case 'rename':
setNameMenuOpen(true);
break;
case 'remove':
onRemove();
break;
default:
console.warn(`Unrecognized menu selection: ${item}`);
break;
}
}}
>
<View style={{ flex: 1, overflow: 'hidden' }}>
<View style={{ flexGrow: 0, flexShrink: 0, padding: 20 }}>
<ReportCardName
name={meta?.name || t('Summary')}
isEditing={nameMenuOpen}
onChange={newName => {
onMetaChange({
...meta,
content: JSON.stringify(content),
name: newName,
});
setNameMenuOpen(false);
}}
onClose={() => setNameMenuOpen(false)}
/>
<DateRange start={start} end={end} />
</View>
<View
style={{
justifyContent: 'center',
alignItems: 'center',
flexGrow: 1,
flexShrink: 1,
}}
>
{data ? (
<SummaryNumber
value={data?.total ?? 0}
suffix={content.type === 'percentage' ? '%' : ''}
loading={!data}
initialFontSize={content.fontSize}
fontSizeChanged={newSize => {
const newContent = { ...content, fontSize: newSize };
onMetaChange({
...meta,
content: JSON.stringify(newContent),
});
}}
animate={isEditing ?? false}
/>
) : (
<LoadingIndicator />
)}
</View>
</View>
</ReportCard>
);
}

View File

@@ -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<typeof useSpreadsheet>,
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<string, number>,
);
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,
};
}

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 24.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 17 56.7" style="enable-background:new 0 0 17 56.7;" xml:space="preserve">
<path d="M1.9,2.2L1.9,2.2c1.3,0.2,2.4,0.7,3.4,1.5c0.8,0.8,1.5,1.8,2,2.9c0.9,2.3,3.1,8.4,3.1,21.8S8.2,47.8,7.3,50.1
c-0.5,1.1-1.2,2.1-2,2.9c-1,0.8-2.1,1.3-3.3,1.5H1.9c-0.4,0.1-0.6,0.4-0.6,0.8c0.1,0.3,0.3,0.6,0.6,0.6c1.6,0.2,3.2-0.1,4.7-0.8
c1.4-0.8,2.7-1.9,3.6-3.2c1.7-2.6,2.9-5.4,3.6-8.4l0.1-0.3c2.5-9.7,2.5-19.8,0-29.5l-0.1-0.3c-0.7-3-1.9-5.8-3.6-8.4
C9.3,3.5,8,2.4,6.6,1.6C5.1,0.9,3.5,0.7,1.9,0.9c-0.4,0-0.7,0.4-0.6,0.7C1.3,1.9,1.5,2.2,1.9,2.2L1.9,2.2z"/>
</svg>

After

Width:  |  Height:  |  Size: 817 B

View File

@@ -0,0 +1,22 @@
import * as React from 'react';
import type { SVGProps } from 'react';
export const SvgCloseParenthesis = (props: SVGProps<SVGSVGElement>) => (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 17 56.7"
preserveAspectRatio='none'
style={{
color: 'inherit',
...props.style,
}}
>
<path
d="M1.9,2.2L1.9,2.2c1.3,0.2,2.4,0.7,3.4,1.5c0.8,0.8,1.5,1.8,2,2.9c0.9,2.3,3.1,8.4,3.1,21.8S8.2,47.8,7.3,50.1
c-0.5,1.1-1.2,2.1-2,2.9c-1,0.8-2.1,1.3-3.3,1.5H1.9c-0.4,0.1-0.6,0.4-0.6,0.8c0.1,0.3,0.3,0.6,0.6,0.6c1.6,0.2,3.2-0.1,4.7-0.8
c1.4-0.8,2.7-1.9,3.6-3.2c1.7-2.6,2.9-5.4,3.6-8.4l0.1-0.3c2.5-9.7,2.5-19.8,0-29.5l-0.1-0.3c-0.7-3-1.9-5.8-3.6-8.4
C9.3,3.5,8,2.4,6.6,1.6C5.1,0.9,3.5,0.7,1.9,0.9c-0.4,0-0.7,0.4-0.6,0.7C1.3,1.9,1.5,2.2,1.9,2.2L1.9,2.2z"
fill="currentColor"
/>
</svg>
);

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 24.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 17 56.7" style="enable-background:new 0 0 17 56.7;" xml:space="preserve">
<path d="M15.1,54.5L15.1,54.5c-1.3-0.2-2.4-0.7-3.4-1.5c-0.8-0.8-1.5-1.8-2-2.9c-0.9-2.3-3.1-8.4-3.1-21.8S8.8,8.9,9.8,6.6
c0.5-1.1,1.2-2.1,2-2.9c1-0.8,2.1-1.3,3.3-1.5h0.1c0.4-0.1,0.6-0.4,0.6-0.8c-0.1-0.3-0.3-0.6-0.6-0.6c-1.6-0.2-3.2,0.1-4.7,0.8
C9,2.4,7.8,3.5,6.8,4.8c-1.7,2.6-2.9,5.4-3.6,8.4l-0.1,0.3c-2.5,9.7-2.5,19.8,0,29.5l0.1,0.3c0.7,3,1.9,5.8,3.6,8.4
c0.9,1.3,2.2,2.4,3.6,3.2c1.5,0.7,3.1,0.9,4.7,0.8c0.4,0,0.7-0.4,0.6-0.7C15.7,54.8,15.5,54.5,15.1,54.5L15.1,54.5z"/>
</svg>

After

Width:  |  Height:  |  Size: 829 B

View File

@@ -0,0 +1,22 @@
import * as React from 'react';
import type { SVGProps } from 'react';
export const SvgOpenParenthesis = (props: SVGProps<SVGSVGElement>) => (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 17 56.7"
preserveAspectRatio='none'
style={{
color: 'inherit',
...props.style,
}}
>
<path
d="M15.1,54.5L15.1,54.5c-1.3-0.2-2.4-0.7-3.4-1.5c-0.8-0.8-1.5-1.8-2-2.9c-0.9-2.3-3.1-8.4-3.1-21.8S8.8,8.9,9.8,6.6
c0.5-1.1,1.2-2.1,2-2.9c1-0.8,2.1-1.3,3.3-1.5h0.1c0.4-0.1,0.6-0.4,0.6-0.8c-0.1-0.3-0.3-0.6-0.6-0.6c-1.6-0.2-3.2,0.1-4.7,0.8
C9,2.4,7.8,3.5,6.8,4.8c-1.7,2.6-2.9,5.4-3.6,8.4l-0.1,0.3c-2.5,9.7-2.5,19.8,0,29.5l0.1,0.3c0.7,3,1.9,5.8,3.6,8.4
c0.9,1.3,2.2,2.4,3.6,3.2c1.5,0.7,3.1,0.9,4.7,0.8c0.4,0,0.7-0.4,0.6-0.7C15.7,54.8,15.5,54.5,15.1,54.5L15.1,54.5z"
fill="currentColor"
/>
</svg>
);

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 24.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" baseProfile="basic" id="Layer_1"
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 28.3 28.3"
xml:space="preserve">
<g>
<path d="M23.2,10.1c1.1,0,2-0.9,2-2V2.2c0-1.1-0.9-2-2-2h-18c-1.1,0-2,0.9-2,2c0,0.4,0.1,0.9,0.4,1.2l8.1,10.8L3.6,25
c-0.7,0.9-0.5,2.1,0.4,2.8c0.3,0.3,0.8,0.4,1.2,0.4h18c1.1,0,2-0.9,2-2v-5.8c0-1.1-0.9-2-2-2s-2,0.9-2,2v3.8h-12l6.6-8.8
c0.5-0.7,0.5-1.7,0-2.4L9.2,4.2h12v3.9C21.2,9.2,22.1,10.1,23.2,10.1z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 654 B

View File

@@ -0,0 +1,20 @@
import * as React from 'react';
import type { SVGProps } from 'react';
export const SvgSum = (props: SVGProps<SVGSVGElement>) => (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 28.3 28.3"
style={{
color: 'inherit',
...props.style,
}}
>
<path
d="M23.2,10.1c1.1,0,2-0.9,2-2V2.2c0-1.1-0.9-2-2-2h-18c-1.1,0-2,0.9-2,2c0,0.4,0.1,0.9,0.4,1.2l8.1,10.8L3.6,25
c-0.7,0.9-0.5,2.1,0.4,2.8c0.3,0.3,0.8,0.4,1.2,0.4h18c1.1,0,2-0.9,2-2v-5.8c0-1.1-0.9-2-2-2s-2,0.9-2,2v3.8h-12l6.6-8.8
c0.5-0.7,0.5-1.7,0-2.4L9.2,4.2h12v3.9C21.2,9.2,22.1,10.1,23.2,10.1z"
fill="currentColor"
/>
</svg>
);

View File

@@ -65,7 +65,8 @@ type SpecializedWidget =
| NetWorthWidget
| CashFlowWidget
| SpendingWidget
| MarkdownWidget;
| MarkdownWidget
| SummaryWidget;
export type Widget = SpecializedWidget | CustomReportWidget;
export type NewWidget = Omit<Widget, 'id' | 'tombstone'>;
@@ -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;

View File

@@ -0,0 +1,6 @@
---
category: Features
authors: [lelemm]
---
Add a summary card to report dashboard