diff --git a/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-cash-flow-graph-and-checks-visuals-1-chromium-linux.png b/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-cash-flow-graph-and-checks-visuals-1-chromium-linux.png index 7ee3aa5afa31bb300e22542905fba7cbc6769e66..48901464a065658245c7a3ed25b6d42e5bf08cca 100644 Binary files a/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-cash-flow-graph-and-checks-visuals-1-chromium-linux.png and b/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-cash-flow-graph-and-checks-visuals-1-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-cash-flow-graph-and-checks-visuals-2-chromium-linux.png b/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-cash-flow-graph-and-checks-visuals-2-chromium-linux.png index e2547a23cbe856ad07b8012a0a6fd8587a3d6496..aa2e7d20f4b9276db56d69cf4acd7e63b7a7bf1e 100644 Binary files a/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-cash-flow-graph-and-checks-visuals-2-chromium-linux.png and b/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-cash-flow-graph-and-checks-visuals-2-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-cash-flow-graph-and-checks-visuals-3-chromium-linux.png b/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-cash-flow-graph-and-checks-visuals-3-chromium-linux.png index 3ca0d4d3aff6d21623295272e55185ab199c2f09..42b40b2ca0a8df0fc972ddb49d3a68654724abd1 100644 Binary files a/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-cash-flow-graph-and-checks-visuals-3-chromium-linux.png and b/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-cash-flow-graph-and-checks-visuals-3-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-net-worth-graph-and-checks-visuals-1-chromium-linux.png b/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-net-worth-graph-and-checks-visuals-1-chromium-linux.png index 6e65aea4e2462823f73f189281ab10337b84d671..cbfaf0d8c686f84dd41f7c072e8c0cb0fbbb2647 100644 Binary files a/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-net-worth-graph-and-checks-visuals-1-chromium-linux.png and b/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-net-worth-graph-and-checks-visuals-1-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-net-worth-graph-and-checks-visuals-2-chromium-linux.png b/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-net-worth-graph-and-checks-visuals-2-chromium-linux.png index cd3672d33309f39b09630637836daa75babc2f06..22495266223823edbd448818b8440bba7a8c4430 100644 Binary files a/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-net-worth-graph-and-checks-visuals-2-chromium-linux.png and b/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-net-worth-graph-and-checks-visuals-2-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-net-worth-graph-and-checks-visuals-3-chromium-linux.png b/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-net-worth-graph-and-checks-visuals-3-chromium-linux.png index 386eaeb60e1b7ead9505ba877d432c233f804614..480c10477584c972a94a1b63df2b6dbc026f2312 100644 Binary files a/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-net-worth-graph-and-checks-visuals-3-chromium-linux.png and b/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-net-worth-graph-and-checks-visuals-3-chromium-linux.png differ diff --git a/packages/desktop-client/src/components/common/Button2.tsx b/packages/desktop-client/src/components/common/Button2.tsx index a48c1bb8dc3ebfa31fbf575370428e60905f4edb..0fb4860fff6c98eca467771f6d2ab9ec9c81a4fc 100644 --- a/packages/desktop-client/src/components/common/Button2.tsx +++ b/packages/desktop-client/src/components/common/Button2.tsx @@ -1,4 +1,10 @@ -import React, { forwardRef, type ComponentPropsWithoutRef } from 'react'; +import React, { + forwardRef, + type ComponentPropsWithoutRef, + type ComponentType, + type ReactNode, + type SVGProps, +} from 'react'; import { type ButtonRenderProps as ReactAriaButtonRenderProps, Button as ReactAriaButton, @@ -121,13 +127,21 @@ const _getActiveStyles = ( type ButtonProps = ComponentPropsWithoutRef<typeof ReactAriaButton> & { variant?: ButtonVariant; bounce?: boolean; + Icon?: ComponentType<SVGProps<SVGSVGElement>>; + children?: ReactNode; }; type ButtonVariant = 'normal' | 'primary' | 'bare' | 'menu' | 'menuSelected'; export const Button = forwardRef<HTMLButtonElement, ButtonProps>( (props, ref) => { - const { children, variant = 'normal', bounce = true, ...restProps } = props; + const { + children, + variant = 'normal', + bounce = true, + Icon, + ...restProps + } = props; const variantWithDisabled: ButtonVariant | `${ButtonVariant}Disabled` = props.isDisabled ? `${variant}Disabled` : variant; @@ -161,6 +175,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>( ...styles.smallText, ...(renderProps.isDisabled ? {} : { ':hover': hoveredStyle }), ...(renderProps.isDisabled ? {} : { ':active': activeStyle }), + ...(Icon ? { paddingLeft: 0 } : {}), }), ); @@ -177,6 +192,9 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>( `${renderProps.defaultClassName} ${defaultButtonClassName(renderProps)} ${buttonClassName(renderProps)}` } > + {Icon && ( + <Icon style={{ height: 15, paddingLeft: 5, paddingRight: 3 }} /> + )} {children} </ReactAriaButton> ); @@ -203,34 +221,30 @@ export const ButtonWithLoading = forwardRef< ...(typeof style === 'function' ? style(buttonRenderProps) : style), })} > - {renderProps => ( - <> - {isLoading && ( - <View - style={{ - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - alignItems: 'center', - justifyContent: 'center', - }} - > - <AnimatedLoading style={{ width: 20, height: 20 }} /> - </View> - )} - <View - style={{ - opacity: isLoading ? 0 : 1, - flexDirection: 'row', - alignItems: 'center', - }} - > - {typeof children === 'function' ? children(renderProps) : children} - </View> - </> + {isLoading && ( + <View + style={{ + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + alignItems: 'center', + justifyContent: 'center', + }} + > + <AnimatedLoading style={{ width: 20, height: 20 }} /> + </View> )} + <View + style={{ + opacity: isLoading ? 0 : 1, + flexDirection: 'row', + alignItems: 'center', + }} + > + {children} + </View> </Button> ); }); diff --git a/packages/desktop-client/src/components/reports/Header.jsx b/packages/desktop-client/src/components/reports/Header.tsx similarity index 67% rename from packages/desktop-client/src/components/reports/Header.jsx rename to packages/desktop-client/src/components/reports/Header.tsx index 1f8e4bc78d146b10ca1e963a5c4c5c6e3691d394..3414909928b576ba4be5b1052bb2875d39075959 100644 --- a/packages/desktop-client/src/components/reports/Header.jsx +++ b/packages/desktop-client/src/components/reports/Header.tsx @@ -1,7 +1,14 @@ +import { type ComponentProps, type ReactNode } from 'react'; import { useLocation } from 'react-router-dom'; import * as monthUtils from 'loot-core/src/shared/months'; +import { + type RuleConditionEntity, + type TimeFrame, +} from 'loot-core/types/models'; +import { useFeatureFlag } from '../../hooks/useFeatureFlag'; +import { SvgPause, SvgPlay } from '../../icons/v1'; import { useResponsive } from '../../ResponsiveProvider'; import { Button } from '../common/Button2'; import { Select } from '../common/Select'; @@ -16,9 +23,32 @@ import { validateStart, } from './reportRanges'; +type HeaderProps = { + start: TimeFrame['start']; + end: TimeFrame['end']; + mode?: TimeFrame['mode']; + show1Month?: boolean; + allMonths: Array<{ name: string; pretty: string }>; + onChangeDates: ( + start: TimeFrame['start'], + end: TimeFrame['end'], + mode: TimeFrame['mode'], + ) => void; + filters?: RuleConditionEntity[]; + conditionsOp: 'and' | 'or'; + onApply?: (conditions: RuleConditionEntity) => void; + onUpdateFilter: ComponentProps<typeof AppliedFilters>['onUpdate']; + onDeleteFilter: ComponentProps<typeof AppliedFilters>['onDelete']; + onConditionsOpChange: ComponentProps< + typeof AppliedFilters + >['onConditionsOpChange']; + children?: ReactNode; +}; + export function Header({ start, end, + mode, show1Month, allMonths, onChangeDates, @@ -28,9 +58,9 @@ export function Header({ onUpdateFilter, onDeleteFilter, onConditionsOpChange, - headerPrefixItems, children, -}) { +}: HeaderProps) { + const isDashboardsFeatureEnabled = useFeatureFlag('dashboards'); const location = useLocation(); const path = location.pathname; const { isNarrowWidth } = useResponsive(); @@ -52,7 +82,21 @@ export function Header({ gap: 15, }} > - {headerPrefixItems} + {isDashboardsFeatureEnabled && mode && ( + <Button + variant={mode === 'static' ? 'normal' : 'primary'} + onPress={() => + onChangeDates( + start, + end, + mode === 'static' ? 'sliding-window' : 'static', + ) + } + Icon={mode === 'static' ? SvgPause : SvgPlay} + > + {mode === 'static' ? 'Paused' : 'Live'} + </Button> + )} <View style={{ @@ -90,13 +134,6 @@ export function Header({ options={allMonths.map(({ name, pretty }) => [name, pretty])} buttonStyle={{ marginRight: 10 }} /> - {filters && ( - <FilterButton - compact={isNarrowWidth} - onApply={onApply} - type="accounts" - /> - )} </View> <View @@ -143,18 +180,34 @@ export function Header({ > All Time </Button> + + {filters && ( + <FilterButton + compact={isNarrowWidth} + onApply={onApply} + hover={false} + exclude={undefined} + /> + )} </View> - {children || <View style={{ flex: 1 }} />} + + {children ? ( + <View + style={{ + flex: 1, + flexDirection: 'row', + justifyContent: 'flex-end', + }} + > + {children} + </View> + ) : ( + <View style={{ flex: 1 }} /> + )} </View> )} {filters && filters.length > 0 && ( - <View - style={{ marginTop: 5 }} - spacing={2} - direction="row" - justify="flex-start" - align="flex-start" - > + <View style={{ marginTop: 5 }}> <AppliedFilters conditions={filters} onUpdate={onUpdateFilter} diff --git a/packages/desktop-client/src/components/reports/Overview.tsx b/packages/desktop-client/src/components/reports/Overview.tsx index c775077666f51b99ac7cb798ff2f43c69f73d833..f234cf8122049c0a4bac8b04171fbd3f56d95120 100644 --- a/packages/desktop-client/src/components/reports/Overview.tsx +++ b/packages/desktop-client/src/components/reports/Overview.tsx @@ -528,6 +528,7 @@ export function Overview() { <div key={item.i}> {item.type === 'net-worth-card' ? ( <NetWorthCard + widgetId={item.i} isEditing={isEditing} accounts={accounts} meta={item.meta} diff --git a/packages/desktop-client/src/components/reports/ReportRouter.tsx b/packages/desktop-client/src/components/reports/ReportRouter.tsx index 2fadf9f39b0b4e4273c8f603399dbda1d574a783..795658e1aaa2cf2f1f5af83e7779d66f0980bfaf 100644 --- a/packages/desktop-client/src/components/reports/ReportRouter.tsx +++ b/packages/desktop-client/src/components/reports/ReportRouter.tsx @@ -12,6 +12,7 @@ export function ReportRouter() { <Routes> <Route path="/" element={<Overview />} /> <Route path="/net-worth" element={<NetWorth />} /> + <Route path="/net-worth/:id" element={<NetWorth />} /> <Route path="/cash-flow" element={<CashFlow />} /> <Route path="/custom" element={<CustomReport />} /> <Route path="/spending" element={<Spending />} /> diff --git a/packages/desktop-client/src/components/reports/ReportSidebar.tsx b/packages/desktop-client/src/components/reports/ReportSidebar.tsx index 0424fba5b6062e7d598e2610845e2e671382db23..0c07319a88d20e19fc33549e2a5ac95223d495f4 100644 --- a/packages/desktop-client/src/components/reports/ReportSidebar.tsx +++ b/packages/desktop-client/src/components/reports/ReportSidebar.tsx @@ -3,6 +3,7 @@ import React, { useMemo, useRef, useState } from 'react'; import * as monthUtils from 'loot-core/src/shared/months'; import { type CategoryEntity } from 'loot-core/types/models/category'; import { type CategoryGroupEntity } from 'loot-core/types/models/category-group'; +import { type TimeFrame } from 'loot-core/types/models/dashboard'; import { type CustomReportEntity } from 'loot-core/types/models/reports'; import { type SyncedPrefs } from 'loot-core/types/prefs'; @@ -44,7 +45,11 @@ type ReportSidebarProps = { setShowUncategorized: (value: boolean) => void; setIncludeCurrentInterval: (value: boolean) => void; setSelectedCategories: (value: CategoryEntity[]) => void; - onChangeDates: (dateStart: string, dateEnd: string) => void; + onChangeDates: ( + dateStart: string, + dateEnd: string, + mode: TimeFrame['mode'], + ) => void; onReportChange: ({ savedReport, type, @@ -419,6 +424,7 @@ export function ReportSidebar({ onChangeDates( customReportItems.startDate, customReportItems.endDate, + 'static', ); }} > diff --git a/packages/desktop-client/src/components/reports/getLiveRange.ts b/packages/desktop-client/src/components/reports/getLiveRange.ts index 22b2eef43551100a547d0438287a8bc4f2d8cf60..f76a2d7b09ab104addbbabe5bf1ad72d0ef11e5d 100644 --- a/packages/desktop-client/src/components/reports/getLiveRange.ts +++ b/packages/desktop-client/src/components/reports/getLiveRange.ts @@ -1,4 +1,5 @@ import * as monthUtils from 'loot-core/src/shared/months'; +import { type TimeFrame } from 'loot-core/types/models'; import { type SyncedPrefs } from 'loot-core/types/prefs'; import { ReportOptions } from './ReportOptions'; @@ -9,7 +10,7 @@ export function getLiveRange( earliestTransaction: string, includeCurrentInterval: boolean, firstDayOfWeekIdx?: SyncedPrefs['firstDayOfWeekIdx'], -): [string, string] { +): [string, string, TimeFrame['mode']] { let dateStart = earliestTransaction; let dateEnd = monthUtils.currentDay(); const rangeName = ReportOptions.dateRangeMap.get(cond); @@ -50,5 +51,5 @@ export function getLiveRange( } } - return [dateStart, dateEnd]; + return [dateStart, dateEnd, 'sliding-window']; } diff --git a/packages/desktop-client/src/components/reports/reportRanges.ts b/packages/desktop-client/src/components/reports/reportRanges.ts index d3f634ef138bcad7768c7477f74497a7dbf73f8e..631b56665c95fc531197adf8fb935905d1a8acd1 100644 --- a/packages/desktop-client/src/components/reports/reportRanges.ts +++ b/packages/desktop-client/src/components/reports/reportRanges.ts @@ -1,4 +1,5 @@ import * as monthUtils from 'loot-core/src/shared/months'; +import { type TimeFrame } from 'loot-core/types/models'; import { type SyncedPrefs } from 'loot-core/types/prefs'; export function validateStart( @@ -7,9 +8,9 @@ export function validateStart( end: string, interval?: string, firstDayOfWeekIdx?: SyncedPrefs['firstDayOfWeekIdx'], -): [string, string] { - let addDays; - let dateStart; +): [string, string, TimeFrame['mode']] { + let addDays: number; + let dateStart: string; switch (interval) { case 'Monthly': dateStart = start + '-01'; @@ -47,9 +48,9 @@ export function validateEnd( end: string, interval?: string, firstDayOfWeekIdx?: SyncedPrefs['firstDayOfWeekIdx'], -): [string, string] { - let subDays; - let dateEnd; +): [string, string, TimeFrame['mode']] { + let subDays: number; + let dateEnd: string; switch (interval) { case 'Monthly': dateEnd = monthUtils.getMonthEnd(end + '-01'); @@ -98,8 +99,8 @@ function boundedRange( end: string, interval?: string, firstDayOfWeekIdx?: SyncedPrefs['firstDayOfWeekIdx'], -): [string, string] { - let latest; +): [string, string, 'static'] { + let latest: string; switch (interval) { case 'Daily': latest = monthUtils.currentDay(); @@ -124,7 +125,7 @@ function boundedRange( if (start < earliest) { start = earliest; } - return [start, end]; + return [start, end, 'static']; } export function getSpecificRange( @@ -150,16 +151,33 @@ export function getSpecificRange( ); } - return [dateStart, dateEnd]; + return [dateStart, dateEnd, 'static']; } export function getFullRange(start: string) { const end = monthUtils.currentMonth(); - return [start, end]; + return [start, end, 'full'] as const; } export function getLatestRange(offset: number) { const end = monthUtils.currentMonth(); const start = monthUtils.subMonths(end, offset); - return [start, end]; + return [start, end, 'sliding-window'] as const; +} + +export function calculateTimeRange( + { start, end, mode }: TimeFrame = { + start: monthUtils.subMonths(monthUtils.currentMonth(), 5), + end: monthUtils.currentMonth(), + mode: 'sliding-window', + }, +) { + if (mode === 'full') { + return getFullRange(start); + } + if (mode === 'sliding-window') { + return getLatestRange(monthUtils.differenceInCalendarMonths(end, start)); + } + + return [start, end, 'static']; } diff --git a/packages/desktop-client/src/components/reports/reports/CashFlow.tsx b/packages/desktop-client/src/components/reports/reports/CashFlow.tsx index 88f6733ef4f12afc925b21438faae174e13d410f..0ac91664750d3ea5d58603c3895b518b3cb88097 100644 --- a/packages/desktop-client/src/components/reports/reports/CashFlow.tsx +++ b/packages/desktop-client/src/components/reports/reports/CashFlow.tsx @@ -134,19 +134,10 @@ export function CashFlow() { onDeleteFilter={onDeleteFilter} conditionsOp={conditionsOp} onConditionsOpChange={onConditionsOpChange} - headerPrefixItems={undefined} > - <View - style={{ - flex: 1, - flexDirection: 'row', - justifyContent: 'flex-end', - }} - > - <Button onPress={() => setShowBalance(state => !state)}> - {showBalance ? 'Hide balance' : 'Show balance'} - </Button> - </View> + <Button onPress={() => setShowBalance(state => !state)}> + {showBalance ? 'Hide balance' : 'Show balance'} + </Button> </Header> <View style={{ diff --git a/packages/desktop-client/src/components/reports/reports/NetWorth.jsx b/packages/desktop-client/src/components/reports/reports/NetWorth.jsx index 4a3d0f9afbf530eedadc05dec1a5e8e9740e90da..73cdfec8952cf171682b00ce527ea87d53004296 100644 --- a/packages/desktop-client/src/components/reports/reports/NetWorth.jsx +++ b/packages/desktop-client/src/components/reports/reports/NetWorth.jsx @@ -1,7 +1,12 @@ import React, { useState, useEffect, useMemo } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import { useDispatch } from 'react-redux'; +import { useParams } from 'react-router-dom'; import * as d from 'date-fns'; +import { addNotification } from 'loot-core/src/client/actions'; +import { useWidget } from 'loot-core/src/client/data-hooks/widget'; import { send } from 'loot-core/src/platform/client/fetch'; import * as monthUtils from 'loot-core/src/shared/months'; import { integerToCurrency } from 'loot-core/src/shared/util'; @@ -11,6 +16,7 @@ import { useFilters } from '../../../hooks/useFilters'; import { useNavigate } from '../../../hooks/useNavigate'; import { useResponsive } from '../../../ResponsiveProvider'; import { theme, styles } from '../../../style'; +import { Button } from '../../common/Button2'; import { Paragraph } from '../../common/Paragraph'; import { View } from '../../common/View'; import { MobileBackButton } from '../../mobile/MobileBackButton'; @@ -19,11 +25,27 @@ import { PrivacyFilter } from '../../PrivacyFilter'; import { Change } from '../Change'; import { NetWorthGraph } from '../graphs/NetWorthGraph'; import { Header } from '../Header'; +import { LoadingIndicator } from '../LoadingIndicator'; +import { calculateTimeRange } from '../reportRanges'; import { createSpreadsheet as netWorthSpreadsheet } from '../spreadsheets/net-worth-spreadsheet'; import { useReport } from '../useReport'; import { fromDateRepr } from '../util'; export function NetWorth() { + const params = useParams(); + const { data: widget, isLoading } = useWidget(params.id ?? ''); + + if (isLoading) { + return <LoadingIndicator />; + } + + return <NetWorthInner widget={widget} />; +} + +function NetWorthInner({ widget }) { + const dispatch = useDispatch(); + const { t } = useTranslation(); + const accounts = useAccounts(); const { conditions, @@ -33,19 +55,22 @@ export function NetWorth() { onDelete: onDeleteFilter, onUpdate: onUpdateFilter, onConditionsOpChange, - } = useFilters(); + } = useFilters(widget?.meta?.conditions, widget?.meta?.conditionsOp); const [allMonths, setAllMonths] = useState(null); - const [start, setStart] = useState( - monthUtils.subMonths(monthUtils.currentMonth(), 5), + + const [initialStart, initialEnd, initialMode] = calculateTimeRange( + widget?.meta?.timeFrame, ); - const [end, setEnd] = useState(monthUtils.currentMonth()); + const [start, setStart] = useState(initialStart); + const [end, setEnd] = useState(initialEnd); + const [mode, setMode] = useState(initialMode); - const params = useMemo( + const reportParams = useMemo( () => netWorthSpreadsheet(start, end, accounts, conditions, conditionsOp), [start, end, accounts, conditions, conditionsOp], ); - const data = useReport('net_worth', params); + const data = useReport('net_worth', reportParams); useEffect(() => { async function run() { const trans = await send('get-earliest-transaction'); @@ -75,14 +100,39 @@ export function NetWorth() { run(); }, []); - function onChangeDates(start, end) { + function onChangeDates(start, end, mode) { setStart(start); setEnd(end); + setMode(mode); + } + + async function onSaveWidget() { + 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 navigate = useNavigate(); const { isNarrowWidth } = useResponsive(); + const title = widget?.meta?.name ?? t('Net Worth'); + if (!allMonths || !data) { return null; } @@ -92,13 +142,13 @@ export function NetWorth() { header={ isNarrowWidth ? ( <MobilePageHeader - title="Net Worth" + title={title} leftContent={ <MobileBackButton onClick={() => navigate('/reports')} /> } /> ) : ( - <PageHeader title="Net Worth" /> + <PageHeader title={title} /> ) } padding={0} @@ -107,6 +157,7 @@ export function NetWorth() { allMonths={allMonths} start={start} end={end} + mode={mode} onChangeDates={onChangeDates} filters={conditions} saved={saved} @@ -115,7 +166,13 @@ export function NetWorth() { onDeleteFilter={onDeleteFilter} conditionsOp={conditionsOp} onConditionsOpChange={onConditionsOpChange} - /> + > + {widget && ( + <Button variant="primary" onPress={onSaveWidget}> + <Trans>Save widget</Trans> + </Button> + )} + </Header> <View style={{ @@ -155,16 +212,18 @@ export function NetWorth() { /> <View style={{ marginTop: 30, userSelect: 'none' }}> - <Paragraph> - <strong>How is net worth calculated?</strong> - </Paragraph> - <Paragraph> - Net worth shows the balance of all accounts over time, including all - of your investments. Your “net worth†is considered to be the amount - you’d have if you sold all your assets and paid off as much debt as - possible. If you hover over the graph, you can also see the amount - of assets and debt individually. - </Paragraph> + <Trans> + <Paragraph> + <strong>How is net worth calculated?</strong> + </Paragraph> + <Paragraph> + Net worth shows the balance of all accounts over time, including + all of your investments. Your “net worth†is considered to be the + amount you’d have if you sold all your assets and paid off as much + debt as possible. If you hover over the graph, you can also see + the amount of assets and debt individually. + </Paragraph> + </Trans> </View> </View> </Page> diff --git a/packages/desktop-client/src/components/reports/reports/NetWorthCard.tsx b/packages/desktop-client/src/components/reports/reports/NetWorthCard.tsx index 5ba5ff082e1fcc1ebfa7982cf94df80a3358b3f8..3c1ed32402c12044d5992cb0f454a5e44f8318a7 100644 --- a/packages/desktop-client/src/components/reports/reports/NetWorthCard.tsx +++ b/packages/desktop-client/src/components/reports/reports/NetWorthCard.tsx @@ -1,13 +1,13 @@ import React, { useState, useMemo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import * as monthUtils from 'loot-core/src/shared/months'; import { integerToCurrency } from 'loot-core/src/shared/util'; import { type AccountEntity, type NetWorthWidget, } from 'loot-core/src/types/models'; +import { useFeatureFlag } from '../../../hooks/useFeatureFlag'; import { useResponsive } from '../../../ResponsiveProvider'; import { styles } from '../../../style'; import { Block } from '../../common/Block'; @@ -19,10 +19,12 @@ import { NetWorthGraph } from '../graphs/NetWorthGraph'; import { LoadingIndicator } from '../LoadingIndicator'; import { ReportCard } from '../ReportCard'; import { ReportCardName } from '../ReportCardName'; +import { calculateTimeRange } from '../reportRanges'; import { createSpreadsheet as netWorthSpreadsheet } from '../spreadsheets/net-worth-spreadsheet'; import { useReport } from '../useReport'; type NetWorthCardProps = { + widgetId: string; isEditing?: boolean; accounts: AccountEntity[]; meta?: NetWorthWidget['meta']; @@ -31,33 +33,45 @@ type NetWorthCardProps = { }; export function NetWorthCard({ + widgetId, isEditing, accounts, meta = {}, onMetaChange, onRemove, }: NetWorthCardProps) { + const isDashboardsFeatureEnabled = useFeatureFlag('dashboards'); const { t } = useTranslation(); const { isNarrowWidth } = useResponsive(); const [nameMenuOpen, setNameMenuOpen] = useState(false); - const end = monthUtils.currentMonth(); - const start = monthUtils.subMonths(end, 5); + const [start, end] = calculateTimeRange(meta?.timeFrame); const [isCardHovered, setIsCardHovered] = useState(false); const onCardHover = useCallback(() => setIsCardHovered(true), []); const onCardHoverEnd = useCallback(() => setIsCardHovered(false), []); const params = useMemo( - () => netWorthSpreadsheet(start, end, accounts), - [start, end, accounts], + () => + netWorthSpreadsheet( + start, + end, + accounts, + meta?.conditions, + meta?.conditionsOp, + ), + [start, end, accounts, meta?.conditions, meta?.conditionsOp], ); const data = useReport('net_worth', params); return ( <ReportCard isEditing={isEditing} - to="/reports/net-worth" + to={ + isDashboardsFeatureEnabled + ? `/reports/net-worth/${widgetId}` + : '/reports/net-worth' + } menuItems={[ { name: 'rename', diff --git a/packages/desktop-client/src/hooks/useFilters.ts b/packages/desktop-client/src/hooks/useFilters.ts index 7fb50a1d74678628e35cac722518cfdb6f1ee858..1bd75ac2525ec40f116478c7a281727ced3dc379 100644 --- a/packages/desktop-client/src/hooks/useFilters.ts +++ b/packages/desktop-client/src/hooks/useFilters.ts @@ -4,9 +4,12 @@ import { type RuleConditionEntity } from 'loot-core/types/models/rule'; export function useFilters<T extends RuleConditionEntity>( initialConditions: T[] = [], + initialConditionsOp: 'and' | 'or' = 'and', ) { const [conditions, setConditions] = useState<T[]>(initialConditions); - const [conditionsOp, setConditionsOp] = useState<'and' | 'or'>('and'); + const [conditionsOp, setConditionsOp] = useState<'and' | 'or'>( + initialConditionsOp, + ); const [saved, setSaved] = useState<T[] | null>(null); const onApply = useCallback( diff --git a/packages/loot-core/src/client/data-hooks/widget.ts b/packages/loot-core/src/client/data-hooks/widget.ts new file mode 100644 index 0000000000000000000000000000000000000000..367957b4f0eb5ff2c28e11e54f8b76bd88c19829 --- /dev/null +++ b/packages/loot-core/src/client/data-hooks/widget.ts @@ -0,0 +1,20 @@ +import { useMemo } from 'react'; + +import { q } from '../../shared/query'; +import { type Widget } from '../../types/models'; +import { useLiveQuery } from '../query-hooks'; + +export function useWidget(id: string) { + const data = useLiveQuery<Widget[]>( + () => q('dashboard').filter({ id }).select('*'), + [id], + ); + + return useMemo( + () => ({ + isLoading: data === null, + data: data?.[0], + }), + [data], + ); +} diff --git a/packages/loot-core/src/types/models/dashboard.d.ts b/packages/loot-core/src/types/models/dashboard.d.ts index 6dc20360f4aac4ed6d2656c1e40b1477b8b248e3..40b00633324c57641f658cdaeaf87aa6534790b8 100644 --- a/packages/loot-core/src/types/models/dashboard.d.ts +++ b/packages/loot-core/src/types/models/dashboard.d.ts @@ -1,4 +1,11 @@ import { type CustomReportEntity } from './reports'; +import { type RuleConditionEntity } from './rule'; + +export type TimeFrame = { + start: string; + end: string; + mode: 'sliding-window' | 'static' | 'full'; +}; type AbstractWidget< T extends string, @@ -16,7 +23,12 @@ type AbstractWidget< export type NetWorthWidget = AbstractWidget< 'net-worth-card', - { name?: string } | null + { + name?: string; + conditions?: RuleConditionEntity[]; + conditionsOp?: 'and' | 'or'; + timeFrame?: TimeFrame; + } | null >; export type CashFlowWidget = AbstractWidget< 'cash-flow-card', diff --git a/upcoming-release-notes/3364.md b/upcoming-release-notes/3364.md new file mode 100644 index 0000000000000000000000000000000000000000..4eed1fc45c0898dfb271fa2be71f07c1ec0e246c --- /dev/null +++ b/upcoming-release-notes/3364.md @@ -0,0 +1,6 @@ +--- +category: Enhancements +authors: [MatissJanis, carkom] +--- + +Dashboards: ability to save filters & time-range on net-worth widgets.