diff --git a/packages/component-library/src/Tooltip.tsx b/packages/component-library/src/Tooltip.tsx index 3d87c13a757..57aae9bd2b7 100644 --- a/packages/component-library/src/Tooltip.tsx +++ b/packages/component-library/src/Tooltip.tsx @@ -10,6 +10,7 @@ type TooltipProps = Partial> & { content: ReactNode; triggerProps?: Partial>; disablePointerEvents?: boolean; + wrapperStyle?: React.CSSProperties; }; export const Tooltip = ({ @@ -17,6 +18,7 @@ export const Tooltip = ({ content, triggerProps = {}, disablePointerEvents = false, + wrapperStyle, ...props }: TooltipProps) => { const triggerRef = useRef(null); @@ -54,7 +56,12 @@ export const Tooltip = ({ return ( { - const id = await send('category-create', { + return send('category-create', { name, groupId, isIncome, hidden: isHidden, }); - return id; }, onSuccess: () => invalidateQueries(queryClient), onError: error => { @@ -304,8 +303,7 @@ export function useCreateCategoryGroupMutation() { return useMutation({ mutationFn: async ({ name }: CreateCategoryGroupPayload) => { - const id = await send('category-group-create', { name }); - return id; + return send('category-group-create', { name }); }, onSuccess: () => invalidateQueries(queryClient), onError: error => { @@ -649,11 +647,20 @@ type ApplyBudgetActionPayload = args: { category: CategoryEntity['id']; }; + } + | { + type: 'set-single-category-template'; + month: string; + args: { + category: CategoryEntity['id']; + amount: number | null; + }; }; export function useBudgetActions() { const dispatch = useDispatch(); const { t } = useTranslation(); + const queryClient = useQueryClient(); return useMutation({ mutationFn: async ({ month, type, args }: ApplyBudgetActionPayload) => { @@ -778,6 +785,13 @@ export function useBudgetActions() { category: args.category, }); return null; + case 'set-single-category-template': + await send('budget/set-single-category-template', { + categoryId: args.category, + amount: args.amount, + }); + invalidateQueries(queryClient); + return null; default: throw new Error(`Unknown budget action type: ${type}`); } diff --git a/packages/desktop-client/src/components/Titlebar.tsx b/packages/desktop-client/src/components/Titlebar.tsx index c82a96a8ee6..b9b08df9d06 100644 --- a/packages/desktop-client/src/components/Titlebar.tsx +++ b/packages/desktop-client/src/components/Titlebar.tsx @@ -1,11 +1,11 @@ import React, { useEffect, useState } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; import { Trans, useTranslation } from 'react-i18next'; -import { Route, Routes, useLocation } from 'react-router'; +import { Route, Routes, useLocation, useMatch } from 'react-router'; import { Button } from '@actual-app/components/button'; import { useResponsive } from '@actual-app/components/hooks/useResponsive'; -import { SvgArrowLeft } from '@actual-app/components/icons/v1'; +import { SvgArrowLeft, SvgChartBar } from '@actual-app/components/icons/v1'; import { SvgAlertTriangle, SvgNavigationMenu, @@ -35,6 +35,7 @@ import { useSidebar } from './sidebar/SidebarProvider'; import { ThemeSelector } from './ThemeSelector'; import { sync } from '@desktop-client/app/appSlice'; +import { useFeatureFlag } from '@desktop-client/hooks/useFeatureFlag'; import { useGlobalPref } from '@desktop-client/hooks/useGlobalPref'; import { useIsTestEnv } from '@desktop-client/hooks/useIsTestEnv'; import { useMetadataPref } from '@desktop-client/hooks/useMetadataPref'; @@ -106,6 +107,33 @@ function PrivacyButton({ style }: PrivacyButtonProps) { ); } +type StatusBarsButtonProps = { + style?: CSSProperties; +}; + +function StatusBarsButton({ style }: StatusBarsButtonProps) { + const { t } = useTranslation(); + const progressBarEnabled = useFeatureFlag('goalTemplatesEnabled'); + const [showProgressBarsPref, setShowProgressBarsPref] = + useGlobalPref('showProgressBars'); + const showProgressBars = showProgressBarsPref !== false; + + if (!progressBarEnabled) return null; + + return ( + + ); +} + type SyncButtonProps = { style?: CSSProperties; isMobile?: boolean; @@ -279,6 +307,7 @@ export function Titlebar({ style }: TitlebarProps) { const serverURL = useServerURL(); const [floatingSidebar] = useGlobalPref('floatingSidebar'); const isTestEnv = useIsTestEnv(); + const isBudgetPage = useMatch('/budget'); return isNarrowWidth ? null : ( {isDevelopmentEnvironment() && !isTestEnv && } + {isBudgetPage && } {serverURL ? : null} diff --git a/packages/desktop-client/src/components/budget/BalanceWithCarryover.tsx b/packages/desktop-client/src/components/budget/BalanceWithCarryover.tsx index 37efa00397e..5055ec84ccc 100644 --- a/packages/desktop-client/src/components/budget/BalanceWithCarryover.tsx +++ b/packages/desktop-client/src/components/budget/BalanceWithCarryover.tsx @@ -95,6 +95,8 @@ type BalanceWithCarryoverProps = Omit< goal: Binding<'envelope-budget' | 'tracking-budget', 'goal'>; budgeted: Binding<'envelope-budget' | 'tracking-budget', 'budget'>; longGoal: Binding<'envelope-budget' | 'tracking-budget', 'long-goal'>; + goalOverride?: number | null; + balanceColorOverride?: string | null; isDisabled?: boolean; shouldInlineGoalStatus?: boolean; CarryoverIndicator?: ComponentType; @@ -107,6 +109,8 @@ export function BalanceWithCarryover({ goal, budgeted, longGoal, + goalOverride, + balanceColorOverride, isDisabled, shouldInlineGoalStatus, CarryoverIndicator: CarryoverIndicatorComponent = CarryoverIndicator, @@ -121,23 +125,35 @@ export function BalanceWithCarryover({ const budgetedValue = useSheetValue(budgeted); const longGoalValue = useSheetValue(longGoal); const isGoalTemplatesEnabled = useFeatureFlag('goalTemplatesEnabled'); + const resolvedGoalValue = + goalOverride != null && isGoalTemplatesEnabled ? goalOverride : goalValue; const getBalanceAmountStyle = useCallback( - (balanceValue: number) => - makeBalanceAmountStyle( + (balanceValue: number) => { + if (balanceColorOverride && balanceValue >= 0) { + return { color: balanceColorOverride }; + } + return makeBalanceAmountStyle( balanceValue, - isGoalTemplatesEnabled ? goalValue : null, + isGoalTemplatesEnabled ? resolvedGoalValue : null, longGoalValue === 1 ? balanceValue : budgetedValue, - ), - [budgetedValue, goalValue, isGoalTemplatesEnabled, longGoalValue], + ); + }, + [ + balanceColorOverride, + budgetedValue, + resolvedGoalValue, + isGoalTemplatesEnabled, + longGoalValue, + ], ); const format = useFormat(); const getDifferenceToGoal = useCallback( (balanceValue: number) => longGoalValue === 1 - ? balanceValue - goalValue - : budgetedValue - goalValue, - [budgetedValue, goalValue, longGoalValue], + ? balanceValue - resolvedGoalValue + : budgetedValue - resolvedGoalValue, + [budgetedValue, resolvedGoalValue, longGoalValue], ); const getDefaultClassName = useCallback( diff --git a/packages/desktop-client/src/components/budget/BudgetCategories.tsx b/packages/desktop-client/src/components/budget/BudgetCategories.tsx index fd21888cd95..a06e9a873c0 100644 --- a/packages/desktop-client/src/components/budget/BudgetCategories.tsx +++ b/packages/desktop-client/src/components/budget/BudgetCategories.tsx @@ -242,9 +242,11 @@ export const BudgetCategories = memo( style={{ marginBottom: 10, backgroundColor: theme.budgetCurrentMonth, // match budget colors, not generic table colors. - overflow: 'hidden', boxShadow: styles.cardShadow, borderRadius: '0 0 4px 4px', + borderColor: theme.tableBorder, + borderBottomWidth: 1, + borderBottomColor: theme.tableBorder, flex: 1, }} > @@ -315,6 +317,7 @@ export const BudgetCategories = memo( categoryGroup={item.group} editingCell={editingCell} dragState={dragState} + isLast={idx === items.length - 1} onEditName={onEditName} onEditMonth={onEditMonth} onSave={_onSaveCategory} diff --git a/packages/desktop-client/src/components/budget/BudgetTable.tsx b/packages/desktop-client/src/components/budget/BudgetTable.tsx index 8c727035aab..b4b9a632854 100644 --- a/packages/desktop-client/src/components/budget/BudgetTable.tsx +++ b/packages/desktop-client/src/components/budget/BudgetTable.tsx @@ -16,6 +16,7 @@ import { BudgetSummaries } from './BudgetSummaries'; import { BudgetTotals } from './BudgetTotals'; import { MonthsProvider } from './MonthsContext'; import type { MonthBounds } from './MonthsContext'; +import { TemplateGoalProvider } from './TemplateGoalContext'; import { findSortDown, findSortUp, @@ -26,6 +27,7 @@ import { import type { DropPosition } from '@desktop-client/components/sort'; import { SchedulesProvider } from '@desktop-client/hooks/useCachedSchedules'; import { useCategories } from '@desktop-client/hooks/useCategories'; +import { useFeatureFlag } from '@desktop-client/hooks/useFeatureFlag'; import { useGlobalPref } from '@desktop-client/hooks/useGlobalPref'; import { useLocalPref } from '@desktop-client/hooks/useLocalPref'; @@ -80,6 +82,7 @@ export function BudgetTable(props: BudgetTableProps) { const [showHiddenCategories, setShowHiddenCategoriesPef] = useLocalPref( 'budget.showHiddenCategories', ); + const isGoalTemplatesEnabled = useFeatureFlag('goalTemplatesEnabled'); const [categoryExpandedStatePref] = useGlobalPref('categoryExpandedState'); const categoryExpandedState = categoryExpandedStatePref ?? 0; const [editing, setEditing] = useState<{ id: string; cell: string } | null>( @@ -172,7 +175,6 @@ export function BudgetTable(props: BudgetTableProps) { if ('isGroup' in next && next.isGroup) { nextIdx += dir; - continue; } else if ( type === 'tracking' || ('is_income' in next && !next.is_income) @@ -257,52 +259,58 @@ export function BudgetTable(props: BudgetTableProps) { - - - + - - - + + + + + - - + + ); } diff --git a/packages/desktop-client/src/components/budget/CategoryProgressBar.test.tsx b/packages/desktop-client/src/components/budget/CategoryProgressBar.test.tsx new file mode 100644 index 00000000000..90d8e5f34fd --- /dev/null +++ b/packages/desktop-client/src/components/budget/CategoryProgressBar.test.tsx @@ -0,0 +1,254 @@ +import { render } from '@testing-library/react'; +import { vi } from 'vitest'; + +import { + CategoryProgressBar, + computeCategoryProgress, +} from './CategoryProgressBar'; + +import { useFeatureFlag } from '@desktop-client/hooks/useFeatureFlag'; + +vi.mock('@desktop-client/hooks/useFeatureFlag', () => ({ + useFeatureFlag: vi.fn(), +})); +vi.mock('@desktop-client/hooks/useFormat', () => ({ + useFormat: () => (v: number) => String(v), +})); + +describe('computeCategoryProgress', () => { + describe('under budget scenarios', () => { + it('should return within-budget when spent is less than assigned', () => { + const result = computeCategoryProgress({ + assigned: 10000, + activity: -3000, + balance: 7000, + }); + + expect(result.state).toBe('funded'); + expect(result.baselineAmount).toBe(10000); + expect(result.spentRatio).toBeCloseTo(0.3); + expect(result.overflowRatio).toBe(0); + }); + + it('should return within-budget with 0% when no spending', () => { + const result = computeCategoryProgress({ + assigned: 10000, + activity: 0, + balance: 10000, + }); + + expect(result.state).toBe('funded'); + expect(result.baselineAmount).toBe(10000); + expect(result.spentRatio).toBe(0); + expect(result.overflowRatio).toBe(0); + }); + }); + + describe('on budget scenario', () => { + it('should return within-budget when spent equals assigned', () => { + const result = computeCategoryProgress({ + assigned: 10000, + activity: -10000, + balance: 0, + }); + + expect(result.state).toBe('funded'); + expect(result.baselineAmount).toBe(10000); + expect(result.spentRatio).toBe(1.0); + expect(result.overflowRatio).toBe(0); + }); + }); + + describe('overspent scenarios', () => { + it('should return over-budget when spent exceeds assigned', () => { + const result = computeCategoryProgress({ + assigned: 10000, + activity: -12000, + balance: -2000, + }); + + expect(result.state).toBe('over-budget'); + expect(result.baselineAmount).toBe(10000); + expect(result.spentRatio).toBe(1.0); + expect(result.overflowRatio).toBeCloseTo(0.2); + }); + + it('should handle large overflow (3x budget)', () => { + const result = computeCategoryProgress({ + assigned: 10000, + activity: -40000, + balance: -30000, + }); + + expect(result.state).toBe('over-budget'); + expect(result.spentRatio).toBe(1.0); + expect(result.overflowRatio).toBeCloseTo(3.0); + }); + }); + + describe('no budget scenarios', () => { + it('should return within-budget when assigned=0 and spent=0', () => { + const result = computeCategoryProgress({ + assigned: 0, + activity: 0, + balance: 0, + }); + + expect(result.state).toBe('funded'); + expect(result.baselineAmount).toBe(0); + expect(result.spentRatio).toBe(0); + expect(result.overflowRatio).toBe(0); + }); + + it('should return over-budget when assigned=0 but spent>0', () => { + const result = computeCategoryProgress({ + assigned: 0, + activity: -500, + balance: -500, + }); + + expect(result.state).toBe('over-budget'); + expect(result.baselineAmount).toBe(500); + expect(result.spentRatio).toBe(1.0); + expect(result.overflowRatio).toBe(0); + }); + }); + + describe('edge cases', () => { + it('should handle negative assigned amount', () => { + const result = computeCategoryProgress({ + assigned: -10000, + activity: 0, + balance: -10000, + }); + + expect(result.state).toBe('funded'); + expect(result.baselineAmount).toBe(0); + }); + + it('should handle all zero values', () => { + const result = computeCategoryProgress({ + assigned: 0, + activity: 0, + balance: 0, + }); + + expect(result.state).toBe('funded'); + expect(result.spentRatio).toBe(0); + expect(result.overflowRatio).toBe(0); + }); + + it('should calculate remaining as balance in all cases', () => { + const result = computeCategoryProgress({ + assigned: 10000, + activity: -3000, + balance: 7000, + }); + + expect(result.remaining).toBe(7000); + }); + + it('should preserve balance in over-budget case', () => { + const result = computeCategoryProgress({ + assigned: 10000, + activity: -15000, + balance: -5000, + }); + + expect(result.remaining).toBe(-5000); + }); + + it('should handle spent with no budget', () => { + const result = computeCategoryProgress({ + assigned: 0, + activity: -2000, + balance: -2000, + }); + + expect(result.state).toBe('over-budget'); + expect(result.baselineAmount).toBe(2000); + expect(result.spentRatio).toBe(1.0); + expect(result.remaining).toBe(-2000); + }); + + it('should handle large positive balance', () => { + const result = computeCategoryProgress({ + assigned: 5000, + activity: -1000, + balance: 4000, + }); + + expect(result.state).toBe('funded'); + expect(result.remaining).toBe(4000); + }); + }); + + describe('template-aware scenarios', () => { + it('should be underfunded when assigned < template and not overspent', () => { + const result = computeCategoryProgress({ + assigned: 20000, + activity: -14000, + balance: 6000, + template: 25000, + }); + + expect(result.state).toBe('underfunded'); + expect(result.baselineAmount).toBe(25000); + expect(result.budgetedRatio).toBeCloseTo(0.8); + expect(result.spentRatio).toBeCloseTo(0.56); + }); + + it('should be funded when assigned >= template', () => { + const result = computeCategoryProgress({ + assigned: 26000, + activity: -14000, + balance: 12000, + template: 25000, + }); + + expect(result.state).toBe('funded'); + expect(result.baselineAmount).toBe(25000); + expect(result.budgetedRatio).toBeCloseTo(1); + }); + + it('should ignore template when overspent', () => { + const result = computeCategoryProgress({ + assigned: 20000, + activity: -22000, + balance: -2000, + template: 30000, + }); + + expect(result.state).toBe('over-budget'); + expect(result.baselineAmount).toBe(20000); + expect(result.spentRatio).toBe(1.0); + expect(result.overflowRatio).toBeCloseTo(0.1); + }); + }); +}); + +describe('CategoryProgressBar rendering', () => { + it('does not render when goalTemplatesEnabled feature flag is disabled', () => { + vi.mocked(useFeatureFlag).mockReturnValue(false); + const { container } = render( + , + ); + expect(container.firstChild).toBeNull(); + }); + + it('renders when goalTemplatesEnabled feature flag is enabled', () => { + vi.mocked(useFeatureFlag).mockReturnValue(true); + const { container } = render( + , + ); + expect(container.firstChild).not.toBeNull(); + }); + + it('checks the goalTemplatesEnabled feature flag', () => { + vi.mocked(useFeatureFlag).mockReturnValue(false); + render( + , + ); + expect(useFeatureFlag).toHaveBeenCalledWith('goalTemplatesEnabled'); + }); +}); diff --git a/packages/desktop-client/src/components/budget/CategoryProgressBar.tsx b/packages/desktop-client/src/components/budget/CategoryProgressBar.tsx new file mode 100644 index 00000000000..4f1d68df615 --- /dev/null +++ b/packages/desktop-client/src/components/budget/CategoryProgressBar.tsx @@ -0,0 +1,250 @@ +import React, { useMemo } from 'react'; + +import { theme } from '@actual-app/components/theme'; +import { Tooltip } from '@actual-app/components/tooltip'; +import { View } from '@actual-app/components/view'; + +import { useFeatureFlag } from '@desktop-client/hooks/useFeatureFlag'; +import { useFormat } from '@desktop-client/hooks/useFormat'; + +/** + * Computes the visual state of a category progress bar based on budgeted vs spent amounts. + * Always compares spent against budgeted (assigned), removing goal-based logic. + */ +export type ProgressParams = { + /** Amount assigned/budgeted (in cents) */ + assigned: number; + /** Amount spent (in cents, negative for spending) */ + activity: number; + /** Current balance (in cents) */ + balance: number; + /** Template amount (in cents) */ + template?: number; +}; + +export type ProgressResult = { + /** The "100%" baseline amount for the bar */ + baselineAmount: number; + /** Spent vs budgeted ratio, normalized to the baseline width */ + spentRatio: number; + /** Budgeted vs template ratio (baseline width) */ + budgetedRatio: number; + /** Extra overflow beyond 1.0 (for overspending visualization) */ + overflowRatio: number; + /** Amount remaining (can be negative) */ + remaining: number; + /** Visual state for color/styling */ + state: 'funded' | 'underfunded' | 'over-budget'; +}; + +export function computeCategoryProgress( + params: ProgressParams, +): ProgressResult { + const { assigned, activity, balance, template = 0 } = params; + const spent = Math.abs(activity); + const hasTemplate = template > 0; + const budgetedRatio = hasTemplate ? Math.min(assigned / template, 1) : 1; + + // No budget case + if (assigned <= 0) { + if (spent <= 0) { + return { + baselineAmount: 0, + spentRatio: 0, + budgetedRatio: 0, + overflowRatio: 0, + remaining: 0, + state: 'funded', + }; + } + + // Spent something but no budget + return { + baselineAmount: spent, + spentRatio: 1.0, + budgetedRatio: 0, + overflowRatio: 0, + remaining: balance, + state: 'over-budget', + }; + } + + // Budget exists (assigned > 0) + if (spent > assigned) { + const overflow = (spent - assigned) / assigned; + return { + baselineAmount: assigned, + spentRatio: 1.0, + budgetedRatio: 1, + overflowRatio: overflow, + remaining: balance, + state: 'over-budget', + }; + } + + const isUnderfunded = hasTemplate && assigned < template; + const baselineAmount = hasTemplate ? template : assigned; + const spentRatio = + assigned > 0 ? Math.min(spent / assigned, 1) * budgetedRatio : 0; + + // spent <= assigned (covers 0%, any %, and exactly 100%), no overspend + return { + baselineAmount, + spentRatio, + budgetedRatio, + overflowRatio: 0, + remaining: balance, + state: isUnderfunded ? 'underfunded' : 'funded', + }; +} + +/** + * Returns color for the progress bar based on state + */ +function getProgressBarColor(state: ProgressResult['state']): string { + switch (state) { + case 'over-budget': + return theme.budgetNumberNegative; + case 'underfunded': + return theme.templateNumberUnderFunded; + default: + return theme.templateNumberFunded; + } +} + +type CategoryProgressBarProps = { + /** Amount assigned/budgeted (in cents) */ + assigned: number; + /** Amount spent (in cents, negative for spending) */ + activity: number; + /** Current balance (in cents) */ + balance: number; + /** Template amount (in cents) */ + template?: number; +}; + +export function CategoryProgressBar({ + assigned, + activity, + balance, + template, +}: CategoryProgressBarProps) { + const progressBarEnabled = useFeatureFlag('goalTemplatesEnabled'); + const format = useFormat(); + + const progress = useMemo( + () => + computeCategoryProgress({ + assigned, + activity, + balance, + template, + }), + [assigned, activity, balance, template], + ); + + const fillColor = getProgressBarColor(progress.state); + + // Compute tooltip text + const tooltipParts: string[] = []; + if (progress.baselineAmount > 0) { + const percent = Math.round( + (progress.spentRatio + progress.overflowRatio) * 100, + ); + tooltipParts.push( + template && template > 0 + ? `${percent}% of template spent` + : `${percent}% of budget spent`, + ); + } + if (template && template > 0) { + tooltipParts.push(`Template: ${format(template, 'financial')}`); + } + if (assigned !== 0) { + tooltipParts.push(`Budgeted: ${format(assigned, 'financial')}`); + } + if (activity !== 0) { + tooltipParts.push(`Spent: ${format(Math.abs(activity), 'financial')}`); + } + if (balance !== 0) { + tooltipParts.push(`Balance: ${format(balance, 'financial')}`); + } + + const tooltipContent = tooltipParts.join(' • '); + + // Don't render if feature is disabled or no baseline + if (!progressBarEnabled || progress.baselineAmount <= 0) { + return null; + } + + return ( + + + + {/* Subtle budgeted fill (template baseline) */} + {template && template > 0 && progress.budgetedRatio > 0 && ( + + )} + + {/* Normal progress bar fill */} + 0 ? 0 : 3, + backgroundColor: fillColor, + width: + progress.overflowRatio > 0 + ? `${(progress.spentRatio / (progress.spentRatio + progress.overflowRatio)) * 100}%` + : `${progress.spentRatio * 100}%`, + }} + /> + + {/* Overflow fill (dark red) for overspending */} + {progress.overflowRatio > 0 && ( + + )} + + + + ); +} diff --git a/packages/desktop-client/src/components/budget/ExpenseCategory.tsx b/packages/desktop-client/src/components/budget/ExpenseCategory.tsx index 0afc324e409..68134a26dd5 100644 --- a/packages/desktop-client/src/components/budget/ExpenseCategory.tsx +++ b/packages/desktop-client/src/components/budget/ExpenseCategory.tsx @@ -25,8 +25,10 @@ import type { OnDragChangeCallback, OnDropCallback, } from '@desktop-client/components/sort'; -import { Row } from '@desktop-client/components/table'; +import { ROW_HEIGHT, Row } from '@desktop-client/components/table'; import { useDragRef } from '@desktop-client/hooks/useDragRef'; +import { useFeatureFlag } from '@desktop-client/hooks/useFeatureFlag'; +import { useGlobalPref } from '@desktop-client/hooks/useGlobalPref'; type ExpenseCategoryProps = { cat: CategoryEntity; @@ -41,6 +43,7 @@ type ExpenseCategoryProps = { onBudgetAction: (month: string, action: string, arg: unknown) => void; onShowActivity: (id: CategoryEntity['id'], month: string) => void; onReorder: OnDropCallback; + isLast?: boolean; }; export function ExpenseCategory({ @@ -56,6 +59,7 @@ export function ExpenseCategory({ onShowActivity, onDragChange, onReorder, + isLast, }: ExpenseCategoryProps) { let dragging = dragState && dragState.item === cat; @@ -63,6 +67,11 @@ export function ExpenseCategory({ dragging = true; } + const progressBarEnabled = useFeatureFlag('goalTemplatesEnabled'); + const [showProgressBarsPref] = useGlobalPref('showProgressBars'); + const showProgressBars = progressBarEnabled && showProgressBarsPref !== false; + const rowHeight = showProgressBars ? ROW_HEIGHT + 10 : undefined; + const { dragRef } = useDraggable({ type: 'category', onDragChange, @@ -82,8 +91,10 @@ export function ExpenseCategory({ return ( @@ -120,6 +136,7 @@ export function ExpenseCategory({ onEdit={onEditMonth} onBudgetAction={onBudgetAction} onShowActivity={onShowActivity} + isLast={isLast} /> )} diff --git a/packages/desktop-client/src/components/budget/GOAL_TEMPLATES_FEATURES.md b/packages/desktop-client/src/components/budget/GOAL_TEMPLATES_FEATURES.md new file mode 100644 index 00000000000..fc2e33e3afd --- /dev/null +++ b/packages/desktop-client/src/components/budget/GOAL_TEMPLATES_FEATURES.md @@ -0,0 +1,61 @@ +# Goal Templates Features + +## Feature Flag: `goalTemplatesEnabled` + +A single feature flag that enables all goal template functionality in the envelope budget view. Controlled via Settings > Experimental Features > "Goal templates". + +### What it enables + +1. **Template Column** - Displays template amounts alongside budget/spent/balance columns +2. **Progress Bars** - Visual spending progress bars beneath each category row +3. **Balance Coloring** - Color-coded balance numbers based on template goal status + +## Template Column + +When enabled, an additional column shows the template amount for each category. Users can view and edit template values directly in the budget table. + +## Progress Bars + +Visual bars rendered below each expense category row showing spending progress. + +### Visual States + +| State | Condition | Bar Color | +|---|---|---| +| Funded | Budgeted >= template (or no template) | Green (`theme.templateNumberFunded`) | +| Underfunded | Budgeted < template and not overspent | Orange (`theme.templateNumberUnderFunded`) | +| Over-budget | Spent > budgeted | Red (`theme.budgetNumberNegative`) | + +### Bar Anatomy + +- **Background track**: `theme.tableBackground`, 6px tall, fully rounded +- **Budgeted fill** (when template set): Translucent (35% opacity) showing how much of the template is budgeted +- **Spent fill**: Solid color showing spending progress +- **Overflow section**: Darker shade for overspending beyond the budget + +### Visibility Toggle + +A titlebar button (eye icon) lets users show/hide progress bars without disabling the feature flag. Stored in the `showProgressBars` global preference. + +## Balance Coloring + +When a category has a template amount, the balance number color reflects funding status: + +| Color | Meaning | +|---|---| +| Green (`theme.templateNumberFunded`) | Category is fully funded (budgeted >= template) | +| Orange (`theme.templateNumberUnderFunded`) | Category is underfunded (budgeted < template) | + +## Theme Colors + +Two theme tokens support these features: + +- `templateNumberFunded` - Green, used for funded state in progress bars and balance text +- `templateNumberUnderFunded` - Orange, used for underfunded state in progress bars and balance text + +## Key Files + +- `CategoryProgressBar.tsx` - Progress bar component and calculation logic +- `ExpenseCategory.tsx` - Row height adjustment for progress bars +- `envelope/EnvelopeBudgetComponents.tsx` - Integration into budget month view +- `Titlebar.tsx` - Progress bar visibility toggle button diff --git a/packages/desktop-client/src/components/budget/IncomeCategory.tsx b/packages/desktop-client/src/components/budget/IncomeCategory.tsx index ac8b8cd1f8e..ad7ba0ee1cc 100644 --- a/packages/desktop-client/src/components/budget/IncomeCategory.tsx +++ b/packages/desktop-client/src/components/budget/IncomeCategory.tsx @@ -78,6 +78,7 @@ export function IncomeCategory({ innerRef={handleDragRef} category={cat} isLast={isLast} + inputCellStyle={{ paddingBottom: 0 }} editing={ editingCell && editingCell.cell === 'name' && diff --git a/packages/desktop-client/src/components/budget/PROGRESS_BAR_IMPLEMENTATION.md b/packages/desktop-client/src/components/budget/PROGRESS_BAR_IMPLEMENTATION.md new file mode 100644 index 00000000000..3f14cea73af --- /dev/null +++ b/packages/desktop-client/src/components/budget/PROGRESS_BAR_IMPLEMENTATION.md @@ -0,0 +1,93 @@ +# Category Progress Bar Implementation Guide + +## Overview + +Visual progress bars rendered beneath each expense category row in the envelope budget view, showing spending progress relative to the budgeted amount. Controlled by the `goalTemplatesEnabled` feature flag. + +## Files + +- `CategoryProgressBar.tsx` - Component and `computeCategoryProgress` pure function +- `CategoryProgressBar.test.tsx` - Unit tests for progress calculation and rendering +- `ExpenseCategory.tsx` - Manages row height when progress bars are shown +- `envelope/EnvelopeBudgetComponents.tsx` - Integrates progress bar into `ExpenseCategoryMonth` + +## Component Architecture + +### `computeCategoryProgress(params): ProgressResult` + +Pure calculation function with no React dependencies. + +**Input (ProgressParams):** + +```typescript +{ + assigned: number; // Amount budgeted (cents) + activity: number; // Amount spent (cents, negative = spending) + balance: number; // Current balance (cents) + template?: number; // Template/goal amount (cents, optional) +} +``` + +**Output (ProgressResult):** + +```typescript +{ + baselineAmount: number; // The "100%" denominator for the bar + spentRatio: number; // Spent vs baseline ratio (0.0-1.0) + budgetedRatio: number; // Budgeted vs template ratio (0.0-1.0, always 1 without template) + overflowRatio: number; // Overflow beyond 1.0 (overspending) + remaining: number; // Balance remaining (can be negative) + state: 'funded' | 'underfunded' | 'over-budget'; +} +``` + +### Logic Rules + +| Condition | state | baselineAmount | spentRatio | +|---|---|---|---| +| No budget, no spending | `funded` | 0 | 0 | +| No budget, has spending | `over-budget` | spent | 1.0 | +| Spent > assigned | `over-budget` | assigned | 1.0 + overflow | +| Spent <= assigned, no template or assigned >= template | `funded` | template or assigned | spent/assigned | +| Spent <= assigned, template > assigned | `underfunded` | template | spent/assigned * budgetedRatio | + +### `CategoryProgressBar` Component + +**Props:** `assigned`, `activity`, `balance`, `template?` (all in cents) + +**Feature gating:** Returns `null` when `goalTemplatesEnabled` is disabled or when `baselineAmount` is 0. + +**Rendering:** +- 6px tall bar with 3px border radius +- Background: `theme.tableBackground` +- Optional translucent budgeted fill when template is set (35% opacity) +- Solid spent fill on top +- Darker overflow section for overspending (72% brightness) +- Tooltip on hover with percentage, template, budgeted, spent, and balance + +### Colors + +| State | Color | +|---|---| +| `funded` | `theme.templateNumberFunded` (green) | +| `underfunded` | `theme.templateNumberUnderFunded` (orange) | +| `over-budget` | `theme.budgetNumberNegative` (red) | + +## Visibility Toggle + +Users can toggle progress bar visibility via the titlebar button (eye icon). This is stored in the `showProgressBars` global pref. The progress bar feature flag must also be enabled. + +## Testing + +Run tests: + +```bash +yarn test -- CategoryProgressBar +``` + +Tests cover: +- Under/on/over budget scenarios +- No budget edge cases +- Template-aware underfunded/funded logic +- Rendering gated by feature flag +- Edge cases (zero, negative, large values) diff --git a/packages/desktop-client/src/components/budget/SidebarCategory.tsx b/packages/desktop-client/src/components/budget/SidebarCategory.tsx index c6b4cae8750..fa19e562f5d 100644 --- a/packages/desktop-client/src/components/budget/SidebarCategory.tsx +++ b/packages/desktop-client/src/components/budget/SidebarCategory.tsx @@ -32,6 +32,7 @@ type SidebarCategoryProps = { style?: CSSProperties; borderColor?: string; isLast?: boolean; + inputCellStyle?: CSSProperties; onEditName: (id: CategoryEntity['id']) => void; onSave: (category: CategoryEntity) => void; onHideNewCategory?: () => void; @@ -55,7 +56,8 @@ export function SidebarCategory({ editing, goalsShown = false, style, - isLast, + isLast: _isLast, + inputCellStyle, onEditName, onSave, onDelete, @@ -193,7 +195,7 @@ export function SidebarCategory({ } }} onBlur={() => onEditName(null)} - style={{ paddingLeft: 13, ...(isLast && { borderBottomWidth: 0 }) }} + style={{ paddingLeft: 13, paddingBottom: 12, ...inputCellStyle }} inputProps={{ placeholder: temporary ? t('New category name') : '', }} diff --git a/packages/desktop-client/src/components/budget/TemplateGoalContext.tsx b/packages/desktop-client/src/components/budget/TemplateGoalContext.tsx new file mode 100644 index 00000000000..d4f7f85564c --- /dev/null +++ b/packages/desktop-client/src/components/budget/TemplateGoalContext.tsx @@ -0,0 +1,118 @@ +import React, { + createContext, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; +import type { ReactNode } from 'react'; + +import { send } from 'loot-core/platform/client/connection'; +import * as monthUtils from 'loot-core/shared/months'; + +import { useCategories } from '@desktop-client/hooks/useCategories'; + +type TemplateGoalsByCategory = Record; +type TemplateGoalsByMonth = Record; + +const TemplateGoalContext = createContext({}); + +type TemplateGoalProviderProps = { + enabled: boolean; + startMonth: string; + numMonths: number; + children: ReactNode; +}; + +export function TemplateGoalProvider({ + enabled, + startMonth, + numMonths, + children, +}: TemplateGoalProviderProps) { + const { data: { list: categories = [] } = { list: [] } } = useCategories(); + const [templateGoalsByMonth, setTemplateGoalsByMonth] = + useState({}); + + const months = useMemo( + () => + Array.from({ length: numMonths }, (_, index) => + monthUtils.addMonths(startMonth, index), + ), + [numMonths, startMonth], + ); + const categoryTemplateFingerprint = useMemo( + () => + categories + .map( + category => + `${category.id}:${category.template_settings?.source || ''}:${category.goal_def || ''}`, + ) + .join('|'), + [categories], + ); + + useEffect(() => { + let mounted = true; + + async function loadTemplateGoals() { + if (!enabled) { + setTemplateGoalsByMonth({}); + return; + } + + const entries = await Promise.all( + months.map(async month => { + try { + const goals = await send('budget/get-template-goals', { month }); + return [month, goals as TemplateGoalsByCategory] as const; + } catch (error) { + console.error( + `Failed to load template goals for month "${month}"`, + error, + ); + return [month, {}] as const; + } + }), + ); + + if (!mounted) { + return; + } + + setTemplateGoalsByMonth( + entries.reduce((all, [month, goals]) => { + all[month] = goals; + return all; + }, {} as TemplateGoalsByMonth), + ); + } + + loadTemplateGoals(); + + return () => { + mounted = false; + }; + }, [enabled, months, categoryTemplateFingerprint]); + + return ( + + {children} + + ); +} + +export function useTemplateGoalsForMonth( + month: string, +): TemplateGoalsByCategory { + const templateGoalsByMonth = useContext(TemplateGoalContext); + return templateGoalsByMonth[month] ?? {}; +} + +export function useTemplateGoal( + categoryId: string, + month: string, +): number | null { + const templateGoalsByMonth = useContext(TemplateGoalContext); + return templateGoalsByMonth[month]?.[categoryId] ?? null; +} diff --git a/packages/desktop-client/src/components/budget/envelope/EnvelopeBudgetComponents.tsx b/packages/desktop-client/src/components/budget/envelope/EnvelopeBudgetComponents.tsx index 17e9d11e52b..1f89cabcea9 100644 --- a/packages/desktop-client/src/components/budget/envelope/EnvelopeBudgetComponents.tsx +++ b/packages/desktop-client/src/components/budget/envelope/EnvelopeBudgetComponents.tsx @@ -1,4 +1,4 @@ -import React, { memo, useRef } from 'react'; +import React, { memo, useMemo, useRef, useState } from 'react'; import type { ComponentProps, CSSProperties } from 'react'; import { Trans, useTranslation } from 'react-i18next'; @@ -16,6 +16,7 @@ import { View } from '@actual-app/components/view'; import { css } from '@emotion/css'; import * as monthUtils from 'loot-core/shared/months'; +import { integerToAmount } from 'loot-core/shared/util'; import type { CategoryGroupMonthProps, CategoryMonthProps } from '..'; @@ -24,22 +25,39 @@ import { BudgetMenu } from './BudgetMenu'; import { IncomeMenu } from './IncomeMenu'; import { BalanceWithCarryover } from '@desktop-client/components/budget/BalanceWithCarryover'; -import { makeAmountGrey } from '@desktop-client/components/budget/util'; +import { CategoryProgressBar } from '@desktop-client/components/budget/CategoryProgressBar'; +import { + makeAmountGrey, + monthFromSheetName, +} from '@desktop-client/components/budget/util'; import { CellValue, CellValueText, } from '@desktop-client/components/spreadsheet/CellValue'; -import { Field, Row, SheetCell } from '@desktop-client/components/table'; +import { + Field, + InputCell, + Row, + ROW_HEIGHT, + SheetCell, +} from '@desktop-client/components/table'; import type { SheetCellProps } from '@desktop-client/components/table'; import { useCategoryScheduleGoalTemplateIndicator } from '@desktop-client/hooks/useCategoryScheduleGoalTemplateIndicator'; import { useContextMenu } from '@desktop-client/hooks/useContextMenu'; +import { useFeatureFlag } from '@desktop-client/hooks/useFeatureFlag'; import { useFormat } from '@desktop-client/hooks/useFormat'; +import { useGlobalPref } from '@desktop-client/hooks/useGlobalPref'; import { useNavigate } from '@desktop-client/hooks/useNavigate'; import { useSheetName } from '@desktop-client/hooks/useSheetName'; import { useSheetValue } from '@desktop-client/hooks/useSheetValue'; import { useUndo } from '@desktop-client/hooks/useUndo'; +import { useCategories } from '@desktop-client/hooks/useCategories'; import type { Binding, SheetFields } from '@desktop-client/spreadsheet'; import { envelopeBudget } from '@desktop-client/spreadsheet/bindings'; +import { + useTemplateGoal, + useTemplateGoalsForMonth, +} from '@desktop-client/components/budget/TemplateGoalContext'; export function useEnvelopeSheetName< FieldName extends SheetFields<'envelope-budget'>, @@ -78,7 +96,88 @@ const cellStyle: CSSProperties = { fontWeight: 600, }; +type TemplateAmountCellProps = { + value: number | null; + onSave: (value: number | null) => void; + style?: CSSProperties; +}; + +function TemplateAmountCell({ value, onSave, style }: TemplateAmountCellProps) { + const [editing, setEditing] = useState(false); + const format = useFormat(); + + return ( + setEditing(true)} + onBlur={() => setEditing(false)} + value={value != null ? format.forEdit(value) : ''} + formatter={rawValue => + rawValue === '' ? '' : format(rawValue, 'financial') + } + onUpdate={rawValue => { + const integerAmount = format.fromEdit(rawValue, null); + const amount = + integerAmount === null + ? null + : integerToAmount(integerAmount, format.currency.decimalPlaces); + const existingAmount = + value === null + ? null + : integerToAmount(value, format.currency.decimalPlaces); + + if (amount !== existingAmount) { + onSave(amount); + } + setEditing(false); + }} + valueStyle={{ + cursor: 'default', + margin: 1, + padding: '0 4px', + borderRadius: 4, + ':hover': { + boxShadow: 'inset 0 0 0 1px ' + theme.pageTextSubdued, + backgroundColor: theme.budgetCurrentMonth, + }, + }} + inputProps={{ + style: { + backgroundColor: theme.budgetCurrentMonth, + }, + }} + style={{ ...styles.tnum, ...(style || null) }} + /> + ); +} + export const BudgetTotalsMonth = memo(function BudgetTotalsMonth() { + const isGoalTemplatesEnabled = useFeatureFlag('goalTemplatesEnabled'); + const format = useFormat(); + const { sheetName } = useEnvelopeSheetName(envelopeBudget.totalBudgeted); + const month = monthFromSheetName(sheetName); + const templateGoals = useTemplateGoalsForMonth(month); + const { data: { grouped: categoryGroups = [] } = { grouped: [] } } = + useCategories(); + + const templateTotal = useMemo(() => { + const values = categoryGroups + .filter(group => !group.is_income) + .flatMap(group => group.categories || []) + .map(category => templateGoals[category.id]) + .filter(value => value != null); + + if (values.length === 0) { + return null; + } + + return values.reduce((sum, value) => sum + value, 0); + }, [categoryGroups, templateGoals]); + return ( + {isGoalTemplatesEnabled && ( + + + Template + + + {templateTotal != null ? format(templateTotal, 'financial') : ''} + + + )} Budgeted @@ -148,6 +257,20 @@ export const ExpenseGroupMonth = memo(function ExpenseGroupMonth({ group, }: CategoryGroupMonthProps) { const { id } = group; + const isGoalTemplatesEnabled = useFeatureFlag('goalTemplatesEnabled'); + const format = useFormat(); + const templateGoals = useTemplateGoalsForMonth(month); + const templateTotal = useMemo(() => { + const values = (group.categories || []) + .map(category => templateGoals[category.id]) + .filter(value => value != null); + + if (values.length === 0) { + return null; + } + + return values.reduce((sum, value) => sum + value, 0); + }, [group.categories, templateGoals]); return ( + {isGoalTemplatesEnabled && ( + + {templateTotal != null ? format(templateTotal, 'financial') : ''} + + )} 0 && + budgetedAmount < templateAmount && + spentAmount <= budgetedAmount; + + const progressBarEnabled = useFeatureFlag('goalTemplatesEnabled'); + const [showProgressBarsPref] = useGlobalPref('showProgressBars'); + const showProgressBars = progressBarEnabled && showProgressBarsPref !== false; + const rowCellStyle = showProgressBars ? { borderBottomWidth: 0 } : null; + const rowContainerStyle = showProgressBars + ? ({ + flexDirection: 'row', + flex: `0 0 ${ROW_HEIGHT}px`, + height: ROW_HEIGHT, + } as const) + : ({ flex: 1, flexDirection: 'row' } as const); + return ( - { - if (editing) return; - handleBudgetContextMenu(e); - }} - > + + {isGoalTemplatesEnabled && ( + { + onBudgetAction(month, 'set-single-category-template', { + category: category.id, + amount, + }); + showUndoNotification({ + message: t('Template updated.'), + }); + }} + style={rowCellStyle || undefined} + /> + )} + { + if (editing) return; + handleBudgetContextMenu(e); + }} + > {!editing && ( @@ -367,7 +546,11 @@ export const ExpenseCategoryMonth = memo(function ExpenseCategoryMonth({ focused={editing} width="flex" onExpose={() => onEdit(category.id, month)} - style={{ ...(editing && { zIndex: 100 }), ...styles.tnum }} + style={{ + ...(editing && { zIndex: 100 }), + ...styles.tnum, + ...(rowCellStyle || null), + }} textAlign="right" valueStyle={{ cursor: 'default', @@ -402,7 +585,11 @@ export const ExpenseCategoryMonth = memo(function ExpenseCategoryMonth({ }} /> - + onShowActivity(category.id, month)} @@ -462,7 +649,11 @@ export const ExpenseCategoryMonth = memo(function ExpenseCategoryMonth({ ref={balanceMenuTriggerRef} name="balance" width="flex" - style={{ paddingRight: styles.monthRightPadding, textAlign: 'right' }} + style={{ + paddingRight: styles.monthRightPadding, + textAlign: 'right', + ...(rowCellStyle || null), + }} > @@ -513,6 +708,24 @@ export const ExpenseCategoryMonth = memo(function ExpenseCategoryMonth({ /> + + {showProgressBars && ( + + + + )} ); }); diff --git a/packages/desktop-client/src/components/budget/tracking/TrackingBudgetComponents.tsx b/packages/desktop-client/src/components/budget/tracking/TrackingBudgetComponents.tsx index 916be13810d..c8c2fb4c61f 100644 --- a/packages/desktop-client/src/components/budget/tracking/TrackingBudgetComponents.tsx +++ b/packages/desktop-client/src/components/budget/tracking/TrackingBudgetComponents.tsx @@ -1,5 +1,5 @@ // @ts-strict-ignore -import React, { memo, useRef, useState } from 'react'; +import React, { memo, useMemo, useRef, useState } from 'react'; import type { ComponentProps, CSSProperties } from 'react'; import { Trans } from 'react-i18next'; @@ -17,6 +17,7 @@ import { View } from '@actual-app/components/view'; import { css } from '@emotion/css'; import * as monthUtils from 'loot-core/shared/months'; +import { integerToAmount } from 'loot-core/shared/util'; import type { CategoryGroupMonthProps, CategoryMonthProps } from '..'; @@ -24,20 +25,30 @@ import { BalanceMenu } from './BalanceMenu'; import { BudgetMenu } from './BudgetMenu'; import { BalanceWithCarryover } from '@desktop-client/components/budget/BalanceWithCarryover'; -import { makeAmountGrey } from '@desktop-client/components/budget/util'; +import { + makeAmountGrey, + monthFromSheetName, +} from '@desktop-client/components/budget/util'; import { CellValue, CellValueText, } from '@desktop-client/components/spreadsheet/CellValue'; -import { Field, SheetCell } from '@desktop-client/components/table'; +import { Field, InputCell, SheetCell } from '@desktop-client/components/table'; import type { SheetCellProps } from '@desktop-client/components/table'; import { useCategoryScheduleGoalTemplateIndicator } from '@desktop-client/hooks/useCategoryScheduleGoalTemplateIndicator'; +import { useFeatureFlag } from '@desktop-client/hooks/useFeatureFlag'; import { useFormat } from '@desktop-client/hooks/useFormat'; import { useNavigate } from '@desktop-client/hooks/useNavigate'; +import { useSheetName } from '@desktop-client/hooks/useSheetName'; import { useSheetValue } from '@desktop-client/hooks/useSheetValue'; import { useUndo } from '@desktop-client/hooks/useUndo'; +import { useCategories } from '@desktop-client/hooks/useCategories'; import type { Binding, SheetFields } from '@desktop-client/spreadsheet'; import { trackingBudget } from '@desktop-client/spreadsheet/bindings'; +import { + useTemplateGoal, + useTemplateGoalsForMonth, +} from '@desktop-client/components/budget/TemplateGoalContext'; export const useTrackingSheetValue = < FieldName extends SheetFields<'tracking-budget'>, @@ -70,7 +81,89 @@ const cellStyle: CSSProperties = { fontWeight: 600, }; +type TemplateAmountCellProps = { + value: number | null; + onSave: (value: number | null) => void; +}; + +function TemplateAmountCell({ value, onSave }: TemplateAmountCellProps) { + const [editing, setEditing] = useState(false); + const format = useFormat(); + + return ( + setEditing(true)} + onBlur={() => setEditing(false)} + value={value != null ? format.forEdit(value) : ''} + formatter={rawValue => + rawValue === '' ? '' : format(rawValue, 'financial') + } + onUpdate={rawValue => { + const integerAmount = format.fromEdit(rawValue, null); + const amount = + integerAmount === null + ? null + : integerToAmount(integerAmount, format.currency.decimalPlaces); + const existingAmount = + value === null + ? null + : integerToAmount(value, format.currency.decimalPlaces); + + if (amount !== existingAmount) { + onSave(amount); + } + setEditing(false); + }} + valueStyle={{ + cursor: 'default', + margin: 1, + padding: '0 4px', + borderRadius: 4, + ':hover': { + boxShadow: 'inset 0 0 0 1px ' + theme.pageTextSubdued, + backgroundColor: theme.budgetCurrentMonth, + }, + }} + inputProps={{ + style: { + backgroundColor: theme.budgetCurrentMonth, + }, + }} + style={{ ...styles.tnum }} + /> + ); +} + export const BudgetTotalsMonth = memo(function BudgetTotalsMonth() { + const isGoalTemplatesEnabled = useFeatureFlag('goalTemplatesEnabled'); + const format = useFormat(); + const { sheetName } = useSheetName<'tracking-budget', 'total-budgeted'>( + 'total-budgeted', + ); + const month = monthFromSheetName(sheetName); + const templateGoals = useTemplateGoalsForMonth(month); + const { data: { grouped: categoryGroups = [] } = { grouped: [] } } = + useCategories(); + + const templateTotal = useMemo(() => { + const values = categoryGroups + .filter(group => !group.is_income) + .flatMap(group => group.categories || []) + .map(category => templateGoals[category.id]) + .filter(value => value != null); + + if (values.length === 0) { + return null; + } + + return values.reduce((sum, value) => sum + value, 0); + }, [categoryGroups, templateGoals]); + return ( + {isGoalTemplatesEnabled && ( + + + Template + + + {templateTotal != null ? format(templateTotal, 'financial') : ''} + + + )} Budgeted @@ -143,6 +246,20 @@ export const GroupMonth = memo(function GroupMonth({ group, }: CategoryGroupMonthProps) { const { id } = group; + const isGoalTemplatesEnabled = useFeatureFlag('goalTemplatesEnabled'); + const format = useFormat(); + const templateGoals = useTemplateGoalsForMonth(month); + const templateTotal = useMemo(() => { + const values = (group.categories || []) + .map(category => templateGoals[category.id]) + .filter(value => value != null); + + if (values.length === 0) { + return null; + } + + return values.reduce((sum, value) => sum + value, 0); + }, [group.categories, templateGoals]); return ( + {isGoalTemplatesEnabled && !group.is_income && ( + + {templateTotal != null ? format(templateTotal, 'financial') : ''} + + )} + {isGoalTemplatesEnabled && !category.is_income && ( + { + onBudgetAction(month, 'set-single-category-template', { + category: category.id, + amount, + }); + showUndoNotification({ + message: 'Template updated.', + }); + }} + /> + )} diff --git a/packages/desktop-client/src/components/budget/util.ts b/packages/desktop-client/src/components/budget/util.ts index d4a16b940eb..aea0099f6b5 100644 --- a/packages/desktop-client/src/components/budget/util.ts +++ b/packages/desktop-client/src/components/budget/util.ts @@ -177,6 +177,24 @@ export function getScrollbarWidth() { return Math.max(styles.scrollbarWidth - 2, 0); } +export function monthFromSheetName(sheetName: string) { + if (!sheetName) { + return ''; + } + + const prefix = 'budget'; + if (!sheetName.startsWith(prefix)) { + return sheetName; + } + + const raw = sheetName.slice(prefix.length); + if (raw.length === 6) { + return raw.slice(0, 4) + '-' + raw.slice(4); + } + + return raw; +} + export async function prewarmMonth( budgetType: SyncedPrefs['budgetType'], spreadsheet: ReturnType, diff --git a/packages/loot-core/src/server/budget/app.ts b/packages/loot-core/src/server/budget/app.ts index 807f8a8f61b..e8d52591df4 100644 --- a/packages/loot-core/src/server/budget/app.ts +++ b/packages/loot-core/src/server/budget/app.ts @@ -32,6 +32,7 @@ export type BudgetHandlers = { 'budget/apply-multiple-templates': typeof goalActions.applyMultipleCategoryTemplates; 'budget/overwrite-goal-template': typeof goalActions.overwriteTemplate; 'budget/apply-single-template': typeof goalActions.applySingleCategoryTemplate; + 'budget/set-single-category-template': typeof goalActions.setSingleCategoryTemplate; 'budget/cleanup-goal-template': typeof cleanupActions.cleanupTemplate; 'budget/hold-for-next-month': typeof actions.holdForNextMonth; 'budget/reset-hold': typeof actions.resetHold; @@ -59,6 +60,7 @@ export type BudgetHandlers = { 'budget/set-category-automations': typeof goalActions.storeTemplates; 'budget/store-note-templates': typeof goalNoteActions.storeNoteTemplates; 'budget/render-note-templates': typeof goalNoteActions.unparse; + 'budget/get-template-goals': typeof goalActions.getTemplateGoalPreview; }; export const app = createApp(); @@ -97,6 +99,10 @@ app.method( 'budget/apply-single-template', mutator(undoable(goalActions.applySingleCategoryTemplate)), ); +app.method( + 'budget/set-single-category-template', + mutator(undoable(goalActions.setSingleCategoryTemplate)), +); app.method( 'budget/cleanup-goal-template', mutator(undoable(cleanupActions.cleanupTemplate)), @@ -158,6 +164,7 @@ app.method( mutator(goalNoteActions.storeNoteTemplates), ); app.method('budget/render-note-templates', goalNoteActions.unparse); +app.method('budget/get-template-goals', goalActions.getTemplateGoalPreview); // Server must return AQL entities not the raw DB data async function getCategories() { diff --git a/packages/loot-core/src/server/budget/goal-template.ts b/packages/loot-core/src/server/budget/goal-template.ts index 2b78e1b0a92..72ad28d457a 100644 --- a/packages/loot-core/src/server/budget/goal-template.ts +++ b/packages/loot-core/src/server/budget/goal-template.ts @@ -9,7 +9,11 @@ import { batchMessages } from '../sync'; import { getSheetValue, isReflectBudget, setBudget, setGoal } from './actions'; import { CategoryTemplateContext } from './category-template-context'; -import { checkTemplateNotes, storeNoteTemplates } from './template-notes'; +import { + checkTemplateNotes, + getCategoriesWithTemplates, + storeNoteTemplates, +} from './template-notes'; type Notification = { type?: 'message' | 'error' | 'warning' | undefined; @@ -145,6 +149,72 @@ export async function getTemplatesForCategory( return getTemplates(c => c.id === categoryId); } +export async function setSingleCategoryTemplate({ + categoryId, + amount, +}: { + categoryId: CategoryEntity['id']; + amount: number | null; +}): Promise { + if (amount === null) { + // Clear template by removing goal_def and template_settings + await db.updateWithSchema('categories', { + id: categoryId, + goal_def: null, + template_settings: null, + }); + return; + } + + const templates: Template[] = [ + { + type: 'simple', + monthly: amount, + directive: 'template', + priority: 0, + }, + ]; + + await storeTemplates({ + categoriesWithTemplates: [{ id: categoryId, templates }], + source: 'ui', + }); +} + +export async function getTemplateGoalPreview({ + month, +}: { + month: string; +}): Promise> { + await storeNoteTemplates(); + const categoryTemplates = await getTemplates(); + const categories = await getCategories(); + const result: Record = {}; + + for (const category of categories) { + const templates = categoryTemplates[category.id]; + if (!templates) { + continue; + } + + try { + const templateContext = await CategoryTemplateContext.init( + templates, + category, + month, + 0, + ); + await templateContext.runAll(Infinity); + const values = templateContext.getValues(); + result[category.id] = values.goal; + } catch { + // Skip categories with template errors + } + } + + return result; +} + type TemplateBudget = { category: CategoryEntity['id']; budgeted: number; @@ -153,7 +223,7 @@ type TemplateBudget = { async function setBudgets(month: string, templateBudget: TemplateBudget[]) { await batchMessages(async () => { templateBudget.forEach(element => { - void setBudget({ + setBudget({ category: element.category, month, amount: element.budgeted, @@ -171,7 +241,7 @@ type TemplateGoal = { async function setGoals(month: string, templateGoal: TemplateGoal[]) { await batchMessages(async () => { templateGoal.forEach(element => { - void setGoal({ + setGoal({ month, category: element.category, goal: element.goal, @@ -240,17 +310,7 @@ async function processTemplate( } } - //break early if nothing to do, or there are errors - if (templateContexts.length === 0 && errors.length === 0) { - if (goalList.length > 0) { - void setGoals(month, goalList); - } - return { - type: 'message', - message: 'Everything is up to date', - }; - } - if (errors.length > 0) { + if (errors.length > 0 || templateContexts.length === 0) { return { sticky: true, message: 'There were errors interpreting some templates:', diff --git a/packages/loot-core/src/server/budget/template-notes.ts b/packages/loot-core/src/server/budget/template-notes.ts index 9cf88bf9e71..fc661dd1f8f 100644 --- a/packages/loot-core/src/server/budget/template-notes.ts +++ b/packages/loot-core/src/server/budget/template-notes.ts @@ -27,7 +27,7 @@ export async function storeNoteTemplates(): Promise { await resetCategoryGoalDefsWithNoTemplates(); } -type CategoryWithTemplateNotes = { +export type CategoryWithTemplateNotes = { id: string; name: string; templates: Template[]; @@ -71,7 +71,7 @@ export async function checkTemplateNotes(): Promise { }; } -async function getCategoriesWithTemplates(): Promise< +export async function getCategoriesWithTemplates(): Promise< CategoryWithTemplateNotes[] > { const templatesForCategory: CategoryWithTemplateNotes[] = []; @@ -97,6 +97,7 @@ async function getCategoriesWithTemplates(): Promise< try { const parsedTemplate: Template = parse(trimmedLine); + let validationError: string | null = null; // Validate schedule adjustments if ( (parsedTemplate.type === 'average' || @@ -108,16 +109,23 @@ async function getCategoriesWithTemplates(): Promise< parsedTemplate.adjustment <= -100 || parsedTemplate.adjustment > 1000 ) { - throw new Error( - `Invalid adjustment percentage (${parsedTemplate.adjustment}%). Must be between -100% and 1000%`, - ); + validationError = `Invalid adjustment percentage (${parsedTemplate.adjustment}%). Must be between -100% and 1000%`; } } else if (parsedTemplate.adjustmentType === 'fixed') { - //placeholder for potential validation of amount/fixed adjustments + // placeholder for potential validation of amount/fixed adjustments } } - parsedTemplates.push(parsedTemplate); + if (validationError) { + parsedTemplates.push({ + type: 'error', + directive: 'error', + line, + error: validationError, + }); + } else { + parsedTemplates.push(parsedTemplate); + } } catch (e: unknown) { parsedTemplates.push({ type: 'error', @@ -249,8 +257,7 @@ export async function unparse(templates: Template[]): Promise { } case 'copy': { // #template copy from months ago [limit] - const result = `${prefix} copy from ${template.lookBack} months ago`; - return result; + return `${prefix} copy from ${template.lookBack} months ago`; } case 'limit': { if (!refill) { diff --git a/packages/loot-core/src/shared/util.test.ts b/packages/loot-core/src/shared/util.test.ts index c76ce8b3209..bff3836dfd7 100644 --- a/packages/loot-core/src/shared/util.test.ts +++ b/packages/loot-core/src/shared/util.test.ts @@ -116,11 +116,11 @@ describe('utility functions', () => { test('number formatting works with apostrophe-dot format', () => { setNumberFormat({ format: 'apostrophe-dot', hideFraction: false }); let formatter = getNumberFormat().formatter; - expect(formatter.format(Number('1234.56'))).toBe(`1\u2019234.56`); + expect(formatter.format(Number('1234.56'))).toBe(`1'234.56`); setNumberFormat({ format: 'apostrophe-dot', hideFraction: true }); formatter = getNumberFormat().formatter; - expect(formatter.format(Number('1234.56'))).toBe(`1\u2019235`); + expect(formatter.format(Number('1234.56'))).toBe(`1'235`); }); test('number formatting works with small negative numbers with 0 decimal places', () => { diff --git a/packages/loot-core/src/types/prefs.ts b/packages/loot-core/src/types/prefs.ts index 6134067991a..eca1fc9d998 100644 --- a/packages/loot-core/src/types/prefs.ts +++ b/packages/loot-core/src/types/prefs.ts @@ -126,6 +126,7 @@ export type GlobalPrefs = Partial<{ port?: number; }; notifyWhenUpdateIsAvailable: boolean; + showProgressBars: boolean; }>; // GlobalPrefsJson represents what's saved in the global-store.json file diff --git a/upcoming-release-notes/7000.md b/upcoming-release-notes/7000.md new file mode 100644 index 00000000000..0585ec23f83 --- /dev/null +++ b/upcoming-release-notes/7000.md @@ -0,0 +1,6 @@ +--- +category: Features +authors: [ohnefrust] +--- + +Adds a visual progress bar below each expense category row showing how much of the budgeted amount has been spent. The bar turns red when a category is overspent, with a distinct overflow segment indicating how far over budget spending has gone. Hovering over the bar shows a tooltip with a detailed breakdown (budgeted, spent, balance, and percentage). Enable via Settings > Experimental Features.