Skip to content

Commit

Permalink
♻️ refactored cash-flow report from victory to recharts (#2260)
Browse files Browse the repository at this point in the history
  • Loading branch information
MatissJanis authored Jan 22, 2024
1 parent a6e38ad commit a4e97e0
Show file tree
Hide file tree
Showing 8 changed files with 216 additions and 136 deletions.
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.
202 changes: 150 additions & 52 deletions packages/desktop-client/src/components/reports/graphs/CashFlowGraph.tsx
Original file line number Diff line number Diff line change
@@ -1,66 +1,164 @@
// @ts-strict-ignore
import React from 'react';
import React, { useState } 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,
amountToCurrencyNoDecimal,
} from 'loot-core/src/shared/util';

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

type CashFlowGraphProps = {
graphData: { expenses; income; balances };
const MAX_BAR_SIZE = 50;
const ANIMATION_DURATION = 1000; // in ms

type CustomTooltipProps = TooltipProps<number, 'date'> & {
isConcise: boolean;
};
export function CashFlowGraph({ graphData, isConcise }: CashFlowGraphProps) {

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

const [{ payload: data }] = payload;

return (
<Container>
{(width, height, portalHost) =>
graphData && (
<VictoryChart
scale={{ x: 'time', y: 'linear' }}
theme={chartTheme}
domainPadding={10}
width={width}
height={height}
containerComponent={
<VictoryVoronoiContainer voronoiDimension="x" />
<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={amountToCurrency(data.income)} />
<AlignedText
left="Expenses:"
right={amountToCurrency(data.expenses)}
/>
<AlignedText
left="Change:"
right={
<strong>{amountToCurrency(data.income + data.expenses)}</strong>
}
>
<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 },
}}
/>
{data.transfers !== 0 && (
<AlignedText
left="Transfers:"
right={amountToCurrency(data.transfers)}
/>
<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>
)}
<AlignedText left="Balance:" right={amountToCurrency(data.balance)} />
</div>
</div>
</div>
);
}

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

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

return (
<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');
}}
minTickGap={50}
/>
<YAxis
tick={{ fill: theme.reportsLabel }}
tickCount={8}
tickFormatter={value =>
privacyMode && !yAxisIsHovered
? '...'
: amountToCurrencyNoDecimal(value)
}
onMouseEnter={() => setYAxisIsHovered(true)}
onMouseLeave={() => setYAxisIsHovered(false)}
/>
<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}
maxBarSize={MAX_BAR_SIZE}
animationDuration={ANIMATION_DURATION}
/>
<Bar
dataKey="expenses"
stackId="a"
fill={chartTheme.colors.red}
maxBarSize={MAX_BAR_SIZE}
animationDuration={ANIMATION_DURATION}
/>
<Line
type="monotone"
dataKey="balance"
dot={false}
stroke={theme.pageTextLight}
strokeWidth={2}
animationDuration={ANIMATION_DURATION}
/>
</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
Loading

0 comments on commit a4e97e0

Please sign in to comment.