Skip to content
Snippets Groups Projects
Unverified Commit c581a801 authored by shall0pass's avatar shall0pass Committed by GitHub
Browse files

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.
parent 293692d5
No related branches found
No related tags found
No related merge requests found
import { Notification } from '../../client/state-types/notifications'; import { Notification } from '../../client/state-types/notifications';
import * as monthUtils from '../../shared/months'; import * as monthUtils from '../../shared/months';
import { import { extractScheduleConds } from '../../shared/schedules';
extractScheduleConds,
getScheduledAmount,
} from '../../shared/schedules';
import { amountToInteger, integerToAmount } from '../../shared/util'; import { amountToInteger, integerToAmount } from '../../shared/util';
import * as db from '../db'; import * as db from '../db';
import { getRuleForSchedule, getNextDate } from '../schedules/app'; import { getRuleForSchedule, getNextDate } from '../schedules/app';
...@@ -156,9 +153,19 @@ async function processTemplate(month, force, category_templates) { ...@@ -156,9 +153,19 @@ async function processTemplate(month, force, category_templates) {
let isScheduleOrBy = false; let isScheduleOrBy = false;
let priorityCheck = 0; let priorityCheck = 0;
if ( if (
template.filter(t => t.type === 'schedule' || t.type === 'by') template.filter(
.length > 0 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( let { lowPriority, errorNotice } = await checkScheduleTemplates(
template, template,
); );
...@@ -322,6 +329,8 @@ async function applyCategoryTemplate( ...@@ -322,6 +329,8 @@ async function applyCategoryTemplate(
); );
all_schedule_names = all_schedule_names.map(v => v.name); 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 // remove lines for past dates, calculate repeating dates
template_lines = template_lines.filter(template => { template_lines = template_lines.filter(template => {
switch (template.type) { switch (template.type) {
...@@ -377,6 +386,8 @@ async function applyCategoryTemplate( ...@@ -377,6 +386,8 @@ async function applyCategoryTemplate(
`${a.month}-01`, `${a.month}-01`,
`${b.month}-01`, `${b.month}-01`,
); );
} else if (a.type === 'schedule' || b.type === 'schedule') {
return a.priority - b.priority;
} else { } else {
return a.type.localeCompare(b.type); return a.type.localeCompare(b.type);
} }
...@@ -411,7 +422,7 @@ async function applyCategoryTemplate( ...@@ -411,7 +422,7 @@ async function applyCategoryTemplate(
} else { } else {
increment = limit; increment = limit;
} }
if (increment < budgetAvailable || !priority) { if (to_budget + increment < budgetAvailable || !priority) {
to_budget += increment; to_budget += increment;
} else { } else {
if (budgetAvailable > 0) to_budget += budgetAvailable; if (budgetAvailable > 0) to_budget += budgetAvailable;
...@@ -608,82 +619,157 @@ async function applyCategoryTemplate( ...@@ -608,82 +619,157 @@ async function applyCategoryTemplate(
break; break;
} }
case 'schedule': { case 'schedule': {
let { id: schedule_id } = await db.first( if (!scheduleFlag) {
'SELECT id FROM schedules WHERE name = ?', scheduleFlag = true;
[template.name], let template = template_lines.filter(t => t.type === 'schedule');
); //in the case of multiple templates per category, schedules may have wrong priority level
let rule = await getRuleForSchedule(schedule_id); let t = [];
let conditions = rule.serialize().conditions; let totalScheduledGoal = 0;
let { date: dateCond, amount: amountCond } =
extractScheduleConds(conditions); for (let ll = 0; ll < template.length; ll++) {
let next_date_string = getNextDate( let { id: sid, completed: complete } = await db.first(
dateCond, 'SELECT * FROM schedules WHERE name = ?',
monthUtils._parse(current_month), [template[ll].name],
); );
console.log(complete);
let isRepeating = let rule = await getRuleForSchedule(sid);
Object(dateCond.value) === dateCond.value && let conditions = rule.serialize().conditions;
'frequency' in dateCond.value; let { date: dateConditions, amount: amountCondition } =
extractScheduleConds(conditions);
let num_months = monthUtils.differenceInCalendarMonths( let target = -amountCondition.value;
next_date_string, let next_date_string = getNextDate(
current_month, dateConditions,
); monthUtils._parse(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.`,
); );
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; t = t.filter(t => t.completed === 0);
remainder = -getScheduledAmount(amountCond.value) - remainder; t = t.sort((a, b) => b.target - a.target);
let target = 0;
if (remainder >= 0) { let diff = 0;
target = remainder; if (balance >= totalScheduledGoal) {
remainder = 0; for (let ll = 0; ll < t.length; ll++) {
} else { if (t[ll].num_months < 0) {
target = 0; errors.push(
remainder = Math.abs(remainder); `Non-repeating schedule ${t[ll].template.name} was due on ${t[ll].next_date_string}, which is in the past.`,
} );
let diff = num_months >= 0 ? Math.round(target / (num_months + 1)) : 0; break;
if (num_months < 0) { }
errors.push( if (
`Non-repeating schedule ${template.name} was due on ${next_date_string}, which is in the past.`, (t[ll].template.full && t[ll].num_months === 0) ||
); t[ll].target_frequency === 'weekly' ||
return { errors }; t[ll].target_frequency === 'daily'
} else if (num_months >= 0) { ) {
if ( diff += t[ll].target;
(diff >= 0 && } else if (t[ll].template.full && t[ll].num_months > 0) {
num_months >= 0 && diff += 0;
to_budget + diff < budgetAvailable) || } else {
!priority 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; to_budget += diff;
if (l === template_lines.length - 1) to_budget -= spent; } else if (
} else { to_budget + diff > budgetAvailable &&
if (budgetAvailable > 0) to_budget = budgetAvailable; budgetAvailable >= 0
) {
to_budget = budgetAvailable;
errors.push(`Insufficient funds.`); errors.push(`Insufficient funds.`);
} }
} }
......
---
category: Bugfix
authors: [shall0pass]
---
Goals: Schedules allow filling for future months
\ No newline at end of file
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment