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

Add progress bars for categories and goals #4371

Open
wants to merge 12 commits into
base: master
Choose a base branch
from
8 changes: 8 additions & 0 deletions packages/desktop-client/src/components/budget/BudgetTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@ export function BudgetTable(props: BudgetTableProps) {
const [showHiddenCategories, setShowHiddenCategoriesPef] = useLocalPref(
'budget.showHiddenCategories',
);
const [showProgressBars, setShowProgressBars] = useLocalPref(
'budget.showProgressBars',
);
const [editing, setEditing] = useState<{ id: string; cell: string } | null>(
null,
);
Expand Down Expand Up @@ -223,6 +226,10 @@ export function BudgetTable(props: BudgetTableProps) {
onCollapse(categoryGroups.map(g => g.id));
};

const toggleProgressBars = () => {
setShowProgressBars(!showProgressBars);
};

return (
<View
data-testid="budget-table"
Expand Down Expand Up @@ -271,6 +278,7 @@ export function BudgetTable(props: BudgetTableProps) {
toggleHiddenCategories={toggleHiddenCategories}
expandAllCategories={expandAllCategories}
collapseAllCategories={collapseAllCategories}
toggleProgressBars={toggleProgressBars}
/>
<View
style={{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,15 @@ type BudgetTotalsProps = {
toggleHiddenCategories: () => void;
expandAllCategories: () => void;
collapseAllCategories: () => void;
toggleProgressBars: () => void;
};

export const BudgetTotals = memo(function BudgetTotals({
MonthComponent,
toggleHiddenCategories,
expandAllCategories,
collapseAllCategories,
toggleProgressBars,
}: BudgetTotalsProps) {
const { t } = useTranslation();
const [menuOpen, setMenuOpen] = useState(false);
Expand Down Expand Up @@ -85,6 +87,8 @@ export const BudgetTotals = memo(function BudgetTotals({
onMenuSelect={type => {
if (type === 'toggle-visibility') {
toggleHiddenCategories();
} else if (type === 'toggle-progress-bars') {
toggleProgressBars();
} else if (type === 'expandAllCategories') {
expandAllCategories();
} else if (type === 'collapseAllCategories') {
Expand All @@ -97,6 +101,10 @@ export const BudgetTotals = memo(function BudgetTotals({
name: 'toggle-visibility',
text: t('Toggle hidden categories'),
},
{
name: 'toggle-progress-bars',
text: t('Toggle progress bars'),
},
{
name: 'expandAllCategories',
text: t('Expand all'),
Expand Down
242 changes: 242 additions & 0 deletions packages/desktop-client/src/components/budget/ProgressBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Use translations for category labels consistently.

The component imports and uses the translation hook for the tooltip titles, but the category labels in the getColorBars function are hardcoded in English.

Update the getColorBars function to accept the translation function and use it for category labels:

function getColorBars(
  budgeted: number,
  spent: number,
  balance: number,
  goal: number,
  isLongGoal: boolean,
+  t: (key: string) => string,
) {
  // ... existing code ...
  
  // Instead of:
  leftBar.category = 'Remaining';
  // Use:
+  leftBar.category = t('Remaining');
  
  // Apply this pattern to all category assignments in the function
}

// And in the component:
const [freshLeftBar, freshRightBar] = getColorBars(
  budgeted,
  spent,
  balance,
  goal,
  isLongGoal,
+  t,
);

Also applies to: 124-124


import { View } from '@actual-app/components/view';

import { envelopeBudget } from 'loot-core/client/queries';
import * as monthUtils from 'loot-core/shared/months';
import { integerToCurrency } from 'loot-core/shared/util';

import { type CategoryEntity } from '../../../../loot-core/src/types/models';
import { theme } from '../../style';
import { useSheetValue } from '../spreadsheet/useSheetValue';

import { useEnvelopeBudget } from './envelope/EnvelopeBudgetContext';

const ColorDefUnderBudgetRemaining = theme.reportsGreen;
const ColorDefUnderBudgetSpent = theme.reportsGray;
const ColorDefOverBudgetSpent = theme.reportsGray;
const ColorDefOverBudgetOverSpent = theme.reportsRed;
const ColorDefGoalRemaining = theme.reportsLabel;
const ColorDefGoalSaved = theme.reportsBlue;
const ColorDefEmpty = ''; // No color for default

class ColorBar {
color: string;
width: number;
category: string;
rawValue: string;

constructor(
color: string = ColorDefEmpty,
width: number = 50,
category: string = '',
rawValue: string = '',
) {
this.color = color;
this.width = width;
this.category = category;
this.rawValue = rawValue;
}
}

/** Generate what the color status bar should look like based on our numbers and the category type */
function getColorBars(
budgeted: number,
spent: number,
balance: number,
goal: number,
isLongGoal: boolean,
) {
const leftBar = new ColorBar();
const rightBar = new ColorBar();

if (isLongGoal) {
// We have a long-term #goal set. These take visual precedence over a monthly template goal, even if both exist
if (balance < 0) {
// Standard goal with a non-negative balance
const toGoal = -1 * balance + goal;
leftBar.width = Math.min(Math.round((goal / toGoal) * 100), 100);
rightBar.width = 100 - leftBar.width;
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Fix calculation edge cases to prevent NaN or division by zero.

Several calculations in the component could result in NaN or division by zero errors when inputs are zero.

Add safeguards to prevent division by zero:

// Line 60 (and similar lines)
-leftBar.width = Math.min(Math.round((goal / toGoal) * 100), 100);
+leftBar.width = toGoal === 0 ? 100 : Math.min(Math.round((goal / toGoal) * 100), 100);

// Line 72
-leftBar.width = Math.min(Math.round((balance / goal) * 100), 100);
+leftBar.width = goal === 0 ? 0 : Math.min(Math.round((balance / goal) * 100), 100);

// Line 87-89
const overage = -1 * spent - budgeted;
const total = budgeted + overage;
-leftBar.width = Math.round((budgeted / total) * 100);
+leftBar.width = total === 0 ? 0 : Math.round((budgeted / total) * 100);

// Line 101-103
const remaining = budgeted - -1 * spent;
-leftBar.width = Math.round((remaining / budgeted) * 100);
+leftBar.width = budgeted === 0 ? 0 : Math.round((remaining / budgeted) * 100);

Also applies to: 72-72, 87-89, 101-103


leftBar.color = ColorDefGoalRemaining;
rightBar.color = ColorDefOverBudgetOverSpent;

leftBar.rawValue = integerToCurrency(toGoal);
rightBar.rawValue = integerToCurrency(balance);

leftBar.category = 'Remaining';
rightBar.category = 'Overspent';
} else {
// Standard goal with a non-negative balance
leftBar.width = Math.min(Math.round((balance / goal) * 100), 100);
rightBar.width = 100 - leftBar.width;

leftBar.color = ColorDefGoalSaved;
rightBar.color = ColorDefGoalRemaining;

leftBar.rawValue = integerToCurrency(balance);
rightBar.rawValue = integerToCurrency(goal - balance);

leftBar.category = 'Saved';
rightBar.category = 'Remaining';
}
} else if (spent * -1 >= budgeted) {
// We overspent (or are exactly at budget)
const overage = -1 * spent - budgeted;
const total = budgeted + overage;
leftBar.width = Math.round((budgeted / total) * 100);
rightBar.width = 100 - leftBar.width;

leftBar.color = ColorDefOverBudgetSpent;
rightBar.color = ColorDefOverBudgetOverSpent;

leftBar.rawValue = integerToCurrency(budgeted);
rightBar.rawValue = integerToCurrency(overage);

leftBar.category = 'Budgeted';
rightBar.category = 'Overspent';
} else {
// We are under budget
const remaining = budgeted - -1 * spent;
leftBar.width = Math.round((remaining / budgeted) * 100);
rightBar.width = 100 - leftBar.width;

leftBar.color = ColorDefUnderBudgetRemaining;
rightBar.color = ColorDefUnderBudgetSpent;

leftBar.rawValue = integerToCurrency(remaining);
rightBar.rawValue = integerToCurrency(spent);

leftBar.category = 'Remaining';
rightBar.category = 'Spent';
}

return [leftBar, rightBar];
}

type ProgressBarProps = {
month: string;
category: CategoryEntity;
};

export function ProgressBar({ month, category }: ProgressBarProps) {
const { t } = useTranslation();
const [leftBar, setLeftBar] = useState<ColorBar>(new ColorBar());
const [rightBar, setRightBar] = useState<ColorBar>(new ColorBar());
const { hoveredMonth } = useEnvelopeBudget();
const isCurrentMonth = monthUtils.isCurrentMonth(month);

// The budgeted amount for this month
const budgeted = Number(
useSheetValue<'envelope-budget', 'budget'>(
envelopeBudget.catBudgeted(category.id),
),
);
// The amount spent this month
const spent = Number(
useSheetValue<'envelope-budget', 'sum-amount'>(
envelopeBudget.catSumAmount(category.id),
),
);
/* Goal is either the template value or the goal value, so use it in conjunction with long-goal. */
const goal = Number(
useSheetValue<'envelope-budget', 'goal'>(
envelopeBudget.catGoal(category.id),
),
);
// If a #goal for the category exists.
const longGoal = Number(
useSheetValue<'envelope-budget', 'long-goal'>(
envelopeBudget.catLongGoal(category.id),
),
);
const isLongGoal = Boolean(longGoal && longGoal > 0);
// The current category balance based on the budgeted, spent, and previous balance amounts
const balance = Number(
useSheetValue<'envelope-budget', 'leftover'>(
envelopeBudget.catBalance(category.id),
),
);

useEffect(() => {
const setColorBars = async () => {
setTimeout(() => {
// Don't show visuals for income categories
if (category.is_income) {
return null;
}
const [freshLeftBar, freshRightBar] = getColorBars(
budgeted,
spent,
balance,
goal,
isLongGoal,
);
setLeftBar(freshLeftBar);
setRightBar(freshRightBar);
}, 100);
};

setColorBars();
}, [category, budgeted, spent, balance, goal, isLongGoal, hoveredMonth]);
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Remove unnecessary setTimeout and fix dependency array.

The useEffect contains a setTimeout that doesn't seem to serve a necessary purpose and could cause delayed rendering or UI flickering.

useEffect(() => {
-  const setColorBars = async () => {
-    setTimeout(() => {
      // Don't show visuals for income categories
      if (category.is_income) {
-        return null;
+        return;
      }
      const [freshLeftBar, freshRightBar] = getColorBars(
        budgeted,
        spent,
        balance,
        goal,
        isLongGoal,
      );
      setLeftBar(freshLeftBar);
      setRightBar(freshRightBar);
-    }, 100);
-  };
-
-  setColorBars();
-}, [category, budgeted, spent, balance, goal, isLongGoal, hoveredMonth]);
+}, [category, budgeted, spent, balance, goal, isLongGoal]);

Note: hoveredMonth is in the dependency array but isn't used within the effect. Remove it unless you intend to use it for calculations.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
useEffect(() => {
const setColorBars = async () => {
setTimeout(() => {
// Don't show visuals for income categories
if (category.is_income) {
return null;
}
const [freshLeftBar, freshRightBar] = getColorBars(
budgeted,
spent,
balance,
goal,
isLongGoal,
);
setLeftBar(freshLeftBar);
setRightBar(freshRightBar);
}, 100);
};
setColorBars();
}, [category, budgeted, spent, balance, goal, isLongGoal, hoveredMonth]);
useEffect(() => {
// Don't show visuals for income categories
if (category.is_income) {
return;
}
const [freshLeftBar, freshRightBar] = getColorBars(
budgeted,
spent,
balance,
goal,
isLongGoal,
);
setLeftBar(freshLeftBar);
setRightBar(freshRightBar);
}, [category, budgeted, spent, balance, goal, isLongGoal]);


const barHeight = 3;
const borderRadius = 30;

let barOpacity = '0.5'; // By default, all categories in all months with some activity are partly visible
if (isCurrentMonth) {
barOpacity = '1'; // By default, categories in the current month are fully visible
}
if (isCurrentMonth && hoveredMonth && hoveredMonth !== month) {
barOpacity = '0.5'; // If a non-current month is hovered over, lower visibility for the current month
} else if (hoveredMonth === month) {
barOpacity = '1'; // If a non-current month is hovered over, raise that month to fully visible
}

return (
<View
style={{
display: 'flex',
position: 'absolute',
right: 0,
bottom: 0,
marginBottom: 1,
width: '50%',
opacity: barOpacity,
transition: 'opacity 0.25s',
}}
>
{/* Left side of the bar */}
<View
style={{
height: barHeight,
backgroundColor: leftBar.color,
width: `${leftBar.width}%`,
position: 'absolute',
bottom: 0,
left: 0,
borderTopLeftRadius: borderRadius,
borderBottomLeftRadius: borderRadius,
transition: 'width 0.5s ease-in-out',
}}
title={`${t(leftBar.category)}: ${leftBar.rawValue}`}
/>
{/* Right side of the bar */}
<View
style={{
height: barHeight,
backgroundColor: rightBar.color,
width: `${rightBar.width}%`,
position: 'absolute',
bottom: 0,
right: 0,
borderTopRightRadius: borderRadius,
borderBottomRightRadius: borderRadius,
transition: 'width 0.5s ease-in-out',
}}
title={`${t(rightBar.category)}: ${rightBar.rawValue}`}
/>
</View>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
} from 'loot-core/types/models';

import { useContextMenu } from '../../../hooks/useContextMenu';
import { useLocalPref } from '../../../hooks/useLocalPref';
import { useUndo } from '../../../hooks/useUndo';
import { SvgCheveronDown } from '../../../icons/v1';
import { theme } from '../../../style';
Expand All @@ -32,10 +33,12 @@ import { useSheetName } from '../../spreadsheet/useSheetName';
import { useSheetValue } from '../../spreadsheet/useSheetValue';
import { Row, Field, SheetCell, type SheetCellProps } from '../../table';
import { BalanceWithCarryover } from '../BalanceWithCarryover';
import { ProgressBar } from '../ProgressBar';
import { makeAmountGrey } from '../util';

import { BalanceMovementMenu } from './BalanceMovementMenu';
import { BudgetMenu } from './BudgetMenu';
import { useEnvelopeBudget } from './EnvelopeBudgetContext';

export function useEnvelopeSheetName<
FieldName extends SheetFields<'envelope-budget'>,
Expand Down Expand Up @@ -142,6 +145,7 @@ export const ExpenseGroupMonth = memo(function ExpenseGroupMonth({
group,
}: ExpenseGroupMonthProps) {
const { id } = group;
const { setHoveredMonth } = useEnvelopeBudget();

return (
<View
Expand All @@ -152,6 +156,8 @@ export const ExpenseGroupMonth = memo(function ExpenseGroupMonth({
? theme.budgetHeaderCurrentMonth
: theme.budgetHeaderOtherMonth,
}}
onMouseOver={() => setHoveredMonth(month)}
onMouseOut={() => setHoveredMonth('')}
>
<EnvelopeSheetCell
name="budgeted"
Expand Down Expand Up @@ -232,6 +238,8 @@ export const ExpenseCategoryMonth = memo(function ExpenseCategoryMonth({
};

const { showUndoNotification } = useUndo();
const [useProgressBars] = useLocalPref('budget.showProgressBars');
const { setHoveredMonth } = useEnvelopeBudget();

return (
<View
Expand All @@ -249,6 +257,8 @@ export const ExpenseCategoryMonth = memo(function ExpenseCategoryMonth({
opacity: 1,
},
}}
onMouseOver={() => setHoveredMonth(month)}
onMouseOut={() => setHoveredMonth('')}
>
<View
ref={budgetMenuTriggerRef}
Expand Down Expand Up @@ -385,6 +395,7 @@ export const ExpenseCategoryMonth = memo(function ExpenseCategoryMonth({
});
}}
/>
{useProgressBars && <ProgressBar month={month} category={category} />}
</View>
<Field name="spent" width="flex" style={{ textAlign: 'right' }}>
<span
Expand Down
Loading