-
shall0pass authored
~This is really just a proof of concept. I have no delusions that this might get included. I'm sure others might have a much cleaner implementation.~ I'm now delusional. Resolves https://github.com/actualbudget/actual/issues/508 Taking @youngcw 's advice, I changed the keyword to #cleanup for the end of month script to keep it separated. This screen video shows two categories that are sources of funds. At the end of the month, any excess in these funds can be redistributed to your highest priorities. Three categories are set as sinks, or recipients, of excess funds. #cleanup source -> Move 'extra' funds to To Budget #cleanup sink -> Fund category with To Budget funds, default weight = 1 #cleanup sink 2 -> Fund category with To Budget funds, weight = 2 Steps of the script: 1. Return funds from any category marked 'source' 2. Fund overspent categories fully if negative carryover is not allowed. 3. Fund each 'sink' category by the desired weight. I run through the script twice. Once to show that if there is a debt category that has a rolling negative balance, it will skip funding that category first and once to show how if a rolling negative balance isn't allowed, it will fund it before applying the weighted remainder. The example shown uses weights of 60, 20, and 20; therefore, the Debt category will receive 60% of the To Budget funds while General and Bills receive 20% each. The weights could have been changed to 6, 2, and 2 or 3 for the Debt category with no additional value for General and Bills to achieve the same result. 
shall0pass authored~This is really just a proof of concept. I have no delusions that this might get included. I'm sure others might have a much cleaner implementation.~ I'm now delusional. Resolves https://github.com/actualbudget/actual/issues/508 Taking @youngcw 's advice, I changed the keyword to #cleanup for the end of month script to keep it separated. This screen video shows two categories that are sources of funds. At the end of the month, any excess in these funds can be redistributed to your highest priorities. Three categories are set as sinks, or recipients, of excess funds. #cleanup source -> Move 'extra' funds to To Budget #cleanup sink -> Fund category with To Budget funds, default weight = 1 #cleanup sink 2 -> Fund category with To Budget funds, weight = 2 Steps of the script: 1. Return funds from any category marked 'source' 2. Fund overspent categories fully if negative carryover is not allowed. 3. Fund each 'sink' category by the desired weight. I run through the script twice. Once to show that if there is a debt category that has a rolling negative balance, it will skip funding that category first and once to show how if a rolling negative balance isn't allowed, it will fund it before applying the weighted remainder. The example shown uses weights of 60, 20, and 20; therefore, the Debt category will receive 60% of the To Budget funds while General and Bills receive 20% each. The weights could have been changed to 6, 2, and 2 or 3 for the Debt category with no additional value for General and Bills to achieve the same result. 
BudgetSummary.tsx 12.84 KiB
import React, { useState } from 'react';
import { css } from 'glamor';
import { rolloverBudget } from 'loot-core/src/client/queries';
import * as monthUtils from 'loot-core/src/shared/months';
import DotsHorizontalTriple from '../../../icons/v1/DotsHorizontalTriple';
import ArrowButtonDown1 from '../../../icons/v2/ArrowButtonDown1';
import ArrowButtonUp1 from '../../../icons/v2/ArrowButtonUp1';
import { colors, styles } from '../../../style';
import {
View,
Block,
Button,
Tooltip,
Menu,
HoverTarget,
AlignedText,
} from '../../common';
import NotesButton from '../../NotesButton';
import CellValue from '../../spreadsheet/CellValue';
import format from '../../spreadsheet/format';
import NamespaceContext from '../../spreadsheet/NamespaceContext';
import SheetValue from '../../spreadsheet/SheetValue';
import { MONTH_BOX_SHADOW } from '../constants';
import HoldTooltip from './HoldTooltip';
import { useRollover } from './RolloverContext';
import TransferTooltip from './TransferTooltip';
type TotalsListProps = {
prevMonthName: string;
collapsed?: boolean;
};
function TotalsList({ prevMonthName, collapsed }: TotalsListProps) {
return (
<View
style={[
{
flexDirection: 'row',
lineHeight: 1.5,
justifyContent: 'center',
},
!collapsed && {
padding: '5px 0',
marginTop: 17,
backgroundColor: colors.n11,
borderTopWidth: 1,
borderBottomWidth: 1,
borderColor: colors.n9,
},
collapsed && {
padding: 7,
},
styles.smallText,
]}
>
<View
style={{
textAlign: 'right',
marginRight: 10,
minWidth: 50,
}}
>
<HoverTarget
style={{ flexShrink: 0 }}
renderContent={() => (
<Tooltip
width={200}
style={{ lineHeight: 1.5, padding: '6px 10px' }}
>
<AlignedText
left="Income:"
right={
<CellValue
binding={rolloverBudget.totalIncome}
type="financial"
/>
}
/>
<AlignedText
left="From Last Month:"
right={
<CellValue
binding={rolloverBudget.fromLastMonth}
type="financial"
/>
}
/>
</Tooltip>
)}
>
<CellValue
binding={rolloverBudget.incomeAvailable}
type="financial"
style={{ fontWeight: 600 }}
/>
</HoverTarget>
<CellValue
binding={rolloverBudget.lastMonthOverspent}
formatter={value => {
let v = format(value, 'financial');
return value > 0 ? '+' + v : value === 0 ? '-' + v : v;
}}
style={[{ fontWeight: 600 }, styles.tnum]}
/>
<CellValue
binding={rolloverBudget.totalBudgeted}
formatter={value => {
let v = format(value, 'financial');
return value > 0 ? '+' + v : value === 0 ? '-' + v : v;
}}
style={[{ fontWeight: 600 }, styles.tnum]}
/>
<CellValue
binding={rolloverBudget.forNextMonth}
formatter={value => {
let n = parseInt(value) || 0;
let v = format(Math.abs(n), 'financial');
return n >= 0 ? '-' + v : '+' + v;
}}
style={[{ fontWeight: 600 }, styles.tnum]}
/>
</View>
<View>
<Block>Available Funds</Block>
<Block>Overspent in {prevMonthName}</Block>
<Block>Budgeted</Block>
<Block>For Next Month</Block>
</View>
</View>
);
}
type ToBudgetProps = {
month: string | number;
prevMonthName?: string;
collapsed?: boolean;
onBudgetAction: (idx: string | number, action: string, arg?: unknown) => void;
};
function ToBudget({
month,
prevMonthName,
collapsed,
onBudgetAction,
}: ToBudgetProps) {
let [menuOpen, setMenuOpen] = useState(null);
return (
<SheetValue binding={rolloverBudget.toBudget} initialValue={0}>
{node => {
const availableValue = parseInt(node.value);
const num = isNaN(availableValue) ? 0 : availableValue;
const isNegative = num < 0;
return (
<View style={{ alignItems: 'center' }}>
<Block>{isNegative ? 'Overbudgeted:' : 'To Budget:'}</Block>
<View>
<HoverTarget
disabled={!collapsed || menuOpen}
renderContent={() => (
<Tooltip position="bottom-center">
<TotalsList
collapsed={true}
prevMonthName={prevMonthName}
/>
</Tooltip>
)}
>
<Block
onClick={() => setMenuOpen('actions')}
data-cellname={node.name}
{...css([
styles.veryLargeText,
{
fontWeight: 400,
userSelect: 'none',
cursor: 'pointer',
color: isNegative ? colors.r4 : colors.p5,
marginBottom: -1,
borderBottom: '1px solid transparent',
':hover': {
borderColor: isNegative ? colors.r4 : colors.p5,
},
},
])}
>
{format(num, 'financial')}
</Block>
</HoverTarget>
{menuOpen === 'actions' && (
<Tooltip
position="bottom-center"
width={200}
style={{ padding: 0 }}
onClose={() => setMenuOpen(null)}
>
<Menu
onMenuSelect={type => {
if (type === 'reset-buffer') {
onBudgetAction(month, 'reset-hold');
setMenuOpen(null);
} else {
setMenuOpen(type);
}
}}
items={[
{
name: 'transfer',
text: 'Move to a category',
},
{
name: 'buffer',
text: 'Hold for next month',
},
{
name: 'reset-buffer',
text: 'Reset next month’s buffer',
},
]}
/>
</Tooltip>
)}
{menuOpen === 'buffer' && (
<HoldTooltip
onClose={() => setMenuOpen(null)}
onSubmit={amount => {
onBudgetAction(month, 'hold', { amount });
}}
/>
)}
{menuOpen === 'transfer' && (
<TransferTooltip
initialAmount={availableValue}
onClose={() => setMenuOpen(null)}
onSubmit={(amount, category) => {
onBudgetAction(month, 'transfer-available', {
amount,
category,
});
}}
/>
)}
</View>
</View>
);
}}
</SheetValue>
);
}
type BudgetSummaryProps = {
month: string | number;
isGoalTemplatesEnabled: boolean;
};
export function BudgetSummary({
month,
isGoalTemplatesEnabled,
}: BudgetSummaryProps) {
let {
currentMonth,
summaryCollapsed: collapsed,
onBudgetAction,
onToggleSummaryCollapse,
} = useRollover();
let [menuOpen, setMenuOpen] = useState(false);
function onMenuOpen(e) {
setMenuOpen(true);
}
function onMenuClose() {
setMenuOpen(false);
}
let prevMonthName = monthUtils.format(monthUtils.prevMonth(month), 'MMM');
let ExpandOrCollapseIcon = collapsed ? ArrowButtonDown1 : ArrowButtonUp1;
return (
<View
data-testid="budget-summary"
style={{
backgroundColor: 'white',
boxShadow: MONTH_BOX_SHADOW,
borderRadius: 6,
marginLeft: 0,
marginRight: 0,
marginTop: 5,
flex: 1,
cursor: 'default',
marginBottom: 5,
overflow: 'hidden',
'& .hover-visible': {
opacity: 0,
transition: 'opacity .25s',
},
'&:hover .hover-visible': {
opacity: 1,
},
}}
>
<NamespaceContext.Provider value={monthUtils.sheetForMonth(month)}>
<View
style={[
{ padding: '0 13px' },
collapsed ? { margin: '10px 0' } : { marginTop: 16 },
]}
>
<View
style={{
position: 'absolute',
left: 10,
top: 0,
}}
>
<Button
className="hover-visible"
bare
onClick={onToggleSummaryCollapse}
>
<ExpandOrCollapseIcon
width={13}
height={13}
// The margin is to make it the exact same size as the dots button
style={{ color: colors.n6, margin: 1 }}
/>
</Button>
</View>
<div
{...css([
{
textAlign: 'center',
marginTop: 3,
fontSize: 18,
fontWeight: 500,
textDecorationSkip: 'ink',
},
currentMonth === month && { textDecoration: 'underline' },
])}
>
{monthUtils.format(month, 'MMMM')}
</div>
<View
style={{
position: 'absolute',
right: 10,
top: 0,
flexDirection: 'row',
alignItems: 'center',
}}
>
<View>
<NotesButton
id={`budget-${month}`}
width={15}
height={15}
tooltipPosition="bottom-right"
defaultColor={colors.n6}
/>
</View>
<View style={{ userSelect: 'none', marginLeft: 2 }}>
<Button bare onClick={onMenuOpen}>
<DotsHorizontalTriple
width={15}
height={15}
style={{ color: colors.n5 }}
/>
</Button>
{menuOpen && (
<Tooltip
position="bottom-right"
width={200}
style={{ padding: 0 }}
onClose={onMenuClose}
>
<Menu
onMenuSelect={type => {
onMenuClose();
onBudgetAction(month, type);
}}
items={[
{ name: 'copy-last', text: 'Copy last month’s budget' },
{ name: 'set-zero', text: 'Set budgets to zero' },
{
name: 'set-3-avg',
text: 'Set budgets to 3 month avg',
},
isGoalTemplatesEnabled && {
name: 'apply-goal-template',
text: 'Apply budget template',
},
isGoalTemplatesEnabled && {
name: 'overwrite-goal-template',
text: 'Overwrite with budget template',
},
isGoalTemplatesEnabled && {
name: 'cleanup-goal-template',
text: 'End of month cleanup',
},
]}
/>
</Tooltip>
)}
</View>
</View>
</View>
{collapsed ? (
<View
style={{
alignItems: 'center',
padding: '10px 20px',
justifyContent: 'space-between',
backgroundColor: colors.n11,
borderTop: '1px solid ' + colors.n10,
}}
>
<ToBudget
collapsed={collapsed}
prevMonthName={prevMonthName}
month={month}
onBudgetAction={onBudgetAction}
/>
</View>
) : (
<>
<TotalsList prevMonthName={prevMonthName} />
<View style={{ margin: '23px 0' }}>
<ToBudget month={month} onBudgetAction={onBudgetAction} />
</View>
</>
)}
</NamespaceContext.Provider>
</View>
);
}