Refactor: extract tooltip components and clean up lint suppressions (#6721)

* Refactor: extract tooltip components and clean up lint suppressions

Extract CustomTooltip components from CrossoverGraph and NetWorthGraph
to module level to fix unstable nested components lint warnings. Also
consolidate theme file lint rule into oxlintrc.json and add proper
typing to styles object.

* Add release notes for maintenance updates addressing lint violations

* Remove style prop from CustomTooltip to prevent container layout styles from affecting tooltip

Co-authored-by: matiss <matiss@mja.lv>

* Refactor NetWorthGraph component by extracting TrendTooltip and StackedTooltip into separate functions for improved readability and maintainability. Update tooltip props to include necessary parameters for rendering. Clean up unused code and enhance tooltip styling.

* Refactor NetWorthGraph component to streamline tooltip handling

- Removed unnecessary prop passing for translation function in TrendTooltip.
- Adjusted import statements for better clarity and consistency.
- Cleaned up code to enhance readability and maintainability.

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
Matiss Janis Aboltins
2026-02-10 15:12:22 +00:00
committed by GitHub
parent 84cebed20b
commit c8aa0cf1d3
9 changed files with 319 additions and 298 deletions

View File

@@ -390,6 +390,12 @@
"typescript-paths/absolute-import": ["error", { "enableAlias": false }]
}
},
{
"files": ["packages/desktop-client/src/style/themes/*"],
"rules": {
"eslint/no-restricted-imports": "off"
}
},
// TODO: enable these
{
"files": [

View File

@@ -12,8 +12,7 @@ const shadowLarge = {
boxShadow: '0 15px 30px 0 rgba(0,0,0,0.11), 0 5px 15px 0 rgba(0,0,0,0.08)',
};
// oxlint-disable-next-line typescript/no-explicit-any
export const styles: Record<string, any> = {
export const styles: CSSProperties = {
incomeHeaderHeight: 70,
cardShadow: '0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24)',
monthRightPadding: 5,

View File

@@ -20,6 +20,119 @@ import { Container } from '@desktop-client/components/reports/Container';
import { useFormat } from '@desktop-client/hooks/useFormat';
import { usePrivacyMode } from '@desktop-client/hooks/usePrivacyMode';
type PayloadItem = {
payload: {
x: string;
investmentIncome: number | string;
expenses: number | string;
nestEgg: number | string;
adjustedExpenses?: number | string;
isProjection?: boolean;
};
};
type CustomTooltipProps = {
active?: boolean;
payload?: PayloadItem[];
};
function CustomTooltip({ active, payload }: CustomTooltipProps) {
const { t } = useTranslation();
const format = useFormat();
if (active && payload && payload.length) {
return (
<div
className={css({
zIndex: 1000,
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>{payload[0].payload.x}</strong>
{payload[0].payload.isProjection ? (
<span style={{ marginLeft: 8, opacity: 0.7 }}>
{t('(projected)')}
</span>
) : null}
</div>
<div style={{ lineHeight: 1.5 }}>
<View
className={css({
display: 'flex',
justifyContent: 'space-between',
})}
>
<div>
<Trans>Monthly investment income:</Trans>
</div>
<div>
<FinancialText>
{format(payload[0].payload.investmentIncome, 'financial')}
</FinancialText>
</div>
</View>
<View
className={css({
display: 'flex',
justifyContent: 'space-between',
})}
>
<div>
<Trans>Monthly expenses:</Trans>
</div>
<div>
<FinancialText>
{format(payload[0].payload.expenses, 'financial')}
</FinancialText>
</div>
</View>
{payload[0].payload.adjustedExpenses != null && (
<View
className={css({
display: 'flex',
justifyContent: 'space-between',
})}
>
<div>
<Trans>Target income:</Trans>
</div>
<div>
<FinancialText>
{format(payload[0].payload.adjustedExpenses, 'financial')}
</FinancialText>
</div>
</View>
)}
<View
className={css({
display: 'flex',
justifyContent: 'space-between',
})}
>
<div>
<Trans>Life savings:</Trans>
</div>
<div>
<FinancialText>
{format(payload[0].payload.nestEgg, 'financial')}
</FinancialText>
</div>
</View>
</div>
</div>
</div>
);
}
return null;
}
type CrossoverGraphProps = {
style?: CSSProperties;
graphData: {
@@ -45,7 +158,6 @@ export function CrossoverGraph({
compact = false,
showTooltip = true,
}: CrossoverGraphProps) {
const { t } = useTranslation();
const privacyMode = usePrivacyMode();
const format = useFormat();
const animationProps = useRechartsAnimation({ isAnimationActive: false });
@@ -57,116 +169,6 @@ export function CrossoverGraph({
return `${format(Math.round(tick), 'financial-no-decimals')}`;
};
type PayloadItem = {
payload: {
x: string;
investmentIncome: number | string;
expenses: number | string;
nestEgg: number | string;
adjustedExpenses?: number | string;
isProjection?: boolean;
};
};
type CustomTooltipProps = {
active?: boolean;
payload?: PayloadItem[];
};
// oxlint-disable-next-line react/no-unstable-nested-components
const CustomTooltip = ({ active, payload }: CustomTooltipProps) => {
if (active && payload && payload.length) {
return (
<div
className={css({
zIndex: 1000,
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>{payload[0].payload.x}</strong>
{payload[0].payload.isProjection ? (
<span style={{ marginLeft: 8, opacity: 0.7 }}>
{t('(projected)')}
</span>
) : null}
</div>
<div style={{ lineHeight: 1.5 }}>
<View
className={css({
display: 'flex',
justifyContent: 'space-between',
})}
>
<div>
<Trans>Monthly investment income:</Trans>
</div>
<div>
<FinancialText>
{format(payload[0].payload.investmentIncome, 'financial')}
</FinancialText>
</div>
</View>
<View
className={css({
display: 'flex',
justifyContent: 'space-between',
})}
>
<div>
<Trans>Monthly expenses:</Trans>
</div>
<div>
<FinancialText>
{format(payload[0].payload.expenses, 'financial')}
</FinancialText>
</div>
</View>
{payload[0].payload.adjustedExpenses != null && (
<View
className={css({
display: 'flex',
justifyContent: 'space-between',
})}
>
<div>
<Trans>Target income:</Trans>
</div>
<div>
<FinancialText>
{format(payload[0].payload.adjustedExpenses, 'financial')}
</FinancialText>
</div>
</View>
)}
<View
className={css({
display: 'flex',
justifyContent: 'space-between',
})}
>
<div>
<Trans>Life savings:</Trans>
</div>
<div>
<FinancialText>
{format(payload[0].payload.nestEgg, 'financial')}
</FinancialText>
</div>
</View>
</div>
</div>
</div>
);
}
};
return (
<Container
style={{

View File

@@ -28,6 +28,7 @@ import {
import { Container } from '@desktop-client/components/reports/Container';
import { numberFormatterTooltip } from '@desktop-client/components/reports/numberFormatter';
import { useFormat } from '@desktop-client/hooks/useFormat';
import type { UseFormatResult } from '@desktop-client/hooks/useFormat';
import { usePrivacyMode } from '@desktop-client/hooks/usePrivacyMode';
type NetWorthDataPoint = {
@@ -40,6 +41,188 @@ type NetWorthDataPoint = {
date: string;
} & Record<string, string | number>;
type TrendTooltipProps = TooltipContentProps<number, string> & {
style?: CSSProperties;
};
function TrendTooltip({ active, payload, style }: TrendTooltipProps) {
const { t } = useTranslation();
if (active && payload && payload.length) {
return (
<div
className={css([
{
zIndex: 1000,
pointerEvents: 'none',
borderRadius: 2,
boxShadow: '0 1px 6px rgba(0, 0, 0, .20)',
backgroundColor: theme.menuBackground,
color: theme.menuItemText,
padding: 10,
},
style,
])}
>
<div>
<div style={{ marginBottom: 10 }}>
<strong>{payload[0].payload.date}</strong>
</div>
<div style={{ lineHeight: 1.5 }}>
<AlignedText
left={t('Assets:')}
right={<FinancialText>{payload[0].payload.assets}</FinancialText>}
/>
<AlignedText
left={t('Debt:')}
right={<FinancialText>{payload[0].payload.debt}</FinancialText>}
/>
<AlignedText
left={t('Net worth:')}
right={
<FinancialText as="strong">
{payload[0].payload.networth}
</FinancialText>
}
/>
<AlignedText
left={t('Change:')}
right={<FinancialText>{payload[0].payload.change}</FinancialText>}
/>
</div>
</div>
</div>
);
}
return null;
}
type StackedTooltipProps = TooltipContentProps<number, string> & {
sortedAccounts: Array<{ id: string; name: string }>;
accounts: Array<{ id: string; name: string }>;
hoveredAccountId: string | null;
format: UseFormatResult;
};
function StackedTooltip({
active,
payload,
sortedAccounts,
accounts,
hoveredAccountId,
format,
}: StackedTooltipProps) {
if (active && payload && payload.length) {
// Calculate total from payload (visible accounts)
const total = payload.reduce(
(acc: number, p) => acc + (Number(p.value) || 0),
0,
);
const sortedPayload = [...payload].sort((a, b) => {
const indexA = sortedAccounts.findIndex(acc => acc.id === a.dataKey);
const indexB = sortedAccounts.findIndex(acc => acc.id === b.dataKey);
return indexB - indexA;
});
const hasPositive = payload.some(p => (Number(p.value) || 0) > 0);
const hasNegative = payload.some(p => (Number(p.value) || 0) < 0);
const showPercentage = !(hasPositive && hasNegative);
return (
<div
className={css([
{
zIndex: 1000,
pointerEvents: 'auto',
borderRadius: 2,
boxShadow: '0 1px 6px rgba(0, 0, 0, .20)',
backgroundColor: theme.menuBackground,
color: theme.menuItemText,
padding: 10,
fontSize: 12,
maxHeight: '80vh',
overflowY: 'auto',
},
])}
>
<div style={{ marginBottom: 10, fontWeight: 'bold' }}>
{payload[0].payload.date}
</div>
<table style={{ borderSpacing: '15px 0', marginLeft: '-15px' }}>
<thead>
<tr>
<th style={{ textAlign: 'left', paddingLeft: 15 }}>
<Trans>Account</Trans>
</th>
<th style={{ textAlign: 'right' }}>
<Trans>Value</Trans>
</th>
{showPercentage && <th style={{ textAlign: 'right' }}>%</th>}
</tr>
</thead>
<tbody>
{sortedPayload.map(entry => {
const accountId = entry.dataKey as string;
const accountName =
accounts.find(a => a.id === accountId)?.name || accountId;
const value = Number(entry.value);
const percent = total !== 0 ? (value / total) * 100 : 0;
return (
<tr key={accountId} style={{ color: entry.color }}>
<td
style={{
textAlign: 'left',
paddingLeft: 15,
textDecoration:
hoveredAccountId === accountId
? 'underline'
: undefined,
}}
>
{accountName}
</td>
<td style={{ textAlign: 'right' }}>
<span style={{ color: theme.pageText }}>
<FinancialText>
{format(value, 'financial')}
</FinancialText>
</span>
</td>
{showPercentage && (
<td style={{ textAlign: 'right' }}>
<span style={{ color: theme.pageText }}>
<FinancialText>{percent.toFixed(1)}%</FinancialText>
</span>
</td>
)}
</tr>
);
})}
<tr
style={{
fontWeight: 'bold',
borderTop: '1px solid ' + theme.tableBorder,
}}
>
<td style={{ textAlign: 'left', paddingLeft: 15, paddingTop: 5 }}>
<Trans>Total</Trans>
</td>
<td style={{ textAlign: 'right', paddingTop: 5 }}>
<FinancialText>{format(total, 'financial')}</FinancialText>
</td>
{showPercentage && (
<td style={{ textAlign: 'right', paddingTop: 5 }}>100.0%</td>
)}
</tr>
</tbody>
</table>
</div>
);
}
return null;
}
type NetWorthGraphProps = {
style?: CSSProperties;
graphData: {
@@ -64,7 +247,6 @@ export function NetWorthGraph({
interval = 'Monthly',
mode = 'trend',
}: NetWorthGraphProps) {
const { t } = useTranslation();
const privacyMode = usePrivacyMode();
const id = useId();
const format = useFormat();
@@ -151,184 +333,6 @@ export function NetWorthGraph({
.map(point => point.x);
}, [interval, graphData.data]);
// Trend Tooltip
// oxlint-disable-next-line react/no-unstable-nested-components
const TrendTooltip = ({
active,
payload,
}: TooltipContentProps<number, string>) => {
if (active && payload && payload.length) {
return (
<div
className={css([
{
zIndex: 1000,
pointerEvents: 'none',
borderRadius: 2,
boxShadow: '0 1px 6px rgba(0, 0, 0, .20)',
backgroundColor: theme.menuBackground,
color: theme.menuItemText,
padding: 10,
},
style,
])}
>
<div>
<div style={{ marginBottom: 10 }}>
<strong>{payload[0].payload.date}</strong>
</div>
<div style={{ lineHeight: 1.5 }}>
<AlignedText
left={t('Assets:')}
right={
<FinancialText>{payload[0].payload.assets}</FinancialText>
}
/>
<AlignedText
left={t('Debt:')}
right={<FinancialText>{payload[0].payload.debt}</FinancialText>}
/>
<AlignedText
left={t('Net worth:')}
right={
<FinancialText as="strong">
{payload[0].payload.networth}
</FinancialText>
}
/>
<AlignedText
left={t('Change:')}
right={
<FinancialText>{payload[0].payload.change}</FinancialText>
}
/>
</div>
</div>
</div>
);
}
return null;
};
// Stacked Tooltip
// oxlint-disable-next-line react/no-unstable-nested-components
const StackedTooltip = ({
active,
payload,
}: TooltipContentProps<number, string>) => {
if (active && payload && payload.length) {
// Calculate total from payload (visible accounts)
const total = payload.reduce(
(acc: number, p) => acc + (Number(p.value) || 0),
0,
);
const sortedPayload = [...payload].sort((a, b) => {
const indexA = sortedAccounts.findIndex(acc => acc.id === a.dataKey);
const indexB = sortedAccounts.findIndex(acc => acc.id === b.dataKey);
return indexB - indexA;
});
const hasPositive = payload.some(p => (Number(p.value) || 0) > 0);
const hasNegative = payload.some(p => (Number(p.value) || 0) < 0);
const showPercentage = !(hasPositive && hasNegative);
return (
<div
className={css([
{
zIndex: 1000,
pointerEvents: 'auto',
borderRadius: 2,
boxShadow: '0 1px 6px rgba(0, 0, 0, .20)',
backgroundColor: theme.menuBackground,
color: theme.menuItemText,
padding: 10,
fontSize: 12,
maxHeight: '80vh',
overflowY: 'auto',
},
])}
>
<div style={{ marginBottom: 10, fontWeight: 'bold' }}>
{payload[0].payload.date}
</div>
<table style={{ borderSpacing: '15px 0', marginLeft: '-15px' }}>
<thead>
<tr>
<th style={{ textAlign: 'left', paddingLeft: 15 }}>
<Trans>Account</Trans>
</th>
<th style={{ textAlign: 'right' }}>
<Trans>Value</Trans>
</th>
{showPercentage && <th style={{ textAlign: 'right' }}>%</th>}
</tr>
</thead>
<tbody>
{sortedPayload.map(entry => {
const accountId = entry.dataKey as string;
const accountName =
accounts.find(a => a.id === accountId)?.name || accountId;
const value = Number(entry.value);
const percent = total !== 0 ? (value / total) * 100 : 0;
return (
<tr key={accountId} style={{ color: entry.color }}>
<td
style={{
textAlign: 'left',
paddingLeft: 15,
textDecoration:
hoveredAccountId === accountId
? 'underline'
: undefined,
}}
>
{accountName}
</td>
<td style={{ textAlign: 'right' }}>
<span style={{ color: theme.pageText }}>
<FinancialText>
{format(value, 'financial')}
</FinancialText>
</span>
</td>
{showPercentage && (
<td style={{ textAlign: 'right' }}>
<span style={{ color: theme.pageText }}>
<FinancialText>{percent.toFixed(1)}%</FinancialText>
</span>
</td>
)}
</tr>
);
})}
<tr
style={{
fontWeight: 'bold',
borderTop: '1px solid ' + theme.tableBorder,
}}
>
<td
style={{ textAlign: 'left', paddingLeft: 15, paddingTop: 5 }}
>
<Trans>Total</Trans>
</td>
<td style={{ textAlign: 'right', paddingTop: 5 }}>
<FinancialText>{format(total, 'financial')}</FinancialText>
</td>
{showPercentage && (
<td style={{ textAlign: 'right', paddingTop: 5 }}>100.0%</td>
)}
</tr>
</tbody>
</table>
</div>
);
}
return null;
};
return (
<Container
style={{
@@ -391,14 +395,22 @@ export function NetWorthGraph({
/>
{effectiveShowTooltip && mode === 'trend' && (
<Tooltip<number, string>
content={props => <TrendTooltip {...props} />}
content={props => <TrendTooltip {...props} style={style} />}
formatter={numberFormatterTooltip}
isAnimationActive={false}
/>
)}
{effectiveShowTooltip && mode === 'stacked' && (
<Tooltip<number, string>
content={props => <StackedTooltip {...props} />}
content={props => (
<StackedTooltip
{...props}
sortedAccounts={sortedAccounts}
accounts={accounts}
hoveredAccountId={hoveredAccountId}
format={format}
/>
)}
isAnimationActive={false}
wrapperStyle={{ zIndex: 9999, pointerEvents: 'auto' }}
/>

View File

@@ -1,4 +1,3 @@
// oxlint-disable-next-line eslint/no-restricted-imports
import * as colorPalette from '@desktop-client/style/palette';
export const pageBackground = colorPalette.gray900;

View File

@@ -1,4 +1,3 @@
// oxlint-disable-next-line eslint/no-restricted-imports
import * as colorPalette from '@desktop-client/style/palette';
export const pageBackground = colorPalette.navy100;

View File

@@ -1,4 +1,3 @@
// oxlint-disable-next-line eslint/no-restricted-imports
import * as colorPalette from '@desktop-client/style/palette';
export const pageBackground = colorPalette.navy100;

View File

@@ -1,4 +1,3 @@
// oxlint-disable-next-line eslint/no-restricted-imports
import * as colorPalette from '@desktop-client/style/palette';
export const pageBackground = colorPalette.gray600;

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [MatissJanis]
---
lint: fix low-hanging fruit violations