From df17843b507893f561cb2361674e33111672ae8e Mon Sep 17 00:00:00 2001 From: Leandro Menezes Date: Tue, 29 Oct 2024 14:48:22 -0300 Subject: [PATCH] Calendar Report --- .../src/components/reports/Overview.tsx | 17 + .../src/components/reports/ReportRouter.tsx | 3 + .../reports/graphs/CalendarGraph.tsx | 290 ++++++ .../src/components/reports/reportRanges.ts | 5 +- .../components/reports/reports/Calendar.tsx | 915 ++++++++++++++++++ .../reports/reports/CalendarCard.tsx | 522 ++++++++++ .../spreadsheets/calendar-spreadsheet.ts | 211 ++++ .../transactions/TransactionList.jsx | 4 + .../transactions/TransactionsTable.jsx | 55 +- .../transactions/TransactionsTable.test.jsx | 2 + .../loot-core/src/types/models/dashboard.d.ts | 13 +- upcoming-release-notes/3758.md | 6 + 12 files changed, 2026 insertions(+), 17 deletions(-) create mode 100644 packages/desktop-client/src/components/reports/graphs/CalendarGraph.tsx create mode 100644 packages/desktop-client/src/components/reports/reports/Calendar.tsx create mode 100644 packages/desktop-client/src/components/reports/reports/CalendarCard.tsx create mode 100644 packages/desktop-client/src/components/reports/spreadsheets/calendar-spreadsheet.ts create mode 100644 upcoming-release-notes/3758.md diff --git a/packages/desktop-client/src/components/reports/Overview.tsx b/packages/desktop-client/src/components/reports/Overview.tsx index 63a19226b6b..ff4f517d8c1 100644 --- a/packages/desktop-client/src/components/reports/Overview.tsx +++ b/packages/desktop-client/src/components/reports/Overview.tsx @@ -22,6 +22,7 @@ import { import { useAccounts } from '../../hooks/useAccounts'; import { useFeatureFlag } from '../../hooks/useFeatureFlag'; import { useNavigate } from '../../hooks/useNavigate'; +import { useSyncedPref } from '../../hooks/useSyncedPref'; import { breakpoints } from '../../tokens'; import { Button } from '../common/Button2'; import { Menu } from '../common/Menu'; @@ -34,6 +35,7 @@ import { useResponsive } from '../responsive/ResponsiveProvider'; import { NON_DRAGGABLE_AREA_CLASS_NAME } from './constants'; import { LoadingIndicator } from './LoadingIndicator'; +import { CalendarCard } from './reports/CalendarCard'; import { CashFlowCard } from './reports/CashFlowCard'; import { CustomReportListCards } from './reports/CustomReportListCards'; import { MarkdownCard } from './reports/MarkdownCard'; @@ -51,6 +53,8 @@ function isCustomReportWidget(widget: Widget): widget is CustomReportWidget { export function Overview() { const { t } = useTranslation(); const dispatch = useDispatch(); + const [_firstDayOfWeekIdx] = useSyncedPref('firstDayOfWeekIdx'); + const firstDayOfWeekIdx = _firstDayOfWeekIdx || '0'; const triggerRef = useRef(null); const extraMenuTriggerRef = useRef(null); @@ -396,6 +400,10 @@ export function Overview() { name: 'markdown-card' as const, text: t('Text widget'), }, + { + name: 'calendar-card' as const, + text: t('Calendar card'), + }, { name: 'custom-report' as const, text: t('New custom report'), @@ -551,6 +559,15 @@ export function Overview() { report={customReportMap.get(item.meta.id)} onRemove={() => onRemoveWidget(item.i)} /> + ) : item.type === 'calendar-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..34e348483fd 100644 --- a/packages/desktop-client/src/components/reports/ReportRouter.tsx +++ b/packages/desktop-client/src/components/reports/ReportRouter.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { Route, Routes } from 'react-router-dom'; import { Overview } from './Overview'; +import { Calendar } from './reports/Calendar'; import { CashFlow } from './reports/CashFlow'; import { CustomReport } from './reports/CustomReport'; import { NetWorth } from './reports/NetWorth'; @@ -19,6 +20,8 @@ export function ReportRouter() { } /> } /> } /> + } /> + } /> ); } diff --git a/packages/desktop-client/src/components/reports/graphs/CalendarGraph.tsx b/packages/desktop-client/src/components/reports/graphs/CalendarGraph.tsx new file mode 100644 index 00000000000..a22666c3eb8 --- /dev/null +++ b/packages/desktop-client/src/components/reports/graphs/CalendarGraph.tsx @@ -0,0 +1,290 @@ +import { type Ref, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { + addDays, + format, + getDate, + isSameMonth, + startOfMonth, + startOfWeek, +} from 'date-fns'; + +import { amountToCurrency } from 'loot-core/shared/util'; +import { type SyncedPrefs } from 'loot-core/types/prefs'; + +import { useResizeObserver } from '../../../hooks/useResizeObserver'; +import { styles, theme } from '../../../style'; +import { Button } from '../../common/Button2'; +import { Tooltip } from '../../common/Tooltip'; +import { View } from '../../common/View'; +import { chartTheme } from '../chart-theme'; + +type CalendarGraphProps = { + data: { + date: Date; + incomeValue: number; + expenseValue: number; + incomeSize: number; + expenseSize: number; + }[]; + start: Date; + firstDayOfWeekIdx?: SyncedPrefs['firstDayOfWeekIdx']; + onDayClick: (date: Date) => void; + // onFilter: ( + // conditionsOrSavedFilter: + // | null + // | { + // conditions: RuleConditionEntity[]; + // conditionsOp: 'and' | 'or'; + // id: RuleConditionEntity[]; + // } + // | RuleConditionEntity, + // ) => void; +}; +export function CalendarGraph({ + data, + start, + firstDayOfWeekIdx, + //onFilter, + onDayClick, +}: CalendarGraphProps) { + const { t } = useTranslation(); + const startingDate = startOfWeek(new Date(), { + weekStartsOn: firstDayOfWeekIdx + ? (parseInt(firstDayOfWeekIdx) as 0 | 1 | 2 | 3 | 4 | 5 | 6) + : 0, + }); + const [fontSize, setFontSize] = useState(14); + + const buttonRef = useResizeObserver(rect => { + const newValue = Math.floor(rect.height / 2); + if (newValue > 14) { + setFontSize(14); + } else { + setFontSize(newValue); + } + }); + + return ( + <> + + {Array.from({ length: 7 }, (_, index) => ( + + {format(addDays(startingDate, index), 'EEEEE')} + + ))} + + + {data.map((day, index) => + !isSameMonth(day.date, startOfMonth(start)) ? ( + + ) : ( + + + + {t('Day:') + ' '} + {format(day.date, 'dd')} + + + + + {day.incomeValue !== 0 && ( + <> + + Income: + + + {day.incomeValue !== 0 + ? amountToCurrency(day.incomeValue) + : ''} + + + ({Math.round(day.incomeSize * 100) / 100 + '%'}) + + + )} + {day.expenseValue !== 0 && ( + <> + + Expenses: + + + {day.expenseValue !== 0 + ? amountToCurrency(day.expenseValue) + : ''} + + + ({Math.round(day.expenseSize * 100) / 100 + '%'}) + + + )} + + + + } + placement="bottom end" + style={{ + ...styles.tooltip, + lineHeight: 1.5, + padding: '6px 10px', + }} + > + onDayClick(day.date)} + /> + + ), + )} + + + ); +} + +type DayButtonProps = { + fontSize: number; + resizeRef: Ref; + day: { + date: Date; + incomeSize: number; + expenseSize: number; + }; + onPress: () => void; +}; +function DayButton({ day, onPress, fontSize, resizeRef }: DayButtonProps) { + const [currentFontSize, setCurrentFontSize] = useState(fontSize); + + useEffect(() => { + setCurrentFontSize(fontSize); + }, [fontSize]); + + return ( + + ); +} diff --git a/packages/desktop-client/src/components/reports/reportRanges.ts b/packages/desktop-client/src/components/reports/reportRanges.ts index 1fa289b5f72..e909ab6752e 100644 --- a/packages/desktop-client/src/components/reports/reportRanges.ts +++ b/packages/desktop-client/src/components/reports/reportRanges.ts @@ -161,7 +161,10 @@ export function getFullRange(start: string) { export function getLatestRange(offset: number) { const end = monthUtils.currentMonth(); - const start = monthUtils.subMonths(end, offset); + let start = end; + if (offset !== 1) { + start = monthUtils.subMonths(end, offset); + } return [start, end, 'sliding-window'] as const; } diff --git a/packages/desktop-client/src/components/reports/reports/Calendar.tsx b/packages/desktop-client/src/components/reports/reports/Calendar.tsx new file mode 100644 index 00000000000..eb02359f902 --- /dev/null +++ b/packages/desktop-client/src/components/reports/reports/Calendar.tsx @@ -0,0 +1,915 @@ +import React, { + useState, + useEffect, + useMemo, + useRef, + type Ref, + useLayoutEffect, + useCallback, +} from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import { useDispatch } from 'react-redux'; +import { useParams, useSearchParams } from 'react-router-dom'; +import { useSpring, animated, config } from 'react-spring'; + +import { useDrag } from '@use-gesture/react'; +import { format, isValid, parseISO } from 'date-fns'; + +import { SchedulesProvider } from 'loot-core/client/data-hooks/schedules'; +import { useTransactions } from 'loot-core/client/data-hooks/transactions'; +import { useWidget } from 'loot-core/client/data-hooks/widget'; +import { send } from 'loot-core/platform/client/fetch'; +import { q, type Query } from 'loot-core/shared/query'; +import { ungroupTransactions } from 'loot-core/shared/transactions'; +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 RuleConditionEntity, + type CalendarWidget, + type TimeFrame, + type TransactionEntity, +} from 'loot-core/types/models'; + +import { useAccounts } from '../../../hooks/useAccounts'; +import { useCategories } from '../../../hooks/useCategories'; +import { useDateFormat } from '../../../hooks/useDateFormat'; +import { useFilters } from '../../../hooks/useFilters'; +import { useMergedRefs } from '../../../hooks/useMergedRefs'; +import { useNavigate } from '../../../hooks/useNavigate'; +import { usePayees } from '../../../hooks/usePayees'; +import { useResizeObserver } from '../../../hooks/useResizeObserver'; +import { SelectedProviderWithItems } from '../../../hooks/useSelected'; +import { SplitsExpandedProvider } from '../../../hooks/useSplitsExpanded'; +import { useSyncedPref } from '../../../hooks/useSyncedPref'; +import { + SvgArrowThickDown, + SvgArrowThickUp, + SvgCheveronDown, + SvgCheveronUp, +} from '../../../icons/v1'; +import { styles, theme } from '../../../style'; +import { Button } from '../../common/Button2'; +import { View } from '../../common/View'; +import { EditablePageHeaderTitle } from '../../EditablePageHeaderTitle'; +import { MobileBackButton } from '../../mobile/MobileBackButton'; +import { TransactionList as TransactionListMobile } from '../../mobile/transactions/TransactionList'; +import { MobilePageHeader, Page, PageHeader } from '../../Page'; +import { useResponsive } from '../../responsive/ResponsiveProvider'; +import { TransactionList } from '../../transactions/TransactionList'; +import { chartTheme } from '../chart-theme'; +import { DateRange } from '../DateRange'; +import { CalendarGraph } from '../graphs/CalendarGraph'; +import { Header } from '../Header'; +import { LoadingIndicator } from '../LoadingIndicator'; +import { calculateTimeRange } from '../reportRanges'; +import { + type CalendarDataType, + calendarSpreadsheet, +} from '../spreadsheets/calendar-spreadsheet'; +import { useReport } from '../useReport'; +import { fromDateRepr } from '../util'; + +export function Calendar() { + const params = useParams(); + const [searchParams] = useSearchParams(); + const { data: widget, isLoading } = useWidget( + params.id ?? '', + 'calendar-card', + ); + + if (isLoading) { + return ; + } + + return ; +} + +type CalendarInnerProps = { + widget?: CalendarWidget; + parameters: URLSearchParams; +}; + +function CalendarInner({ widget, parameters }: CalendarInnerProps) { + 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 [query, setQuery] = useState(undefined); + const [dirty, setDirty] = useState(false); + + const { transactions: transactionsGrouped, loadMore: loadMoreTransactions } = + useTransactions({ query }); + + const allTransactions = useMemo( + () => ungroupTransactions(transactionsGrouped as TransactionEntity[]), + [transactionsGrouped], + ); + + const accounts = useAccounts(); + const payees = usePayees(); + const { grouped: categoryGroups } = useCategories(); + + const [_firstDayOfWeekIdx] = useSyncedPref('firstDayOfWeekIdx'); + const firstDayOfWeekIdx = _firstDayOfWeekIdx || '0'; + const { + conditions, + conditionsOp, + onApply: onApplyFilter, + onDelete: onDeleteFilter, + onUpdate: onUpdateFilter, + onConditionsOpChange, + } = useFilters(widget?.meta?.conditions, widget?.meta?.conditionsOp); + + useLayoutEffect(() => { + if (parameters.has('day') && onApplyFilter) { + onApplyFilter({ + op: 'is', + field: 'date', + value: parameters.get('day') as string, + }); + } + + if ( + parameters.has('monthStart') && + isValid(new Date(parameters.get('monthStart') as string)) && + parameters.has('monthEnd') && + isValid(new Date(parameters.get('monthEnd') as string)) && + onApplyFilter + ) { + onApplyFilter({ + conditions: [ + { + field: 'date', + op: 'gte', + value: parameters.get('monthStart'), + }, + { + field: 'date', + op: 'lte', + value: parameters.get('monthEnd'), + }, + ] as RuleConditionEntity[], + conditionsOp: 'and', + id: [], + }); + } + }, [parameters, onApplyFilter]); + + const params = useMemo(() => { + if (dirty === true) { + setDirty(false); + } + + return calendarSpreadsheet( + start, + end, + conditions, + conditionsOp, + firstDayOfWeekIdx, + ); + }, [start, end, conditions, conditionsOp, firstDayOfWeekIdx, dirty]); + + const [sortField, setSortField] = useState(''); + const [ascDesc, setAscDesc] = useState('desc'); + + useEffect(() => { + const conditionsOpKey = conditionsOp === 'or' ? '$or' : '$and'; + + send('make-filters-from-conditions', { + conditions: conditions.filter(cond => !cond.customName), + }).then((data: { filters: unknown[] }) => { + let query = q('transactions') + .filter({ + [conditionsOpKey]: data.filters, + }) + .filter({ + $and: [ + { date: { $gte: monthUtils.firstDayOfMonth(start) } }, + { date: { $lte: monthUtils.lastDayOfMonth(end) } }, + ], + }) + .select('*'); + + if (sortField) { + query = query.orderBy({ + [getField(sortField)]: ascDesc, + }); + } + + setQuery(query); + }); + }, [start, end, conditions, conditionsOp, sortField, ascDesc]); + + const [flexAlignment, setFlexAlignment] = useState('center'); + const scrollbarContainer = useRef(null); + const ref = useResizeObserver(() => { + setFlexAlignment( + scrollbarContainer.current && + scrollbarContainer.current.scrollWidth > + scrollbarContainer.current.clientWidth + ? 'flex-start' + : 'center', + ); + }); + const mergedRef = useMergedRefs( + ref, + scrollbarContainer, + ) as Ref; + + const data = useReport('calendar', params); + + 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('Calendar'); + const table = useRef(null); + const dateFormat = useDateFormat(); + + const onSaveWidgetName = async (newName: string) => { + if (!widget) { + throw new Error('No widget that could be saved.'); + } + + const name = newName || t('Net Worth'); + await send('dashboard-update-widget', { + id: widget.id, + meta: { + ...(widget.meta ?? {}), + name, + }, + }); + }; + + function onChangeDates(start: string, end: string, mode: TimeFrame['mode']) { + setStart(start); + setEnd(end); + setMode(mode); + } + + async function onSaveWidget() { + if (!widget) { + throw new Error('No widget that could be saved.'); + } + + await send('dashboard-update-widget', { + id: widget.id, + meta: { + ...(widget.meta ?? {}), + conditions, + conditionsOp, + timeFrame: { + start, + end, + mode, + }, + }, + }); + dispatch( + addNotification({ + type: 'message', + message: t('Dashboard widget successfully saved.'), + }), + ); + } + + const { totalIncome, totalExpense } = useMemo(() => { + if (!data) { + return { totalIncome: 0, totalExpense: 0 }; + } + return { + totalIncome: data.calendarData.reduce( + (prev, cur) => prev + cur.totalIncome, + 0, + ), + totalExpense: data.calendarData.reduce( + (prev, cur) => prev + cur.totalExpense, + 0, + ), + }; + }, [data]); + + const onSort = (headerClicked: string, ascDesc: 'asc' | 'desc') => { + if (headerClicked === sortField) { + setAscDesc(ascDesc); + } else { + setSortField(headerClicked); + setAscDesc('desc'); + } + }; + + const onOpenTransaction = useCallback( + (transaction: TransactionEntity) => { + navigate(`/transactions/${transaction.id}`); + }, + [navigate], + ); + + const CHEVRON_HEIGHT = 26; + + const refContainer = useRef(null); + + useEffect(() => { + if (refContainer.current) { + setTotalHeight(refContainer.current.clientHeight - 115); + } + }, [query]); + + const [totalHeight, setTotalHeight] = useState(0); + const closeY = useRef(3000); + + const openY = 0; + const [mobileTransactionsOpen, setMobileTransactionsOpen] = useState(false); + + const [{ y }, api] = useSpring(() => ({ + y: closeY.current, + immediate: false, + })); + + useEffect(() => { + closeY.current = totalHeight; + api.start({ + y: mobileTransactionsOpen ? openY : closeY.current, + immediate: false, + }); + }, [totalHeight, mobileTransactionsOpen, api]); + + const open = ({ canceled }: { canceled: boolean }) => { + api.start({ + y: openY, + immediate: false, + config: canceled ? config.wobbly : config.stiff, + }); + setMobileTransactionsOpen(true); + }; + + const close = (velocity = 0) => { + api.start({ + y: closeY.current, + config: { ...config.stiff, velocity }, + }); + setMobileTransactionsOpen(false); + }; + + const bind = useDrag( + ({ offset: [, oy], cancel }) => { + if (oy < 0) { + cancel(); + open({ canceled: false }); + } + + api.start({ y: oy, immediate: true }); + if (oy > totalHeight - CHEVRON_HEIGHT * 1.5 && mobileTransactionsOpen) { + cancel(); + close(); + setMobileTransactionsOpen(false); + } else { + setMobileTransactionsOpen(true); + } + // } + }, + { + from: () => [0, y.get()], + filterTaps: true, + bounds: { top: -totalHeight + 115, bottom: totalHeight - CHEVRON_HEIGHT }, + axis: 'y', + rubberband: true, + }, + ); + + return ( + navigate('/reports')} /> + } + /> + ) : ( + + ) : ( + title + ) + } + /> + ) + } + padding={0} + > +
+ {widget && ( + + )} +
+ } style={{ flexGrow: 1 }}> + + + {data && ( + + {data.calendarData.map((calendar, index) => ( + + ))} + + )} + + + + { + return Promise.resolve([]); + }} + registerDispatch={() => {}} + selectAllFilter={(item: TransactionEntity) => + !item._unmatched && !item.is_parent + } + > + + + {!isNarrowWidth ? ( + + false} + isMatched={() => false} + isFiltered={() => true} + dateFormat={dateFormat} + hideFraction={false} + addNotification={addNotification} + renderEmpty={() => ( + + No transactions + + )} + onSort={onSort} + sortField={sortField} + ascDesc={ascDesc} + onChange={() => {}} + onRefetch={() => setDirty(true)} + onCloseAddTransaction={() => {}} + onCreatePayee={() => {}} + onApplyFilter={() => {}} + onBatchDelete={() => {}} + onBatchDuplicate={() => {}} + onBatchLinkSchedule={() => {}} + onBatchUnlinkSchedule={() => {}} + onCreateRule={() => {}} + onScheduleAction={() => {}} + onMakeAsNonSplitTransactions={() => {}} + showSelection={false} + allowSplitTransaction={false} + /> + + ) : ( + + + + false} + onLoadMore={loadMoreTransactions} + transactions={allTransactions} + onOpenTransaction={onOpenTransaction} + /> + + + )} + + + + +
+ ); +} +type CalendarHeaderProps = { + calendar: { + start: Date; + end: Date; + data: CalendarDataType[]; + totalExpense: number; + totalIncome: number; + }; + totalIncome: number; + totalExpense: number; + onApplyFilter: ( + conditions: + | null + | RuleConditionEntity + | { + conditions: RuleConditionEntity[]; + conditionsOp: 'and' | 'or'; + id: RuleConditionEntity[]; + }, + ) => void; + firstDayOfWeekIdx: string; +}; + +function CalendarWithHeader({ + calendar, + totalIncome, + totalExpense, + onApplyFilter, + firstDayOfWeekIdx, +}: CalendarHeaderProps) { + return ( + + + + + {totalIncome ? ( + <> + + + {amountToCurrency(calendar.totalIncome)} + + + ) : ( + '' + )} + {totalExpense ? ( + <> + + + {amountToCurrency(calendar.totalExpense)} + + + ) : ( + '' + )} + + +
+ + onApplyFilter({ + conditions: [ + { + field: 'date', + op: 'is', + value: format(date, 'yyyy-MM-dd'), + }, + ], + conditionsOp: 'and', + id: [], + }) + } + firstDayOfWeekIdx={firstDayOfWeekIdx} + /> +
+
+ ); +} + +type CalendarCardHeaderProps = { + start: string; + end: string; + totalIncome: number; + totalExpense: number; + isNarrowWidth: boolean; +}; + +function CalendarCardHeader({ + start, + end, + totalIncome, + totalExpense, + isNarrowWidth, +}: CalendarCardHeaderProps) { + return ( + + + + + + {totalIncome !== 0 && ( + <> + + Income: + + + {totalIncome !== 0 ? amountToCurrency(totalIncome) : ''} + + + )} + {totalExpense !== 0 && ( + <> + + Expenses: + + + {totalExpense !== 0 ? amountToCurrency(totalExpense) : ''} + + + )} + + + + + ); +} + +function getField(field?: string) { + if (!field) { + return 'date'; + } + + switch (field) { + case 'account': + return 'account.name'; + case 'payee': + return 'payee.name'; + case 'category': + return 'category.name'; + case 'payment': + return 'amount'; + case 'deposit': + return 'amount'; + default: + return field; + } +} diff --git a/packages/desktop-client/src/components/reports/reports/CalendarCard.tsx b/packages/desktop-client/src/components/reports/reports/CalendarCard.tsx new file mode 100644 index 00000000000..06b607d7b01 --- /dev/null +++ b/packages/desktop-client/src/components/reports/reports/CalendarCard.tsx @@ -0,0 +1,522 @@ +import React, { + useState, + useMemo, + useRef, + type Ref, + useEffect, + type Dispatch, + type SetStateAction, +} from 'react'; +import { useTranslation } from 'react-i18next'; + +import { format } from 'date-fns'; +import { debounce } from 'debounce'; + +import { amountToCurrency } from 'loot-core/shared/util'; +import * as monthUtils from 'loot-core/src/shared/months'; +import { type CalendarWidget } from 'loot-core/types/models'; +import { type SyncedPrefs } from 'loot-core/types/prefs'; + +import { useFeatureFlag } from '../../../hooks/useFeatureFlag'; +import { useMergedRefs } from '../../../hooks/useMergedRefs'; +import { useNavigate } from '../../../hooks/useNavigate'; +import { useResizeObserver } from '../../../hooks/useResizeObserver'; +import { SvgArrowThickDown, SvgArrowThickUp } from '../../../icons/v1'; +import { styles, theme } from '../../../style'; +import { Block } from '../../common/Block'; +import { Button } from '../../common/Button2'; +import { Tooltip } from '../../common/Tooltip'; +import { View } from '../../common/View'; +import { useResponsive } from '../../responsive/ResponsiveProvider'; +import { chartTheme } from '../chart-theme'; +import { DateRange } from '../DateRange'; +import { CalendarGraph } from '../graphs/CalendarGraph'; +import { LoadingIndicator } from '../LoadingIndicator'; +import { ReportCard } from '../ReportCard'; +import { ReportCardName } from '../ReportCardName'; +import { calculateTimeRange } from '../reportRanges'; +import { + type CalendarDataType, + calendarSpreadsheet, +} from '../spreadsheets/calendar-spreadsheet'; +import { useReport } from '../useReport'; + +type CalendarProps = { + widgetId: string; + isEditing?: boolean; + meta?: CalendarWidget['meta']; + onMetaChange: (newMeta: CalendarWidget['meta']) => void; + onRemove: () => void; + firstDayOfWeekIdx?: SyncedPrefs['firstDayOfWeekIdx']; +}; + +export function CalendarCard({ + widgetId, + isEditing, + meta = {}, + onMetaChange, + onRemove, + firstDayOfWeekIdx, +}: CalendarProps) { + const { t } = useTranslation(); + const isDashboardsFeatureEnabled = useFeatureFlag('dashboards'); + const [start, end] = calculateTimeRange(meta?.timeFrame, { + start: monthUtils.dayFromDate(monthUtils.currentMonth()), + end: monthUtils.currentDay(), + mode: 'full', + }); + const params = useMemo( + () => + calendarSpreadsheet( + start, + end, + meta?.conditions, + meta?.conditionsOp, + firstDayOfWeekIdx, + ), + [start, end, meta?.conditions, meta?.conditionsOp, firstDayOfWeekIdx], + ); + + const [cardOrientation, setCardOrientation] = useState<'row' | 'column'>( + 'row', + ); + const { isNarrowWidth } = useResponsive(); + + const cardRef = useResizeObserver(rect => { + if (rect.height > rect.width) { + setCardOrientation('column'); + } else { + setCardOrientation('row'); + } + }); + + const data = useReport('calendar', params); + + const [nameMenuOpen, setNameMenuOpen] = useState(false); + + const { totalIncome, totalExpense } = useMemo(() => { + if (!data) { + return { totalIncome: 0, totalExpense: 0 }; + } + return { + totalIncome: data.calendarData.reduce( + (prev, cur) => prev + cur.totalIncome, + 0, + ), + totalExpense: data.calendarData.reduce( + (prev, cur) => prev + cur.totalExpense, + 0, + ), + }; + }, [data]); + + const [monthNameFormats, setMonthNameFormats] = useState([]); + const [selectedMonthNameFormat, setSelectedMonthNameFormat] = + useState('MMMM yyyy'); + + useEffect(() => { + if (data) { + setMonthNameFormats( + Array(data.calendarData.length).map(() => 'MMMM yyyy'), + ); + } else { + setMonthNameFormats([]); + } + }, [data]); + + useEffect(() => { + if (monthNameFormats.length) { + setSelectedMonthNameFormat( + monthNameFormats.reduce( + (a, b) => ((a?.length ?? 0) <= (b?.length ?? 0) ? a : b), + 'MMMM yyyy', + ), + ); + } else { + setSelectedMonthNameFormat('MMMM yyyy'); + } + }, [monthNameFormats]); + + return ( + { + switch (item) { + case 'rename': + setNameMenuOpen(true); + break; + case 'remove': + onRemove(); + break; + default: + throw new Error(`Unrecognized selection: ${item}`); + } + }} + > + + + + { + onMetaChange({ + ...meta, + name: newName, + }); + setNameMenuOpen(false); + }} + onClose={() => setNameMenuOpen(false)} + /> + + + + + + {totalIncome !== 0 && ( + <> + + Income: + + + {totalIncome !== 0 + ? amountToCurrency(totalIncome) + : ''} + + + )} + {totalExpense !== 0 && ( + <> + + Expenses: + + + {totalExpense !== 0 + ? amountToCurrency(totalExpense) + : ''} + + + )} + + + } + > + + + + + + + + {data ? ( + data.calendarData.map((calendar, index) => ( + + )) + ) : ( + + )} + + + + + ); +} + +type CalendarCardInnerProps = { + calendar: { + start: Date; + end: Date; + data: CalendarDataType[]; + totalExpense: number; + totalIncome: number; + }; + firstDayOfWeekIdx: string; + setMonthNameFormats: Dispatch>; + selectedMonthNameFormat: string; + index: number; + isDashboardsFeatureEnabled: boolean; + widgetId: string; +}; +function CalendarCardInner({ + calendar, + firstDayOfWeekIdx, + setMonthNameFormats, + selectedMonthNameFormat, + index, + isDashboardsFeatureEnabled, + widgetId, +}: CalendarCardInnerProps) { + const [monthNameVisible, setMonthNameVisible] = useState(true); + const monthFormatSizeContainers = useRef<(HTMLSpanElement | null)[]>( + new Array(5), + ); + const monthNameContainerRef = useRef(null); + + const debouncedResizeCallback = debounce(() => { + for (let i = 0; i < monthFormatSizeContainers.current.length; i++) { + if ( + monthNameContainerRef.current && + monthFormatSizeContainers && + monthFormatSizeContainers.current[i] && + monthNameContainerRef.current.clientWidth > + (monthFormatSizeContainers?.current[i]?.clientWidth ?? 0) + ) { + setMonthNameFormats(prev => { + const newArray = [...prev]; + newArray[index] = + monthFormatSizeContainers?.current[i]?.getAttribute( + 'data-format', + ) ?? ''; + return newArray; + }); + + setMonthNameVisible(true); + return; + } + } + + if ( + monthNameContainerRef.current && + monthNameContainerRef.current.scrollWidth > + monthNameContainerRef.current.clientWidth + ) { + setMonthNameVisible(false); + } else { + setMonthNameVisible(true); + } + }, 20); + + const monthNameResizeRef = useResizeObserver(debouncedResizeCallback); + + useEffect(() => { + return () => { + debouncedResizeCallback.clear(); + }; + }, [debouncedResizeCallback]); + + const mergedRef = useMergedRefs( + monthNameContainerRef, + monthNameResizeRef, + ) as Ref; + + const navigate = useNavigate(); + + return ( + + + + + + + + {calendar.totalIncome !== 0 ? ( + <> + + {amountToCurrency(calendar.totalIncome)} + + ) : ( + '' + )} + + + {calendar.totalExpense !== 0 ? ( + <> + + {amountToCurrency(calendar.totalExpense)} + + ) : ( + '' + )} + + + + { + navigate( + isDashboardsFeatureEnabled + ? `/reports/calendar/${widgetId}?day=${format(date, 'yyyy-MM-dd')}` + : '/reports/calendar', + ); + }} + /> + + (monthFormatSizeContainers.current[0] = rel)} + style={{ position: 'fixed', top: -9999, left: -9999 }} + data-format="MMMM yyyy" + > + {format(calendar.start, 'MMMM yyyy')}: + + (monthFormatSizeContainers.current[1] = rel)} + style={{ position: 'fixed', top: -9999, left: -9999 }} + data-format="MMM yyyy" + > + {format(calendar.start, 'MMM yyyy')}: + + (monthFormatSizeContainers.current[2] = rel)} + style={{ position: 'fixed', top: -9999, left: -9999 }} + data-format="MMM yy" + > + {format(calendar.start, 'MMM yy')}: + + (monthFormatSizeContainers.current[3] = rel)} + style={{ position: 'fixed', top: -9999, left: -9999 }} + data-format="MMM" + > + {format(calendar.start, 'MMM')}: + + (monthFormatSizeContainers.current[4] = rel)} + style={{ position: 'fixed', top: -9999, left: -9999 }} + data-format="" + /> + + + ); +} diff --git a/packages/desktop-client/src/components/reports/spreadsheets/calendar-spreadsheet.ts b/packages/desktop-client/src/components/reports/spreadsheets/calendar-spreadsheet.ts new file mode 100644 index 00000000000..d21abe467f4 --- /dev/null +++ b/packages/desktop-client/src/components/reports/spreadsheets/calendar-spreadsheet.ts @@ -0,0 +1,211 @@ +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 RuleConditionEntity } from 'loot-core/types/models'; +import { type SyncedPrefs } from 'loot-core/types/prefs'; + +export type CalendarDataType = { + date: Date; + incomeValue: number; + expenseValue: number; + incomeSize: number; + expenseSize: number; +}; +export function calendarSpreadsheet( + start: string, + end: string, + conditions: RuleConditionEntity[] = [], + conditionsOp: 'and' | 'or' = 'and', + firstDayOfWeekIdx?: SyncedPrefs['firstDayOfWeekIdx'], +) { + return async ( + spreadsheet: ReturnType, + setData: (data: { + calendarData: { + start: Date; + end: Date; + data: CalendarDataType[]; + totalExpense: number; + totalIncome: number; + }[]; + }) => void, + ) => { + const { filters } = await send('make-filters-from-conditions', { + conditions: conditions.filter(cond => !cond.customName), + }); + const conditionsOpKey = conditionsOp === 'or' ? '$or' : '$and'; + + const startDay = d.parse( + monthUtils.firstDayOfMonth(start), + 'yyyy-MM-dd', + new Date(), + ); + + const endDay = d.parse( + monthUtils.lastDayOfMonth(end), + 'yyyy-MM-dd', + new Date(), + ); + + 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, + }) + .groupBy(['date']) + .select(['date', { amount: { $sum: '$amount' } }]); + + const expenseData = await runQuery( + makeRootQuery().filter({ + $and: { amount: { $lt: 0 } }, + }), + ); + + const incomeData = await runQuery( + makeRootQuery().filter({ + $and: { amount: { $gt: 0 } }, + }), + ); + + 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; + }; + + setData( + recalculate( + incomeData.data, + expenseData.data, + getOneDatePerMonth(startDay, endDay), + start, + firstDayOfWeekIdx, + ), + ); + }; +} + +function recalculate( + incomeData: Array<{ + date: string; + amount: number; + }>, + expenseData: Array<{ + date: string; + amount: number; + }>, + months: Date[], + start: string, + firstDayOfWeekIdx?: SyncedPrefs['firstDayOfWeekIdx'], +) { + const getDaysArray = (month: Date) => { + const expenseValues = expenseData + .filter(f => + d.isSameMonth(d.parse(f.date, 'yyyy-MM-dd', new Date()), month), + ) + .map(m => Math.abs(m.amount)); + const incomeValues = incomeData + .filter(f => + d.isSameMonth(d.parse(f.date, 'yyyy-MM-dd', new Date()), month), + ) + .map(m => Math.abs(m.amount)); + + const totalExpenseValue = expenseValues.length + ? expenseValues.reduce((acc, val) => acc + val, 0) + : null; + + const totalIncomeValue = incomeValues.length + ? incomeValues.reduce((acc, val) => acc + val, 0) + : null; + + const getBarLength = (value: number) => { + if (value < 0 && totalExpenseValue !== null && totalExpenseValue !== 0) { + return (Math.abs(value) / totalExpenseValue) * 100; + } else if ( + value > 0 && + totalIncomeValue !== null && + totalIncomeValue !== 0 + ) { + return (value / totalIncomeValue) * 100; + } else { + return 0; + } + }; + + const firstDay = d.startOfMonth(month); + const beginDay = d.startOfWeek(firstDay, { + weekStartsOn: firstDayOfWeekIdx + ? (parseInt(firstDayOfWeekIdx) as 0 | 1 | 2 | 3 | 4 | 5 | 6) + : 0, + }); + let totalDays = + d.differenceInDays(firstDay, beginDay) + d.getDaysInMonth(firstDay); + if (totalDays % 7 !== 0) { + totalDays += 7 - (totalDays % 7); + } + const daysArray = []; + + for (let i = 0; i < totalDays; i++) { + const currentDate = d.addDays(beginDay, i); + if (!d.isSameMonth(currentDate, firstDay)) { + daysArray.push({ + date: currentDate, + incomeValue: 0, + expenseValue: 0, + incomeSize: 0, + expenseSize: 0, + }); + } else { + const currentIncome = + incomeData.find(f => + d.isSameDay(d.parse(f.date, 'yyyy-MM-dd', new Date()), currentDate), + )?.amount ?? 0; + + const currentExpense = + expenseData.find(f => + d.isSameDay(d.parse(f.date, 'yyyy-MM-dd', new Date()), currentDate), + )?.amount ?? 0; + daysArray.push({ + date: currentDate, + incomeSize: getBarLength(currentIncome), + incomeValue: Math.abs(currentIncome) / 100, + expenseSize: getBarLength(currentExpense), + expenseValue: Math.abs(currentExpense) / 100, + }); + } + } + + return { + data: daysArray as CalendarDataType[], + totalExpense: (totalExpenseValue ?? 0) / 100, + totalIncome: (totalIncomeValue ?? 0) / 100, + }; + }; + + return { + calendarData: months.map(m => { + return { + ...getDaysArray(m), + start: d.startOfMonth(m), + end: d.endOfMonth(m), + }; + }), + }; +} diff --git a/packages/desktop-client/src/components/transactions/TransactionList.jsx b/packages/desktop-client/src/components/transactions/TransactionList.jsx index 41b4be1c0e4..849c6daf497 100644 --- a/packages/desktop-client/src/components/transactions/TransactionList.jsx +++ b/packages/desktop-client/src/components/transactions/TransactionList.jsx @@ -88,6 +88,8 @@ export function TransactionList({ onCloseAddTransaction, onCreatePayee, onApplyFilter, + showSelection = true, + allowSplitTransaction = true, onBatchDelete, onBatchDuplicate, onBatchLinkSchedule, @@ -251,6 +253,8 @@ export function TransactionList({ onCreateRule={onCreateRule} onScheduleAction={onScheduleAction} onMakeAsNonSplitTransactions={onMakeAsNonSplitTransactions} + showSelection={showSelection} + allowSplitTransaction={allowSplitTransaction} /> ); } diff --git a/packages/desktop-client/src/components/transactions/TransactionsTable.jsx b/packages/desktop-client/src/components/transactions/TransactionsTable.jsx index 73f377f35c7..998280f8c02 100644 --- a/packages/desktop-client/src/components/transactions/TransactionsTable.jsx +++ b/packages/desktop-client/src/components/transactions/TransactionsTable.jsx @@ -178,6 +178,7 @@ const TransactionHeader = memo( onSort, ascDesc, field, + showSelection, }) => { const dispatchSelected = useSelectedDispatch(); const { t } = useTranslation(); @@ -205,19 +206,32 @@ const TransactionHeader = memo( borderColor: theme.tableBorder, }} > - - dispatchSelected({ type: 'select-all', isRangeSelect: e.shiftKey }) - } - /> + {showSelection && ( + + dispatchSelected({ + type: 'select-all', + isRangeSelect: e.shiftKey, + }) + } + /> + )} + {!showSelection && ( + + )} ) - ) : isPreview && isChild ? ( + ) : (isPreview && isChild) || !showSelection ? ( ) : ( ))} ); }; @@ -1995,6 +2017,7 @@ function TransactionTableInner({ onSort={props.onSort} ascDesc={props.ascDesc} field={props.sortField} + showSelection={props.showSelection} /> {props.isAdding && ( @@ -2589,6 +2612,8 @@ export const TransactionTable = forwardRef((props, ref) => { newTransactions={newTransactions} tableNavigator={tableNavigator} newNavigator={newNavigator} + showSelection={props.showSelection} + allowSplitTransaction={props.allowSplitTransaction} /> ); }); diff --git a/packages/desktop-client/src/components/transactions/TransactionsTable.test.jsx b/packages/desktop-client/src/components/transactions/TransactionsTable.test.jsx index 95c73607e91..a654e329d26 100644 --- a/packages/desktop-client/src/components/transactions/TransactionsTable.test.jsx +++ b/packages/desktop-client/src/components/transactions/TransactionsTable.test.jsx @@ -168,6 +168,8 @@ function LiveTransactionTable(props) { onAdd={onAdd} onAddSplit={onAddSplit} onCreatePayee={onCreatePayee} + showSelection={true} + allowSplitTransaction={true} /> diff --git a/packages/loot-core/src/types/models/dashboard.d.ts b/packages/loot-core/src/types/models/dashboard.d.ts index 29a73e15efd..9b5836a2b8b 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 + | CalendarWidget; export type Widget = SpecializedWidget | CustomReportWidget; export type NewWidget = Omit; @@ -88,3 +89,13 @@ export type ExportImportDashboard = { version: 1; widgets: ExportImportDashboardWidget[]; }; + +export type CalendarWidget = AbstractWidget< + 'calendar-card', + { + name?: string; + conditions?: RuleConditionEntity[]; + conditionsOp?: 'and' | 'or'; + timeFrame?: TimeFrame; + } | null +>; diff --git a/upcoming-release-notes/3758.md b/upcoming-release-notes/3758.md new file mode 100644 index 00000000000..f3251429ec8 --- /dev/null +++ b/upcoming-release-notes/3758.md @@ -0,0 +1,6 @@ +--- +category: Enhancements +authors: [lelemm] +--- + +Added Calendar report