Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

♻️ refactored cash-flow report from victory to recharts #2260

Merged
merged 12 commits into from
Jan 22, 2024
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be possible to get the thin bars again? Or just thinner?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I kinda like the fatter bars, they add vibrancy the thin ones always seemed a bit harder to read. No shaming please. :)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The wide bars feel more cluttered to me. I believe white space is part of Actual's design language and the different bars seem to go against that.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can see what you're saying, possibly some room in the middle?

For what it's worth sizes are all over the place in live, although there's always generous spacing as you noted.

One month (Skinny):
image

Three months (Skinny):
image

Six months (Thick):
image

One year (Medium):
image

All time (Skinny):
image

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
181 changes: 130 additions & 51 deletions packages/desktop-client/src/components/reports/graphs/CashFlowGraph.tsx
Original file line number Diff line number Diff line change
@@ -1,66 +1,145 @@
// @ts-strict-ignore
import React from 'react';

import * as d from 'date-fns';
import { css } from 'glamor';
import {
VictoryChart,
VictoryBar,
VictoryLine,
VictoryAxis,
VictoryVoronoiContainer,
VictoryGroup,
} from 'victory';
Bar,
CartesianGrid,
ComposedChart,
Line,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
type TooltipProps,
} from 'recharts';

import { usePrivacyMode } from 'loot-core/src/client/privacy';
import { amountToCurrency } from 'loot-core/src/shared/util';

import { theme } from '../../../style';
import { AlignedText } from '../../common/AlignedText';
import { PrivacyFilter } from '../../PrivacyFilter';
import { chartTheme } from '../chart-theme';
import { Container } from '../Container';
import { Tooltip } from '../Tooltip';

type CustomTooltipProps = TooltipProps<number, 'date'> & {
isConcise: boolean;
};

function CustomTooltip({ active, payload, isConcise }: CustomTooltipProps) {
if (!active || !payload) {
return null;
}

const [{ payload: data }] = payload;

return (
<div
className={`${css({
pointerEvents: 'none',
borderRadius: 2,
boxShadow: '0 1px 6px rgba(0, 0, 0, .20)',
backgroundColor: theme.menuBackground,
color: theme.menuItemText,
padding: 10,
})}`}
>
<div>
<div style={{ marginBottom: 10 }}>
<strong>
{d.format(data.date, isConcise ? 'MMMM yyyy' : 'MMMM dd, yyyy')}
</strong>
</div>
<div style={{ lineHeight: 1.5 }}>
<AlignedText
left="Income:"
right={
<PrivacyFilter>{amountToCurrency(data.income)}</PrivacyFilter>
}
/>
<AlignedText
left="Expenses:"
right={
<PrivacyFilter>{amountToCurrency(data.expenses)}</PrivacyFilter>
}
/>
<AlignedText
left="Change:"
right={
<strong>
<PrivacyFilter>
{amountToCurrency(data.income + data.expenses)}
</PrivacyFilter>
</strong>
}
/>
<AlignedText
left="Balance:"
right={
<PrivacyFilter>{amountToCurrency(data.balance)}</PrivacyFilter>
}
/>
</div>
</div>
</div>
);
}

type CashFlowGraphProps = {
graphData: { expenses; income; balances };
graphData: {
expenses: { x: Date; y: number }[];
income: { x: Date; y: number }[];
balances: { x: Date; y: number }[];
};
isConcise: boolean;
};
export function CashFlowGraph({ graphData, isConcise }: CashFlowGraphProps) {
const privacyMode = usePrivacyMode();

const data = graphData.expenses.map((row, idx) => ({
date: row.x,
expenses: row.y,
income: graphData.income[idx].y,
balance: graphData.balances[idx].y,
}));

return (
<Container>
{(width, height, portalHost) =>
graphData && (
<VictoryChart
scale={{ x: 'time', y: 'linear' }}
theme={chartTheme}
domainPadding={10}
width={width}
height={height}
containerComponent={
<VictoryVoronoiContainer voronoiDimension="x" />
}
>
<VictoryGroup>
<VictoryBar
data={graphData.expenses}
style={{ data: { fill: chartTheme.colors.red } }}
/>
<VictoryBar data={graphData.income} />
</VictoryGroup>
<VictoryLine
data={graphData.balances}
labelComponent={<Tooltip portalHost={portalHost} />}
labels={x => x.premadeLabel}
style={{
data: { stroke: theme.pageTextLight },
}}
/>
<VictoryAxis
// eslint-disable-next-line rulesdir/typography
tickFormat={x => d.format(x, isConcise ? "MMM ''yy" : 'MMM d')}
tickValues={graphData.balances.map(item => item.x)}
tickCount={Math.min(5, graphData.balances.length)}
offsetY={50}
/>
<VictoryAxis dependentAxis crossAxis={false} />
</VictoryChart>
)
}
</Container>
<ResponsiveContainer width="100%" height={300}>
<ComposedChart stackOffset="sign" data={data}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey="date"
tick={{ fill: theme.reportsLabel }}
tickFormatter={x => {
// eslint-disable-next-line rulesdir/typography
return d.format(x, isConcise ? "MMM ''yy" : 'MMM d');
}}
/>
<YAxis
tick={{ fill: theme.reportsLabel }}
tickFormatter={value => (privacyMode ? '...' : value)}
/>
<Tooltip
labelFormatter={x => {
// eslint-disable-next-line rulesdir/typography
return d.format(x, isConcise ? "MMM ''yy" : 'MMM d');
}}
content={<CustomTooltip isConcise={isConcise} />}
isAnimationActive={false}
/>

<ReferenceLine y={0} stroke="#000" />
<Bar dataKey="income" stackId="a" fill={chartTheme.colors.blue} />
<Bar dataKey="expenses" stackId="a" fill={chartTheme.colors.red} />
<Line
type="monotone"
dataKey="balance"
dot={false}
stroke={theme.pageTextLight}
strokeWidth={2}
/>
</ComposedChart>
</ResponsiveContainer>
);
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React, { useState, useMemo, useCallback } from 'react';

import { VictoryBar, VictoryGroup, VictoryVoronoiContainer } from 'victory';
import { Bar, BarChart, ResponsiveContainer } from 'recharts';

import * as monthUtils from 'loot-core/src/shared/months';
import { integerToCurrency } from 'loot-core/src/shared/util';
Expand All @@ -11,14 +11,37 @@ import { View } from '../../common/View';
import { PrivacyFilter } from '../../PrivacyFilter';
import { Change } from '../Change';
import { chartTheme } from '../chart-theme';
import { Container } from '../Container';
import { DateRange } from '../DateRange';
import { LoadingIndicator } from '../LoadingIndicator';
import { ReportCard } from '../ReportCard';
import { simpleCashFlow } from '../spreadsheets/cash-flow-spreadsheet';
import { Tooltip } from '../Tooltip';
import { useReport } from '../useReport';

function CustomLabel({ value, name, position, ...props }) {
return (
<>
<text
{...props}
dy={10}
dx={position === 'right' ? 20 : -5}
textAnchor={position === 'right' ? 'start' : 'end'}
fill={theme.tableText}
>
{name}
</text>
<text
{...props}
dy={26}
dx={position === 'right' ? 20 : -4}
textAnchor={position === 'right' ? 'start' : 'end'}
fill={theme.tableText}
>
<PrivacyFilter>{integerToCurrency(value)}</PrivacyFilter>
</text>
</>
);
}

export function CashFlowCard() {
const end = monthUtils.currentDay();
const start = monthUtils.currentMonth() + '-01';
Expand All @@ -31,7 +54,7 @@ export function CashFlowCard() {
const onCardHoverEnd = useCallback(() => setIsCardHovered(false));

const { graphData } = data || {};
const expense = -(graphData?.expense || 0);
const expenses = -(graphData?.expense || 0);
const income = graphData?.income || 0;

return (
Expand All @@ -55,7 +78,7 @@ export function CashFlowCard() {
<View style={{ textAlign: 'right' }}>
<PrivacyFilter activationFilters={[!isCardHovered]}>
<Change
amount={income - expense}
amount={income - expenses}
style={{ color: theme.tableText, fontWeight: 300 }}
/>
</PrivacyFilter>
Expand All @@ -64,84 +87,33 @@ export function CashFlowCard() {
</View>

{data ? (
<Container style={{ height: 'auto', flex: 1 }}>
{(width, height, portalHost) => (
<VictoryGroup
colorScale={[chartTheme.colors.blue, chartTheme.colors.red]}
width={100}
height={height}
theme={chartTheme}
domain={{
x: [0, 100],
y: [0, Math.max(income, expense, 100)],
}}
containerComponent={
<VictoryVoronoiContainer voronoiDimension="x" />
}
labelComponent={
<Tooltip
portalHost={portalHost}
offsetX={(width - 100) / 2}
offsetY={y => (y + 40 > height ? height - 40 : y)}
light={true}
forceActive={true}
style={{
padding: 0,
}}
/>
}
padding={{
top: 0,
bottom: 0,
left: 0,
right: 0,
}}
>
<VictoryBar
barWidth={13}
data={[
{
x: 30,
y: Math.max(income, 5),
premadeLabel: (
<View style={{ textAlign: 'right' }}>
Income
<View>
<PrivacyFilter activationFilters={[!isCardHovered]}>
{integerToCurrency(income)}
</PrivacyFilter>
</View>
</View>
),
labelPosition: 'left',
},
]}
labels={d => d.premadeLabel}
/>
<VictoryBar
barWidth={13}
data={[
{
x: 60,
y: Math.max(expense, 5),
premadeLabel: (
<View>
Expenses
<View>
<PrivacyFilter activationFilters={[!isCardHovered]}>
{integerToCurrency(expense)}
</PrivacyFilter>
</View>
</View>
),
labelPosition: 'right',
},
]}
labels={d => d.premadeLabel}
/>
</VictoryGroup>
)}
</Container>
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={[
{
income,
expenses,
},
]}
margin={{
top: 10,
bottom: 0,
}}
>
<Bar
dataKey="income"
fill={chartTheme.colors.blue}
barSize={14}
label={<CustomLabel name="Income" position="left" />}
/>
<Bar
dataKey="expenses"
fill={chartTheme.colors.red}
barSize={14}
label={<CustomLabel name="Expenses" position="right" />}
/>
</BarChart>
</ResponsiveContainer>
) : (
<LoadingIndicator />
)}
Expand Down
6 changes: 6 additions & 0 deletions upcoming-release-notes/2260.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [MatissJanis]
---

Refactored cash flow report from `victory` to `recharts`