From ebc943bd705dfcb80899512e40d527ae716a017c Mon Sep 17 00:00:00 2001 From: shall0pass <20625555+shall0pass@users.noreply.github.com> Date: Thu, 27 Jul 2023 16:01:50 -0500 Subject: [PATCH] Add per-category button to fill budget cells, including Goal template support (#1350) --- .../components/budget/report/components.tsx | 192 +++++++++++++----- .../budget/rollover/rollover-components.tsx | 189 ++++++++++++----- .../loot-core/src/client/actions/queries.ts | 43 +++- .../loot-core/src/server/budget/actions.ts | 27 +++ packages/loot-core/src/server/budget/app.ts | 9 + .../src/server/budget/goaltemplates.ts | 87 ++++++-- .../src/server/budget/types/handlers.d.ts | 22 +- upcoming-release-notes/1350.md | 6 + 8 files changed, 449 insertions(+), 126 deletions(-) create mode 100644 upcoming-release-notes/1350.md diff --git a/packages/desktop-client/src/components/budget/report/components.tsx b/packages/desktop-client/src/components/budget/report/components.tsx index 484d64013..78496230c 100644 --- a/packages/desktop-client/src/components/budget/report/components.tsx +++ b/packages/desktop-client/src/components/budget/report/components.tsx @@ -1,11 +1,13 @@ -import React, { memo } from 'react'; +import React, { memo, useState } from 'react'; import { reportBudget } from 'loot-core/src/client/queries'; import evalArithmetic from 'loot-core/src/shared/arithmetic'; import { integerToCurrency, amountToInteger } from 'loot-core/src/shared/util'; +import useFeatureFlag from '../../../hooks/useFeatureFlag'; +import CheveronDown from '../../../icons/v1/CheveronDown'; import { styles, colors } from '../../../style'; -import { View, Text, Tooltip, Menu, useTooltip } from '../../common'; +import { Button, View, Text, Tooltip, Menu, useTooltip } from '../../common'; import CellValue from '../../spreadsheet/CellValue'; import format from '../../spreadsheet/format'; import useSheetValue from '../../spreadsheet/useSheetValue'; @@ -195,56 +197,150 @@ export const CategoryMonth = memo(function CategoryMonth({ }: CategoryMonthProps) { let borderColor = colors.border; let balanceTooltip = useTooltip(); + const [menuOpen, setMenuOpen] = useState(false); + const [hover, setHover] = useState(false); + const isGoalTemplatesEnabled = useFeatureFlag('goalTemplatesEnabled'); return ( - <View style={{ flex: 1, flexDirection: 'row' }}> - <SheetCell - name="budget" - exposed={editing} - focused={editing} - width="flex" - borderColor={borderColor} - onExpose={() => onEdit(category.id, monthIndex)} - style={[editing && { zIndex: 100 }, styles.tnum]} - textAlign="right" - valueStyle={[ - { - cursor: 'default', - margin: 1, - padding: '0 4px', - borderRadius: 4, - }, - { - ':hover': { - boxShadow: 'inset 0 0 0 1px ' + colors.n7, - backgroundColor: 'white', - }, - }, - ]} - valueProps={{ - binding: reportBudget.catBudgeted(category.id), - type: 'financial', - getValueStyle: makeAmountGrey, - formatExpr: expr => { - return integerToCurrency(expr); - }, - unformatExpr: expr => { - return amountToInteger(evalArithmetic(expr, 0)); - }, - }} - inputProps={{ - onBlur: () => { - onEdit(null); - }, + <View + style={{ + flex: 1, + flexDirection: 'row', + '& .hover-visible': { + opacity: 0, + transition: 'opacity .25s', + }, + '&:hover .hover-visible': { + opacity: 1, + }, + }} + > + <View + style={{ + flex: 1, + flexDirection: 'row', + borderTopWidth: 1, + borderBottomWidth: 1, + borderColor, + backgroundColor: 'white', }} - onSave={amount => { - onBudgetAction(monthIndex, 'budget-amount', { - category: category.id, - amount, - }); + onMouseOverCapture={() => setHover(true)} + onMouseLeave={() => { + setHover(false); }} - /> - + > + {!editing && (hover || menuOpen) && ( + <View + style={{ + flexShrink: 0, + marginRight: 0, + marginLeft: 3, + justifyContent: 'center', + }} + > + <Button + type="bare" + onClick={e => { + e.stopPropagation(); + setMenuOpen(true); + }} + style={{ + padding: 3, + }} + > + <CheveronDown + width={14} + height={14} + className="hover-visible" + style={menuOpen && { opacity: 1 }} + /> + </Button> + {menuOpen && ( + <Tooltip + position="bottom-left" + width={200} + style={{ padding: 0 }} + onClose={() => setMenuOpen(false)} + > + <Menu + onMenuSelect={type => { + onBudgetAction(monthIndex, type, { category: category.id }); + setMenuOpen(false); + }} + items={[ + { + name: 'copy-single-last', + text: 'Copy last month’s budget', + }, + { + name: 'set-single-3-avg', + text: 'Set to 3 month average', + }, + { + name: 'set-single-6-avg', + text: 'Set to 6 month average', + }, + { + name: 'set-single-12-avg', + text: 'Set to yearly average', + }, + isGoalTemplatesEnabled && { + name: 'apply-single-category-template', + text: 'Apply budget template', + }, + ]} + /> + </Tooltip> + )} + </View> + )} + <SheetCell + name="budget" + exposed={editing} + focused={editing} + width="flex" + borderColor="white" + onExpose={() => onEdit(category.id, monthIndex)} + style={[editing && { zIndex: 100 }, styles.tnum]} + textAlign="right" + valueStyle={[ + { + cursor: 'default', + margin: 1, + padding: '0 4px', + borderRadius: 4, + }, + { + ':hover': { + boxShadow: 'inset 0 0 0 1px ' + colors.n7, + backgroundColor: 'white', + }, + }, + ]} + valueProps={{ + binding: reportBudget.catBudgeted(category.id), + type: 'financial', + getValueStyle: makeAmountGrey, + formatExpr: expr => { + return integerToCurrency(expr); + }, + unformatExpr: expr => { + return amountToInteger(evalArithmetic(expr, 0)); + }, + }} + inputProps={{ + onBlur: () => { + onEdit(null); + }, + }} + onSave={amount => { + onBudgetAction(monthIndex, 'budget-amount', { + category: category.id, + amount, + }); + }} + /> + </View> <Field name="spent" width="flex" diff --git a/packages/desktop-client/src/components/budget/rollover/rollover-components.tsx b/packages/desktop-client/src/components/budget/rollover/rollover-components.tsx index 6b33a8a22..7f9c192f9 100644 --- a/packages/desktop-client/src/components/budget/rollover/rollover-components.tsx +++ b/packages/desktop-client/src/components/budget/rollover/rollover-components.tsx @@ -4,6 +4,8 @@ import { rolloverBudget } from 'loot-core/src/client/queries'; import evalArithmetic from 'loot-core/src/shared/arithmetic'; import { integerToCurrency, amountToInteger } from 'loot-core/src/shared/util'; +import useFeatureFlag from '../../../hooks/useFeatureFlag'; +import CheveronDown from '../../../icons/v1/CheveronDown'; import { styles, colors } from '../../../style'; import CategoryAutocomplete from '../../autocomplete/CategorySelect'; import { @@ -320,56 +322,150 @@ export const ExpenseCategoryMonth = memo(function ExpenseCategoryMonth({ }: ExpenseCategoryMonthProps) { let borderColor = colors.border; let balanceTooltip = useTooltip(); + const [menuOpen, setMenuOpen] = useState(false); + const [hover, setHover] = useState(false); + const isGoalTemplatesEnabled = useFeatureFlag('goalTemplatesEnabled'); return ( - <View style={{ flex: 1, flexDirection: 'row' }}> - <SheetCell - name="budget" - exposed={editing} - focused={editing} - width="flex" - borderColor={borderColor} - onExpose={() => onEdit(category.id, monthIndex)} - style={[editing && { zIndex: 100 }, styles.tnum]} - textAlign="right" - valueStyle={[ - { - cursor: 'default', - margin: 1, - padding: '0 4px', - borderRadius: 4, - }, - { - ':hover': { - boxShadow: 'inset 0 0 0 1px ' + colors.n7, - backgroundColor: 'white', - }, - }, - ]} - valueProps={{ - binding: rolloverBudget.catBudgeted(category.id), - type: 'financial', - getValueStyle: makeAmountGrey, - formatExpr: expr => { - return integerToCurrency(expr); - }, - unformatExpr: expr => { - return amountToInteger(evalArithmetic(expr, 0)); - }, - }} - inputProps={{ - onBlur: () => { - onEdit(null); - }, + <View + style={{ + flex: 1, + flexDirection: 'row', + '& .hover-visible': { + opacity: 0, + transition: 'opacity .25s', + }, + '&:hover .hover-visible': { + opacity: 1, + }, + }} + > + <View + style={{ + flex: 1, + flexDirection: 'row', + borderTopWidth: 1, + borderBottomWidth: 1, + borderColor, + backgroundColor: 'white', }} - onSave={amount => { - onBudgetAction(monthIndex, 'budget-amount', { - category: category.id, - amount, - }); + onMouseOverCapture={() => setHover(true)} + onMouseLeave={() => { + setHover(false); }} - /> - + > + {!editing && (hover || menuOpen) ? ( + <View + style={{ + flexShrink: 1, + marginRight: 0, + marginLeft: 3, + justifyContent: 'center', + }} + > + <Button + type="bare" + onClick={e => { + e.stopPropagation(); + setMenuOpen(true); + }} + style={{ + padding: 3, + }} + > + <CheveronDown + width={14} + height={14} + className="hover-visible" + style={menuOpen && { opacity: 1 }} + /> + </Button> + {menuOpen && ( + <Tooltip + position="bottom-left" + width={200} + style={{ padding: 0 }} + onClose={() => setMenuOpen(false)} + > + <Menu + onMenuSelect={type => { + onBudgetAction(monthIndex, type, { category: category.id }); + setMenuOpen(false); + }} + items={[ + { + name: 'copy-single-last', + text: 'Copy last month’s budget', + }, + { + name: 'set-single-3-avg', + text: 'Set to 3 month average', + }, + { + name: 'set-single-6-avg', + text: 'Set to 6 month average', + }, + { + name: 'set-single-12-avg', + text: 'Set to yearly average', + }, + isGoalTemplatesEnabled && { + name: 'apply-single-category-template', + text: 'Apply budget template', + }, + ]} + /> + </Tooltip> + )} + </View> + ) : null} + <SheetCell + name="budget" + exposed={editing} + focused={editing} + width="flex" + borderColor="white" + onExpose={() => onEdit(category.id, monthIndex)} + style={[editing && { zIndex: 100 }, styles.tnum]} + textAlign="right" + valueStyle={[ + { + cursor: 'default', + margin: 1, + padding: '0 4px', + borderRadius: 4, + }, + { + ':hover': { + boxShadow: 'inset 0 0 0 1px ' + colors.n7, + backgroundColor: 'white', + }, + }, + ]} + valueProps={{ + binding: rolloverBudget.catBudgeted(category.id), + type: 'financial', + getValueStyle: makeAmountGrey, + formatExpr: expr => { + return integerToCurrency(expr); + }, + unformatExpr: expr => { + return amountToInteger(evalArithmetic(expr, 0)); + }, + }} + inputProps={{ + onBlur: () => { + onEdit(null); + }, + }} + onSave={amount => { + onBudgetAction(monthIndex, 'budget-amount', { + category: category.id, + amount, + }); + }} + /> + </View> <Field name="spent" width="flex" @@ -391,7 +487,6 @@ export const ExpenseCategoryMonth = memo(function ExpenseCategoryMonth({ /> </span> </Field> - <Field name="balance" width="flex" diff --git a/packages/loot-core/src/client/actions/queries.ts b/packages/loot-core/src/client/actions/queries.ts index 63524f60e..1b9b18cc3 100644 --- a/packages/loot-core/src/client/actions/queries.ts +++ b/packages/loot-core/src/client/actions/queries.ts @@ -30,16 +30,10 @@ export function applyBudgetAction(month, type, args) { dispatch(addNotification(await send('budget/check-templates'))); break; case 'apply-goal-template': - dispatch( - addNotification(await send('budget/apply-goal-template', { month })), - ); + await send('budget/apply-goal-template', { month }); break; case 'overwrite-goal-template': - dispatch( - addNotification( - await send('budget/overwrite-goal-template', { month }), - ), - ); + await send('budget/overwrite-goal-template', { month }); break; case 'cleanup-goal-template': dispatch( @@ -87,6 +81,39 @@ export function applyBudgetAction(month, type, args) { }); break; } + case 'apply-single-category-template': + await send('budget/apply-single-template', { + month, + category: args.category, + }); + break; + case 'set-single-3-avg': + await send('budget/set-n-month-avg', { + month, + N: 3, + category: args.category, + }); + break; + case 'set-single-6-avg': + await send('budget/set-n-month-avg', { + month, + N: 6, + category: args.category, + }); + break; + case 'set-single-12-avg': + await send('budget/set-n-month-avg', { + month, + N: 12, + category: args.category, + }); + break; + case 'copy-single-last': + await send('budget/copy-single-month', { + month, + category: args.category, + }); + break; default: } }; diff --git a/packages/loot-core/src/server/budget/actions.ts b/packages/loot-core/src/server/budget/actions.ts index 23e68fee3..0dd5a735b 100644 --- a/packages/loot-core/src/server/budget/actions.ts +++ b/packages/loot-core/src/server/budget/actions.ts @@ -136,6 +136,17 @@ export async function copyPreviousMonth({ month }) { }); } +export async function copySinglePreviousMonth({ month, category }) { + let prevMonth = monthUtils.prevMonth(month); + let newAmount = await getSheetValue( + monthUtils.sheetForMonth(prevMonth), + 'budget-' + category, + ); + await batchMessages(async () => { + setBudget({ category: category, month, amount: newAmount }); + }); +} + export async function setZero({ month }) { let categories = await db.all( 'SELECT * FROM v_categories WHERE tombstone = 0', @@ -185,6 +196,22 @@ export async function set3MonthAvg({ month }) { }); } +export async function setNMonthAvg({ month, N, category }) { + let prevMonth = monthUtils.prevMonth(month); + let sumAmount = 0; + for (let l = 0; l < N; l++) { + sumAmount += await getSheetValue( + monthUtils.sheetForMonth(prevMonth), + 'sum-amount-' + category, + ); + prevMonth = monthUtils.prevMonth(prevMonth); + } + await batchMessages(async () => { + const avg = Math.round(sumAmount / N); + setBudget({ category: category, month, amount: -avg }); + }); +} + export async function holdForNextMonth({ month, amount }) { let row = await db.first( 'SELECT buffered FROM zero_budget_months WHERE id = ?', diff --git a/packages/loot-core/src/server/budget/app.ts b/packages/loot-core/src/server/budget/app.ts index db683c5df..b9faad04a 100644 --- a/packages/loot-core/src/server/budget/app.ts +++ b/packages/loot-core/src/server/budget/app.ts @@ -14,8 +14,13 @@ app.method( 'budget/copy-previous-month', mutator(undoable(actions.copyPreviousMonth)), ); +app.method( + 'budget/copy-single-month', + mutator(undoable(actions.copySinglePreviousMonth)), +); app.method('budget/set-zero', mutator(undoable(actions.setZero))); app.method('budget/set-3month-avg', mutator(undoable(actions.set3MonthAvg))); +app.method('budget/set-n-month-avg', mutator(undoable(actions.setNMonthAvg))); app.method( 'budget/check-templates', mutator(undoable(goalActions.runCheckTemplates)), @@ -28,6 +33,10 @@ app.method( 'budget/overwrite-goal-template', mutator(undoable(goalActions.overwriteTemplate)), ); +app.method( + 'budget/apply-single-template', + mutator(undoable(goalActions.applySingleCategoryTemplate)), +); app.method( 'budget/cleanup-goal-template', mutator(undoable(cleanupActions.cleanupTemplate)), diff --git a/packages/loot-core/src/server/budget/goaltemplates.ts b/packages/loot-core/src/server/budget/goaltemplates.ts index cdfe1b48c..7c3c033bf 100644 --- a/packages/loot-core/src/server/budget/goaltemplates.ts +++ b/packages/loot-core/src/server/budget/goaltemplates.ts @@ -7,16 +7,32 @@ import { import { amountToInteger, integerToAmount } from '../../shared/util'; import * as db from '../db'; import { getRuleForSchedule, getNextDate } from '../schedules/app'; +import { batchMessages } from '../sync'; import { setBudget, setZero, getSheetValue, isReflectBudget } from './actions'; import { parse } from './goal-template.pegjs'; -export function applyTemplate({ month }) { - return processTemplate(month, false); +export async function applyTemplate({ month }) { + let category_templates = await getCategoryTemplates(null); + return processTemplate(month, false, category_templates); } -export function overwriteTemplate({ month }) { - return processTemplate(month, true); +export async function overwriteTemplate({ month }) { + let category_templates = await getCategoryTemplates(null); + return processTemplate(month, true, category_templates); +} + +export async function applySingleCategoryTemplate({ month, category }) { + let categories = await db.all(`SELECT * FROM v_categories WHERE id = ?`, [ + category, + ]); + let category_templates = await getCategoryTemplates(categories[0]); + await setBudget({ + category: category, + month, + amount: 0, + }); + return processTemplate(month, false, category_templates); } export function runCheckTemplates() { @@ -35,13 +51,22 @@ function checkScheduleTemplates(template) { return { lowPriority, errorNotice }; } -async function processTemplate( - month: string, - force: boolean, -): Promise<Notification> { +async function setGoalBudget({ month, templateBudget }) { + await batchMessages(async () => { + templateBudget.forEach(element => { + setBudget({ + category: element.category, + month, + amount: element.amount, + }); + }); + }); +} + +async function processTemplate(month, force, category_templates) { + let templateBudget = []; let num_applied = 0; let errors = []; - let category_templates = await getCategoryTemplates(); let lowestPriority = 0; let originalCategoryBalance = []; @@ -57,7 +82,11 @@ async function processTemplate( `budget-${category.id}`, ); if (budgeted) { - originalCategoryBalance.push({ cat: category, amount: budgeted }); + originalCategoryBalance.push({ + cat: category, + amount: budgeted, + isIncome: category.is_income, + }); } let template = category_templates[category.id]; if (template) { @@ -69,7 +98,22 @@ async function processTemplate( } } } - setZero({ month }); + + await setZero({ month }); + + //setZero() sets budgeted Income to 0. Reset income categories before continuing. + if (isReflectBudget()) { + for (let l = 0; l < originalCategoryBalance.length; l++) { + if (originalCategoryBalance[l].isIncome) { + await setBudget({ + category: originalCategoryBalance[l].cat.id, + month, + amount: originalCategoryBalance[l].amount, + }); + } + } + } + // find all remainder templates, place them after all other templates let remainder_found; let remainder_priority = lowestPriority + 1; @@ -87,11 +131,14 @@ async function processTemplate( } } } - // so the remainders don't get skiped + // so the remainders don't get skipped if (remainder_found) lowestPriority = remainder_priority; let sheetName = monthUtils.sheetForMonth(month); let available_start = await getSheetValue(sheetName, `to-budget`); + let available_remaining = isReflectBudget() + ? await getSheetValue(sheetName, `total-saved`) + : await getSheetValue(sheetName, `to-budget`); for (let priority = 0; priority <= lowestPriority; priority++) { // setup scaling for remainder let remainder_scale = 1; @@ -152,15 +199,16 @@ async function processTemplate( priority, remainder_scale, available_start, + available_remaining, force, ); if (to_budget != null) { num_applied++; - await setBudget({ + templateBudget.push({ category: category.id, - month, amount: to_budget, }); + available_remaining -= to_budget; } if (applyErrors != null) { errors = errors.concat( @@ -171,7 +219,9 @@ async function processTemplate( } } } + await setGoalBudget({ month, templateBudget }); } + if (!force) { //if overwrite is not preferred, set cell to original value for (let l = 0; l < originalCategoryBalance.length; l++) { @@ -220,12 +270,13 @@ async function processTemplate( } const TEMPLATE_PREFIX = '#template'; -async function getCategoryTemplates() { +async function getCategoryTemplates(category) { let templates = {}; let notes = await db.all( `SELECT * FROM notes WHERE lower(note) like '%${TEMPLATE_PREFIX}%'`, ); + if (category) notes = notes.filter(n => n.id === category.id); for (let n = 0; n < notes.length; n++) { let lines = notes[n].note.split('\n'); @@ -255,6 +306,7 @@ async function applyCategoryTemplate( priority, remainder_scale, available_start, + budgetAvailable, force, ) { let current_month = `${month}-01`; @@ -328,9 +380,6 @@ async function applyCategoryTemplate( let budgeted = await getSheetValue(sheetName, `budget-${category.id}`); let spent = await getSheetValue(sheetName, `sum-amount-${category.id}`); let balance = await getSheetValue(sheetName, `leftover-${category.id}`); - let budgetAvailable = isReflectBudget() - ? await getSheetValue(sheetName, `total-saved`) - : await getSheetValue(sheetName, `to-budget`); let to_budget = budgeted; let limit; let hold; @@ -661,7 +710,7 @@ async function applyCategoryTemplate( } async function checkTemplates(): Promise<Notification> { - let category_templates = await getCategoryTemplates(); + let category_templates = await getCategoryTemplates(null); let errors = []; let categories = await db.all( diff --git a/packages/loot-core/src/server/budget/types/handlers.d.ts b/packages/loot-core/src/server/budget/types/handlers.d.ts index 8def30dd2..3c44a0a4b 100644 --- a/packages/loot-core/src/server/budget/types/handlers.d.ts +++ b/packages/loot-core/src/server/budget/types/handlers.d.ts @@ -15,13 +15,11 @@ export interface BudgetHandlers { 'budget/check-templates': () => Promise<Notification>; - 'budget/apply-goal-template': (arg: { - month: string; - }) => Promise<Notification>; + 'budget/apply-goal-template': (arg: { month: string }) => Promise<unknown>; 'budget/overwrite-goal-template': (arg: { month: string; - }) => Promise<Notification>; + }) => Promise<unknown>; 'budget/cleanup-goal-template': (arg: { month: string; @@ -38,4 +36,20 @@ export interface BudgetHandlers { 'budget/transfer-category': (...args: unknown[]) => Promise<unknown>; 'budget/set-carryover': (...args: unknown[]) => Promise<unknown>; + + 'budget/apply-single-template': (arg: { + month: string; + category: string; //category id + }) => Promise<unknown>; + + 'budget/set-n-month-avg': (arg: { + month: string; + N: number; + category: string; //category id + }) => Promise<unknown>; + + 'budget/copy-single-month': (arg: { + month: string; + category: string; //category id + }) => Promise<unknown>; } diff --git a/upcoming-release-notes/1350.md b/upcoming-release-notes/1350.md new file mode 100644 index 000000000..0dea3c99f --- /dev/null +++ b/upcoming-release-notes/1350.md @@ -0,0 +1,6 @@ +--- +category: Enhancements +authors: [shall0pass, kyrias] +--- + +Add ability to apply budget prefill calculations to a single category. Includes Goal template support. -- GitLab