diff --git a/packages/desktop-client/src/components/schedules/EditSchedule.js b/packages/desktop-client/src/components/schedules/EditSchedule.js index fa18ba4090f54f0e9bd557f69cd1721df33a3315..8d5361dc6395adc1c5ecf4b1b3ff9d6e2b991d55 100644 --- a/packages/desktop-client/src/components/schedules/EditSchedule.js +++ b/packages/desktop-client/src/components/schedules/EditSchedule.js @@ -25,6 +25,7 @@ import DateSelect from '../select/DateSelect'; import RecurringSchedulePicker from '../select/RecurringSchedulePicker'; import { SelectedItemsButton } from '../table'; import { AmountInput, BetweenAmountInput } from '../util/AmountInput'; +import GenericInput from '../util/GenericInput'; function mergeFields(defaults, initial) { let res = { ...defaults }; @@ -115,6 +116,7 @@ export default function ScheduleDetails() { amountOp: schedule._amountOp || 'isapprox', date: schedule._date, posts_transaction: action.schedule.posts_transaction, + name: schedule.name, }, }; } @@ -201,6 +203,7 @@ export default function ScheduleDetails() { amountOp: null, date: null, posts_transaction: false, + name: null, }, initialFields, ), @@ -341,6 +344,18 @@ export default function ScheduleDetails() { async function onSave() { dispatch({ type: 'form-error', error: null }); + if (state.fields.name) { + let { data: sameName } = await runQuery( + q('schedules').filter({ name: state.fields.name }).select('id'), + ); + if (sameName.length > 0 && sameName[0].id !== state.schedule.id) { + dispatch({ + type: 'form-error', + error: 'There is already a schedule with this name', + }); + return; + } + } let { error, conditions } = updateScheduleConditions( state.schedule, @@ -356,6 +371,7 @@ export default function ScheduleDetails() { schedule: { id: state.schedule.id, posts_transaction: state.fields.posts_transaction, + name: state.fields.name, }, conditions, }); @@ -431,6 +447,20 @@ export default function ScheduleDetails() { title={payee ? `Schedule: ${payee.name}` : 'Schedule'} modalSize="medium" > + <Stack direction="row" style={{ marginTop: 10 }}> + <FormField style={{ flex: 1 }}> + <FormLabel title="Schedule Name" htmlFor="name-field" /> + <GenericInput + field="string" + type="string" + value={state.fields.name} + multi={false} + onChange={e => { + dispatch({ type: 'set-field', field: 'name', value: e }); + }} + /> + </FormField> + </Stack> <Stack direction="row" style={{ marginTop: 20 }}> <FormField style={{ flex: 1 }}> <FormLabel title="Payee" htmlFor="payee-field" /> diff --git a/packages/desktop-client/src/components/schedules/SchedulesTable.js b/packages/desktop-client/src/components/schedules/SchedulesTable.js index 16a47be92a056f6a2d02d7b7483874ba6c04ee2c..dbc80154e960c724976f442d56088b9e7a91d4c6 100644 --- a/packages/desktop-client/src/components/schedules/SchedulesTable.js +++ b/packages/desktop-client/src/components/schedules/SchedulesTable.js @@ -195,6 +195,14 @@ export function SchedulesTable({ ':hover': { backgroundColor: colors.hover }, }} > + <Field width="flex" name="name"> + <Text + style={item.name == null ? { color: colors.n8 } : null} + title={item.name ? item.name : ''} + > + {item.name ? item.name : 'None'} + </Text> + </Field> <Field width="flex" name="payee"> <DisplayId type="payees" id={item._payee} /> </Field> @@ -263,6 +271,7 @@ export function SchedulesTable({ return ( <View style={[{ flex: 1 }, tableStyle]}> <TableHeader height={ROW_HEIGHT} inset={15} version="v2"> + <Field width="flex">Name</Field> <Field width="flex">Payee</Field> <Field width="flex">Account</Field> <Field width={110}>Next date</Field> diff --git a/packages/loot-core/migrations/1681115033845_add_schedule_name.sql b/packages/loot-core/migrations/1681115033845_add_schedule_name.sql new file mode 100644 index 0000000000000000000000000000000000000000..f175f94e73e39f2b47b9f6ae2c27d5672d1ea82a --- /dev/null +++ b/packages/loot-core/migrations/1681115033845_add_schedule_name.sql @@ -0,0 +1,5 @@ +BEGIN TRANSACTION; + +ALTER TABLE schedules ADD COLUMN name TEXT DEFAULT NULL; + +COMMIT; diff --git a/packages/loot-core/src/server/aql/schema/index.ts b/packages/loot-core/src/server/aql/schema/index.ts index ed74ff2142b1d0aee1703653a188a070e033c9a1..466de6c452daea8ccfefd1b9990a6e0f0a5a1375 100644 --- a/packages/loot-core/src/server/aql/schema/index.ts +++ b/packages/loot-core/src/server/aql/schema/index.ts @@ -85,6 +85,7 @@ export const schema = { }, schedules: { id: f('id'), + name: f('string'), rule: f('id', { ref: 'rules', required: true }), next_date: f('date'), completed: f('boolean'), diff --git a/packages/loot-core/src/server/budget/goal-template.pegjs b/packages/loot-core/src/server/budget/goal-template.pegjs index df5b31deb68ea8f3e3748f983dbf12fbdd058438..b54b3d31c7cc21eb1218f59c65c257dfb0b15df0 100644 --- a/packages/loot-core/src/server/budget/goal-template.pegjs +++ b/packages/loot-core/src/server/budget/goal-template.pegjs @@ -1,7 +1,7 @@ // https://pegjs.org expr - = percent: percent _ of _ category: $([^\n] *) + = percent: percent _ of _ category: $([^\r\n\t]+) { return { type: 'percentage', percent: +percent, category } } / amount: amount _ repeatEvery _ weeks: weekCount _ starting _ starting: date limit: limit? { return { type: 'week', amount, weeks, starting, limit } } @@ -17,6 +17,8 @@ expr { return { type: 'simple', monthly, limit } } / upTo _ limit: amount { return { type: 'simple', limit } } + / schedule _ name: name + { return { type: 'schedule', name} } repeat 'repeat interval' = 'month'i { return { annual: false } } @@ -39,6 +41,7 @@ of = 'of'i repeatEvery = 'repeat'i _ 'every'i starting = 'starting'i upTo = 'up'i _ 'to'i +schedule = 'schedule'i _ 'space' = ' '+ d 'digit' = [0-9] @@ -50,3 +53,5 @@ month 'month' = $(year '-' d d) day 'day' = $(d d) date = $(month '-' day) currencySymbol 'currency symbol' = symbol: . & { return /\p{Sc}/u.test(symbol) } + +name 'Name' = $([^\r\n\t]+) diff --git a/packages/loot-core/src/server/budget/goaltemplates.js b/packages/loot-core/src/server/budget/goaltemplates.js index 8d490e87792b4a6ae3f93cb3d05da8e4bce91b99..67ecd5c3f124f9c04bc3575374aea2775f2f7c17 100644 --- a/packages/loot-core/src/server/budget/goaltemplates.js +++ b/packages/loot-core/src/server/budget/goaltemplates.js @@ -2,12 +2,18 @@ import { differenceInCalendarMonths, addMonths, addWeeks, + addDays, format, } from 'date-fns'; import * as monthUtils from '../../shared/months'; +import { + extractScheduleConds, + getScheduledAmount, +} from '../../shared/schedules'; import { amountToInteger, integerToAmount } from '../../shared/util'; import * as db from '../db'; +import { getRuleForSchedule, getNextDate } from '../schedules/app'; import { setBudget, getSheetValue } from './actions'; import { parse } from './goal-template.pegjs'; @@ -131,6 +137,10 @@ async function getCategoryTemplates() { async function applyCategoryTemplate(category, template_lines, month, force) { let current_month = new Date(`${month}-01`); let errors = []; + let all_schedule_names = await db.all( + 'SELECT name from schedules WHERE name NOT NULL AND tombstone = 0', + ); + all_schedule_names = all_schedule_names.map(v => v.name); // remove lines for past dates, calculate repeating dates template_lines = template_lines.filter(template => { @@ -166,6 +176,12 @@ async function applyCategoryTemplate(category, template_lines, month, force) { template.from = format(spend_from, 'yyyy-MM'); } break; + case 'schedule': + if (!all_schedule_names.includes(template.name)) { + errors.push(`Schedule ${template.name} does not exist`); + return null; + } + break; default: } return true; @@ -323,6 +339,54 @@ async function applyCategoryTemplate(category, template_lines, month, force) { case 'error': return { errors }; default: + 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 isRepeating = + Object(dateCond.value) === dateCond.value && + 'frequency' in dateCond.value; + let next_date_string = getNextDate(dateCond, current_month); + let num_months = differenceInCalendarMonths( + new Date(next_date_string), + current_month, + ); + let target = -getScheduledAmount(amountCond.value); + let diff = target - balance + budgeted; + 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 > -1) { + to_budget += Math.round(diff / num_months); + } + } else { + let monthly_target = 0; + let next_month = addMonths(current_month, 1); + let next_date = new Date(next_date_string); + if (isRepeating) { + while (next_date.getTime() < next_month.getTime()) { + if (next_date.getTime() >= current_month.getTime()) { + monthly_target += target; + } + next_date = addDays(next_date, 1); + next_date_string = getNextDate(dateCond, next_date); + next_date = new Date(next_date_string); + } + } else { + monthly_target = target; + } + to_budget += monthly_target - balance + budgeted; + } + break; + } } } @@ -349,6 +413,6 @@ async function applyCategoryTemplate(category, template_lines, month, force) { integerToAmount(last_month_balance + to_budget); str += ' ' + template_lines.map(x => x.line).join('\n'); console.log(str); - return { amount: to_budget }; + return { amount: to_budget, errors }; } } diff --git a/packages/loot-core/src/server/schedules/app.js b/packages/loot-core/src/server/schedules/app.js index 663bb9a9d9ed3d51cb24c9ec3d69b05e6ce70bbb..dce9170f84afdbe3b908841bf174134f432e0e67 100644 --- a/packages/loot-core/src/server/schedules/app.js +++ b/packages/loot-core/src/server/schedules/app.js @@ -176,6 +176,20 @@ export async function setNextDate({ id, start, conditions, reset }) { // Methods +export async function checkIfScheduleExists(name, scheduleId) { + let idForName = await db.first('SELECT id from schedules WHERE name = ?', [ + name, + ]); + + if (idForName == null) { + return false; + } + if (scheduleId) { + return idForName['id'] !== scheduleId; + } + return true; +} + export async function createSchedule({ schedule, conditions = [] } = {}) { let scheduleId = (schedule && schedule.id) || uuid.v4Sync(); @@ -189,6 +203,15 @@ export async function createSchedule({ schedule, conditions = [] } = {}) { let nextDate = getNextDate(dateCond); let nextDateRepr = nextDate ? toDateRepr(nextDate) : null; + if (schedule) { + if (schedule.name) { + if (await checkIfScheduleExists(schedule.name, scheduleId)) { + throw new Error('Cannot create schedules with the same name'); + } + } else { + schedule.name = null; + } + } // Create the rule here based on the info let ruleId; @@ -224,6 +247,13 @@ export async function updateSchedule({ schedule, conditions, resetNextDate }) { throw new Error('You cannot change the rule of a schedule'); } + if (schedule.name) { + if (await checkIfScheduleExists(schedule.name, schedule.id)) { + throw new Error('There is already a schedule with this name'); + } + } else { + schedule.name = null; + } // We need the rule if there are conditions let rule; diff --git a/upcoming-release-notes/885.md b/upcoming-release-notes/885.md new file mode 100644 index 0000000000000000000000000000000000000000..ddd77291e9e0f1513d9294d8d4e341be63caecd8 --- /dev/null +++ b/upcoming-release-notes/885.md @@ -0,0 +1,6 @@ +--- +category: Features +authors: [pole95] +--- + +Add template keyword to budget according to named schedules