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