Goals: Schedule multi month forecasting (#1452)

References discord discussion starting here:
https://discord.com/channels/937901803608096828/940290142579605514/1133523705063030824

Currently the schedule keyword won't fill any future budget cells if the
category balance already satisfies the schedule. This PR is an attempt
to improve the behavior by allowing budget fills regardless of the
category balance.

This is a drastic rewrite of the schedule keyword. Though I've tried not
to have any regressions, it is possible because of how different the
logic is. I've tested compounding using a simple template, so a small
change in the 'by' keyword was also made.
This commit is contained in:
shall0pass
2023-08-05 14:26:27 -05:00
committed by GitHub
parent 293692d5c5
commit c581a8016c
2 changed files with 170 additions and 78 deletions

View File

@@ -1,9 +1,6 @@
import { Notification } from '../../client/state-types/notifications';
import * as monthUtils from '../../shared/months';
import {
extractScheduleConds,
getScheduledAmount,
} from '../../shared/schedules';
import { extractScheduleConds } from '../../shared/schedules';
import { amountToInteger, integerToAmount } from '../../shared/util';
import * as db from '../db';
import { getRuleForSchedule, getNextDate } from '../schedules/app';
@@ -156,9 +153,19 @@ async function processTemplate(month, force, category_templates) {
let isScheduleOrBy = false;
let priorityCheck = 0;
if (
template.filter(t => t.type === 'schedule' || t.type === 'by')
.length > 0
template.filter(
t =>
(t.type === 'schedule' || t.type === 'by') &&
t.priority === priority,
).length > 0
) {
template = template.filter(
t =>
(t.priority === priority &&
(t.type !== 'schedule' || t.type !== 'by')) ||
t.type === 'schedule' ||
t.type === 'by',
);
let { lowPriority, errorNotice } = await checkScheduleTemplates(
template,
);
@@ -322,6 +329,8 @@ async function applyCategoryTemplate(
);
all_schedule_names = all_schedule_names.map(v => v.name);
let scheduleFlag = false; //only run schedules portion once
// remove lines for past dates, calculate repeating dates
template_lines = template_lines.filter(template => {
switch (template.type) {
@@ -377,6 +386,8 @@ async function applyCategoryTemplate(
`${a.month}-01`,
`${b.month}-01`,
);
} else if (a.type === 'schedule' || b.type === 'schedule') {
return a.priority - b.priority;
} else {
return a.type.localeCompare(b.type);
}
@@ -411,7 +422,7 @@ async function applyCategoryTemplate(
} else {
increment = limit;
}
if (increment < budgetAvailable || !priority) {
if (to_budget + increment < budgetAvailable || !priority) {
to_budget += increment;
} else {
if (budgetAvailable > 0) to_budget += budgetAvailable;
@@ -608,82 +619,157 @@ async function applyCategoryTemplate(
break;
}
case 'schedule': {
let { id: schedule_id } = await db.first(
'SELECT id FROM schedules WHERE name = ?',
[template.name],
);
let rule = await getRuleForSchedule(schedule_id);
let conditions = rule.serialize().conditions;
let { date: dateCond, amount: amountCond } =
extractScheduleConds(conditions);
let next_date_string = getNextDate(
dateCond,
monthUtils._parse(current_month),
);
if (!scheduleFlag) {
scheduleFlag = true;
let template = template_lines.filter(t => t.type === 'schedule');
//in the case of multiple templates per category, schedules may have wrong priority level
let t = [];
let totalScheduledGoal = 0;
let isRepeating =
Object(dateCond.value) === dateCond.value &&
'frequency' in dateCond.value;
let num_months = monthUtils.differenceInCalendarMonths(
next_date_string,
current_month,
);
if (isRepeating) {
let monthlyTarget = 0;
let next_month = monthUtils.addMonths(current_month, num_months + 1);
let next_date = getNextDate(
dateCond,
monthUtils._parse(current_month),
);
while (next_date < next_month) {
monthlyTarget += amountCond.value;
next_date = monthUtils.addDays(next_date, 1);
next_date = getNextDate(dateCond, monthUtils._parse(next_date));
}
amountCond.value = monthlyTarget;
}
if (template.full === true || isReflectBudget()) {
if (num_months === 0) {
to_budget = -getScheduledAmount(amountCond.value);
}
if (isReflectBudget() && !template.full) {
errors.push(
`Report budgets require the full option for Schedules.`,
for (let ll = 0; ll < template.length; ll++) {
let { id: sid, completed: complete } = await db.first(
'SELECT * FROM schedules WHERE name = ?',
[template[ll].name],
);
console.log(complete);
let rule = await getRuleForSchedule(sid);
let conditions = rule.serialize().conditions;
let { date: dateConditions, amount: amountCondition } =
extractScheduleConds(conditions);
let target = -amountCondition.value;
let next_date_string = getNextDate(
dateConditions,
monthUtils._parse(current_month),
);
let target_interval = dateConditions.value.interval;
let target_frequency = dateConditions.value.frequency;
let isRepeating =
Object(dateConditions.value) === dateConditions.value &&
'frequency' in dateConditions.value;
let num_months = monthUtils.differenceInCalendarMonths(
next_date_string,
current_month,
);
t.push({
template: template[ll],
target: target,
next_date_string: next_date_string,
target_interval: target_interval,
target_frequency: target_frequency,
num_months: num_months,
completed: complete,
});
if (!complete) {
if (isRepeating) {
let monthlyTarget = 0;
let next_month = monthUtils.addMonths(
current_month,
t[ll].num_months + 1,
);
let next_date = getNextDate(
dateConditions,
monthUtils._parse(current_month),
);
while (next_date < next_month) {
monthlyTarget += amountCondition.value;
next_date = monthUtils.addDays(next_date, 1);
next_date = getNextDate(
dateConditions,
monthUtils._parse(next_date),
);
}
t[ll].target = -monthlyTarget;
totalScheduledGoal += target;
}
} else {
errors.push(
`Schedule ${t[ll].template.name} is a completed schedule.`,
);
}
}
break;
}
if (l === 0) remainder = last_month_balance;
remainder = -getScheduledAmount(amountCond.value) - remainder;
let target = 0;
if (remainder >= 0) {
target = remainder;
remainder = 0;
} else {
target = 0;
remainder = Math.abs(remainder);
}
let diff = num_months >= 0 ? Math.round(target / (num_months + 1)) : 0;
if (num_months < 0) {
errors.push(
`Non-repeating schedule ${template.name} was due on ${next_date_string}, which is in the past.`,
);
return { errors };
} else if (num_months >= 0) {
if (
(diff >= 0 &&
num_months >= 0 &&
to_budget + diff < budgetAvailable) ||
!priority
) {
t = t.filter(t => t.completed === 0);
t = t.sort((a, b) => b.target - a.target);
let diff = 0;
if (balance >= totalScheduledGoal) {
for (let ll = 0; ll < t.length; ll++) {
if (t[ll].num_months < 0) {
errors.push(
`Non-repeating schedule ${t[ll].template.name} was due on ${t[ll].next_date_string}, which is in the past.`,
);
break;
}
if (
(t[ll].template.full && t[ll].num_months === 0) ||
t[ll].target_frequency === 'weekly' ||
t[ll].target_frequency === 'daily'
) {
diff += t[ll].target;
} else if (t[ll].template.full && t[ll].num_months > 0) {
diff += 0;
} else {
diff += t[ll].target / t[ll].target_interval;
}
}
} else if (balance < totalScheduledGoal) {
for (let ll = 0; ll < t.length; ll++) {
if (isReflectBudget()) {
if (!t[ll].template.full) {
errors.push(
`Report budgets require the full option for Schedules.`,
);
break;
}
if (t[ll].template.full && t[ll].num_months === 0) {
to_budget += t[ll].target;
}
}
if (!isReflectBudget()) {
if (t[ll].num_months < 0) {
errors.push(
`Non-repeating schedule ${t[ll].template.name} was due on ${t[ll].next_date_string}, which is in the past.`,
);
break;
}
if (t[ll].template.full && t[ll].num_months > 0) {
remainder = 0;
} else if (ll === 0 && !t[ll].template.full) {
remainder = t[ll].target - last_month_balance;
} else {
remainder = t[ll].target - remainder;
}
let tg = 0;
if (remainder >= 0) {
tg = remainder;
remainder = 0;
} else {
tg = 0;
remainder = Math.abs(remainder);
}
if (
t[ll].template.full ||
t[ll].num_months === 0 ||
t[ll].target_frequency === 'weekly' ||
t[ll].target_frequency === 'daily'
) {
diff += tg;
} else if (t[ll].template.full && t[ll].num_months > 0) {
diff += 0;
} else {
diff += tg / (t[ll].num_months + 1);
}
}
}
}
diff = Math.round(diff);
if ((diff > 0 && to_budget + diff <= budgetAvailable) || !priority) {
to_budget += diff;
if (l === template_lines.length - 1) to_budget -= spent;
} else {
if (budgetAvailable > 0) to_budget = budgetAvailable;
} else if (
to_budget + diff > budgetAvailable &&
budgetAvailable >= 0
) {
to_budget = budgetAvailable;
errors.push(`Insufficient funds.`);
}
}

View File

@@ -0,0 +1,6 @@
---
category: Bugfix
authors: [shall0pass]
---
Goals: Schedules allow filling for future months