diff --git a/packages/desktop-client/src/components/budget/BudgetTable.tsx b/packages/desktop-client/src/components/budget/BudgetTable.tsx
index bcdf1719277..19b54720d2f 100644
--- a/packages/desktop-client/src/components/budget/BudgetTable.tsx
+++ b/packages/desktop-client/src/components/budget/BudgetTable.tsx
@@ -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,
);
@@ -223,6 +226,10 @@ export function BudgetTable(props: BudgetTableProps) {
onCollapse(categoryGroups.map(g => g.id));
};
+ const toggleProgressBars = () => {
+ setShowProgressBars(!showProgressBars);
+ };
+
return (
void;
expandAllCategories: () => void;
collapseAllCategories: () => void;
+ toggleProgressBars: () => void;
};
export const BudgetTotals = memo(function BudgetTotals({
@@ -25,6 +26,7 @@ export const BudgetTotals = memo(function BudgetTotals({
toggleHiddenCategories,
expandAllCategories,
collapseAllCategories,
+ toggleProgressBars,
}: BudgetTotalsProps) {
const { t } = useTranslation();
const [menuOpen, setMenuOpen] = useState(false);
@@ -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') {
@@ -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'),
diff --git a/packages/desktop-client/src/components/budget/ProgressBar.tsx b/packages/desktop-client/src/components/budget/ProgressBar.tsx
new file mode 100644
index 00000000000..d0aa32ab65b
--- /dev/null
+++ b/packages/desktop-client/src/components/budget/ProgressBar.tsx
@@ -0,0 +1,238 @@
+import { useState, useEffect } from 'react';
+import { useTranslation } from 'react-i18next';
+
+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 (budgeted === 0) {
+ // If we have nothing budgeted, don't show a bar for this
+ } else if (isLongGoal) {
+ // We have a long-term #goal set. These take visual precedence over a monthly template goal, even if both exist
+ const toGoal = goal - balance;
+
+ if (toGoal <= 0) {
+ // If over the goal, consider it complete
+ leftBar.width = 100;
+ } else if (balance < 0) {
+ // If balance is < 0, show no progress
+ leftBar.width = 0;
+ } else {
+ // Otherwise, standard ratio with a positive balance and a positive amount to reach the goal
+ leftBar.width = bound(Math.round((balance / goal) * 100), 0, 100);
+ }
+ rightBar.width = 100 - leftBar.width;
+
+ leftBar.color = ColorDefGoalSaved;
+ rightBar.color = ColorDefGoalRemaining;
+
+ leftBar.rawValue = integerToCurrency(balance);
+ rightBar.rawValue = integerToCurrency(toGoal);
+
+ leftBar.category = 'Saved';
+ rightBar.category = 'Remaining';
+ } else if (spent * -1 >= budgeted) {
+ // We overspent (or are exactly at budget)
+ const overage = budgeted + spent;
+ const total = budgeted + Math.abs(overage);
+ leftBar.width = bound(Math.round((budgeted / total) * 100), 0, 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 + spent;
+ leftBar.width = bound(Math.round((remaining / budgeted) * 100), 0, 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];
+}
+
+function bound(val: number, min: number, max: number): number {
+ return Math.max(min, Math.min(val, max));
+}
+
+type ProgressBarProps = {
+ month: string;
+ category: CategoryEntity;
+};
+
+export function ProgressBar({ month, category }: ProgressBarProps) {
+ const { t } = useTranslation();
+ const [leftBar, setLeftBar] = useState(new ColorBar());
+ const [rightBar, setRightBar] = useState(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(() => {
+ // 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 BAR_HEIGHT = 3;
+ const BORDER_RADIUS = 30;
+ const PARTIAL_OPACITY = '0.4';
+ const FULL_OPACITY = '1';
+
+ let barOpacity = PARTIAL_OPACITY; // By default, all categories in all months with some activity are partly visible
+ if (isCurrentMonth) {
+ barOpacity = FULL_OPACITY; // By default, categories in the current month are fully visible
+ }
+ if (isCurrentMonth && hoveredMonth && hoveredMonth !== month) {
+ barOpacity = PARTIAL_OPACITY; // If a non-current month is hovered over, lower visibility for the current month
+ } else if (hoveredMonth === month) {
+ barOpacity = FULL_OPACITY; // If a non-current month is hovered over, raise that month to fully visible
+ }
+
+ return (
+
+ {/* Left side of the bar */}
+
+ {/* Right side of the bar */}
+
+
+ );
+}
diff --git a/packages/desktop-client/src/components/budget/envelope/EnvelopeBudgetComponents.tsx b/packages/desktop-client/src/components/budget/envelope/EnvelopeBudgetComponents.tsx
index 758522f5e50..af729003eaf 100644
--- a/packages/desktop-client/src/components/budget/envelope/EnvelopeBudgetComponents.tsx
+++ b/packages/desktop-client/src/components/budget/envelope/EnvelopeBudgetComponents.tsx
@@ -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';
@@ -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'>,
@@ -142,6 +145,7 @@ export const ExpenseGroupMonth = memo(function ExpenseGroupMonth({
group,
}: ExpenseGroupMonthProps) {
const { id } = group;
+ const { setHoveredMonth } = useEnvelopeBudget();
return (
setHoveredMonth(month)}
+ onMouseOut={() => setHoveredMonth('')}
>
setHoveredMonth(month)}
+ onMouseOut={() => setHoveredMonth('')}
>
+ {useProgressBars && }
void;
onToggleSummaryCollapse: () => void;
currentMonth: string;
+ setHoveredMonth: (month: string) => void;
+ hoveredMonth: string;
};
const EnvelopeBudgetContext = createContext({
@@ -20,6 +22,10 @@ const EnvelopeBudgetContext = createContext({
);
},
currentMonth: 'unknown',
+ hoveredMonth: 'unknown',
+ setHoveredMonth: () => {
+ throw new Error('Unitialised context method called: setHoveredMonth');
+ },
});
type EnvelopeBudgetProviderProps = Omit<
@@ -33,6 +39,8 @@ export function EnvelopeBudgetProvider({
onBudgetAction,
onToggleSummaryCollapse,
children,
+ hoveredMonth,
+ setHoveredMonth,
}: EnvelopeBudgetProviderProps) {
const currentMonth = monthUtils.currentMonth();
@@ -43,6 +51,8 @@ export function EnvelopeBudgetProvider({
summaryCollapsed,
onBudgetAction,
onToggleSummaryCollapse,
+ hoveredMonth,
+ setHoveredMonth,
}}
>
{children}
diff --git a/packages/desktop-client/src/components/budget/index.tsx b/packages/desktop-client/src/components/budget/index.tsx
index 040ea5cb69e..68ae049c5dd 100644
--- a/packages/desktop-client/src/components/budget/index.tsx
+++ b/packages/desktop-client/src/components/budget/index.tsx
@@ -84,6 +84,7 @@ function BudgetInner(props: BudgetInnerProps) {
const maxMonths = maxMonthsPref || 1;
const [initialized, setInitialized] = useState(false);
const { grouped: categoryGroups } = useCategories();
+ const [hoveredMonth, setHoveredMonth] = useState();
useEffect(() => {
async function run() {
@@ -364,6 +365,8 @@ function BudgetInner(props: BudgetInnerProps) {
summaryCollapsed={summaryCollapsed}
onBudgetAction={onBudgetAction}
onToggleSummaryCollapse={onToggleCollapse}
+ setHoveredMonth={setHoveredMonth}
+ hoveredMonth={hoveredMonth}
>
;
export type Theme = 'light' | 'dark' | 'auto' | 'midnight' | 'development';
diff --git a/upcoming-release-notes/4371.md b/upcoming-release-notes/4371.md
new file mode 100644
index 00000000000..ac5475b4969
--- /dev/null
+++ b/upcoming-release-notes/4371.md
@@ -0,0 +1,6 @@
+---
+category: Features
+authors: [tbuist]
+---
+
+Add progress bars for categories and goals
\ No newline at end of file