Skip to content

Commit

Permalink
Add schedule end date/count field (actualbudget#1899)
Browse files Browse the repository at this point in the history
* Add "end" field with date/count options

* Use "end" field to generate schedule

* Show "end" field in recurring description

* Disable weekend before/after picker when not enabled

* Add release notes

* Fix failing typechecks

* Add some description tests

* PR feedback

* 'Features', not 'Feature'

* Fix goal templates infinite loop

* Empty commit to bump ci

* Fix bug where schedule templates in the past would apply incorrectly

For example, if you had a schedule which started in November 2023 for
1.00, and you applied the schedule in October 2023, then you would end
up with a value of 0.50 applied in October.

* Fix handling of schedules with an end date

This commit also includes a refactor of the skip-weekend logic: rather
than referring only to dates with skipped weekends (which requires
checking whether the "next date" request worked correctly), we track a
"base date" which is the previous value of the schedule according to the
rrule, excluding any weekend-skipping. This lets us use `addDays(baseDate, 1)`
to get the next occurrence, regardless of the weekend behaviour.

Doing things this way ensures that the loop will always make progress.

* Only compute skipped weekend if weekend skips were requested

* Fix typo in iterate-schedule-occurrences code

We should be using `nextBaseDate` to derive the next base date, not
`nextDate`; this is because we want the base date to be guaranteed to
make progress in each loop iteration, so we can finish in at most 30
iterations without duplicate base dates.

* Use const

* Revert const -> let for one mutable variable
  • Loading branch information
jfdoming authored Dec 14, 2023
1 parent d12b57d commit 39d2846
Show file tree
Hide file tree
Showing 11 changed files with 391 additions and 126 deletions.
3 changes: 3 additions & 0 deletions packages/desktop-client/src/components/common/Select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ type SelectProps<Value extends string> = {
style?: CSSProperties;
wrapperStyle?: CSSProperties;
line?: number;
disabled?: boolean;
disabledKeys?: Value[];
};

Expand Down Expand Up @@ -46,6 +47,7 @@ export default function Select<Value extends string>({
style,
wrapperStyle,
line,
disabled,
disabledKeys = [],
}: SelectProps<Value>) {
const arrowSize = 7;
Expand All @@ -55,6 +57,7 @@ export default function Select<Value extends string>({
<ListboxInput
value={value}
onChange={onChange}
disabled={disabled}
style={{
color: bare ? 'inherit' : theme.formInputText,
backgroundColor: bare ? 'transparent' : theme.cardBackground,
Expand Down
2 changes: 1 addition & 1 deletion packages/desktop-client/src/components/rules/Value.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ export default function Value<T>({
case 'date':
if (value) {
if (value.frequency) {
return getRecurringDescription(value);
return getRecurringDescription(value, dateFormat);
}
return formatDate(parseISO(value), dateFormat);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React, { useState } from 'react';
import { useSelector } from 'react-redux';

import q, { runQuery } from 'loot-core/src/client/query-helpers';
import { send } from 'loot-core/src/platform/client/fetch';
Expand Down Expand Up @@ -35,11 +36,14 @@ function DiscoverSchedulesTable({
}) {
const selectedItems = useSelectedItems();
const dispatchSelected = useSelectedDispatch();
const dateFormat = useSelector(
state => state.prefs.local.dateFormat || 'MM/dd/yyyy',
);

function renderItem({ item }: { item: DiscoverScheduleEntity }) {
const selected = selectedItems.has(item.id);
const amountOp = item._conditions.find(c => c.field === 'amount').op;
const recurDescription = getRecurringDescription(item.date);
const recurDescription = getRecurringDescription(item.date, dateFormat);

return (
<Row
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,9 @@ export default function ScheduleDetails({ modalProps, actions, id }) {
patterns: [],
skipWeekend: false,
weekendSolveMode: 'after',
endMode: 'never',
endOccurrences: '1',
endDate: monthUtils.currentDay(),
};
const schedule = {
posts_transaction: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ function parseConfig(config) {
patterns: [createMonthlyRecurrence(monthUtils.currentDay())],
skipWeekend: false,
weekendSolveMode: 'before',
endMode: 'never',
endOccurrences: '1',
endDate: monthUtils.currentDay(),
}
);
}
Expand All @@ -65,6 +68,7 @@ function unparseConfig(parsed) {
return {
...parsed,
interval: validInterval(parsed.interval),
endOccurrences: validInterval(parsed.endOccurrences),
};
}

Expand Down Expand Up @@ -316,10 +320,8 @@ function RecurringScheduleTooltip({ config: currentConfig, onClose, onSave }) {
position="bottom-left"
onClose={onClose}
>
<div style={{ display: 'flex', alignItems: 'center' }}>
<label htmlFor="start" style={{ marginRight: 5 }}>
Starts:
</label>
<div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
<label htmlFor="start">From</label>
<DateSelect
id="start"
inputProps={{ placeholder: 'Start Date' }}
Expand All @@ -328,21 +330,59 @@ function RecurringScheduleTooltip({ config: currentConfig, onClose, onSave }) {
containerProps={{ style: { width: 100 } }}
dateFormat={dateFormat}
/>
<Select
id="repeat_end_dropdown"
bare
options={[
['never', 'indefinitely'],
['after_n_occurrences', 'for'],
['on_date', 'until'],
]}
value={config.endMode}
onChange={value => updateField('endMode', value)}
style={{
border: '1px solid ' + theme.formInputBorder,
height: 27.5,
}}
/>
{config.endMode === 'after_n_occurrences' && (
<>
<Input
id="end_occurrences"
style={{ width: 40 }}
type="number"
min={1}
onChange={e => updateField('endOccurrences', e.target.value)}
defaultValue={config.endOccurrences || 1}
/>
<Text>occurrence{config.endOccurrences === '1' ? '' : 's'}</Text>
</>
)}
{config.endMode === 'on_date' && (
<DateSelect
id="end_date"
inputProps={{ placeholder: 'End Date' }}
value={config.endDate}
onSelect={value => updateField('endDate', value)}
containerProps={{ style: { width: 100 } }}
dateFormat={dateFormat}
/>
)}
</div>
<Stack
direction="row"
align="center"
justify="flex-start"
style={{ marginTop: 10 }}
spacing={2}
spacing={1}
>
<Text style={{ whiteSpace: 'nowrap' }}>Repeat every</Text>
<Input
id="interval"
style={{ width: 40 }}
type="text"
onBlur={e => updateField('interval', e.target.value)}
onEnter={e => updateField('interval', e.target.value)}
type="number"
min={1}
onChange={e => updateField('interval', e.target.value)}
defaultValue={config.interval || 1}
/>
<Select
Expand Down Expand Up @@ -392,7 +432,10 @@ function RecurringScheduleTooltip({ config: currentConfig, onClose, onSave }) {
/>
<label
htmlFor="form_skipwe"
style={{ userSelect: 'none', marginRight: 5 }}
style={{
userSelect: 'none',
marginRight: 5,
}}
>
Move schedule{' '}
</label>
Expand All @@ -404,6 +447,7 @@ function RecurringScheduleTooltip({ config: currentConfig, onClose, onSave }) {
]}
value={state.config.weekendSolveMode}
onChange={value => dispatch({ type: 'set-weekend-solve', value })}
disabled={!skipWeekend}
style={{
minHeight: '1px',
width: '5rem',
Expand Down Expand Up @@ -441,6 +485,9 @@ export default function RecurringSchedulePicker({
onChange,
}) {
const { isOpen, close, getOpenEvents } = useTooltip();
const dateFormat = useSelector(
state => state.prefs.local.dateFormat || 'MM/dd/yyyy',
);

function onSave(config) {
onChange(config);
Expand All @@ -453,7 +500,9 @@ export default function RecurringSchedulePicker({
{...getOpenEvents()}
style={{ textAlign: 'left', ...buttonStyle }}
>
{value ? getRecurringDescription(value) : 'No recurring date'}
{value
? getRecurringDescription(value, dateFormat)
: 'No recurring date'}
</Button>
{isOpen && (
<RecurringScheduleTooltip
Expand Down
63 changes: 44 additions & 19 deletions packages/loot-core/src/server/budget/goals/goalsSchedule.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import * as monthUtils from '../../../shared/months';
import { extractScheduleConds } from '../../../shared/schedules';
import * as db from '../../db';
import { getRuleForSchedule, getNextDate } from '../../schedules/app';
import {
getRuleForSchedule,
getNextDate,
getDateWithSkippedWeekend,
} from '../../schedules/app';
import { isReflectBudget } from '../actions';

export async function goalsSchedule(
Expand All @@ -26,7 +30,6 @@ export async function goalsSchedule(
'SELECT * FROM schedules WHERE name = ?',
[template[ll].name],
);
console.log(complete);
const rule = await getRuleForSchedule(sid);
const conditions = rule.serialize().conditions;
const { date: dateConditions, amount: amountCondition } =
Expand All @@ -52,6 +55,8 @@ export async function goalsSchedule(
next_date_string,
current_month,
);
const startDate = dateConditions.value.start ?? dateConditions.value;
const started = startDate <= monthUtils.addMonths(current_month, 1);
t.push({
template: template[ll],
target,
Expand All @@ -60,47 +65,67 @@ export async function goalsSchedule(
target_frequency,
num_months,
completed: complete,
started,
});
if (!complete) {
if (!complete && started) {
if (isRepeating) {
let monthlyTarget = 0;
const next_month = monthUtils.addMonths(
const nextMonth = monthUtils.addMonths(
current_month,
t[ll].num_months + 1,
);
let next_date = getNextDate(
let nextBaseDate = getNextDate(
dateConditions,
monthUtils._parse(current_month),
true,
);
while (next_date < next_month) {

let nextDate = dateConditions.value.skipWeekend
? monthUtils.dayFromDate(
getDateWithSkippedWeekend(
monthUtils._parse(nextBaseDate),
dateConditions.value.weekendSolveMode,
),
)
: nextBaseDate;

while (nextDate < nextMonth) {
monthlyTarget += -target;
const current_date = next_date;
next_date = monthUtils.addDays(next_date, 1);
next_date = getNextDate(
const currentDate = nextBaseDate;
const oneDayLater = monthUtils.addDays(nextBaseDate, 1);
nextBaseDate = getNextDate(
dateConditions,
monthUtils._parse(next_date),
monthUtils._parse(oneDayLater),
true,
);
nextDate = dateConditions.value.skipWeekend
? monthUtils.dayFromDate(
getDateWithSkippedWeekend(
monthUtils._parse(nextBaseDate),
dateConditions.value.weekendSolveMode,
),
)
: nextBaseDate;
const diffDays = monthUtils.differenceInCalendarDays(
next_date,
current_date,
nextBaseDate,
currentDate,
);
if (!diffDays) {
next_date = monthUtils.addDays(next_date, 3);
next_date = getNextDate(
dateConditions,
monthUtils._parse(next_date),
);
// This can happen if the schedule has an end condition.
break;
}
}
t[ll].target = -monthlyTarget;
totalScheduledGoal += target;
}
} else {
errors.push(`Schedule ${t[ll].template.name} is a completed schedule.`);
errors.push(
`Schedule ${t[ll].template.name} is not active during the month in question.`,
);
}
}

t = t.filter(t => t.completed === 0);
t = t.filter(t => t.completed === 0 && t.started);
t = t.sort((a, b) => b.target - a.target);

let increment = 0;
Expand Down
21 changes: 17 additions & 4 deletions packages/loot-core/src/server/schedules/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,11 @@ export function updateConditions(conditions, newConditions) {
return updated.concat(added);
}

export function getNextDate(dateCond, start = new Date(currentDay())) {
export function getNextDate(
dateCond,
start = new Date(currentDay()),
noSkipWeekend = false,
) {
start = d.startOfDay(start);

const cond = new Condition(
Expand All @@ -80,11 +84,17 @@ export function getNextDate(dateCond, start = new Date(currentDay())) {
if (value.type === 'date') {
return value.date;
} else if (value.type === 'recur') {
const dates = value.schedule.occurrences({ start, take: 1 }).toArray();
let dates = value.schedule.occurrences({ start, take: 1 }).toArray();

if (dates.length === 0) {
// Could be a schedule with limited occurrences, so we try to
// find the last occurrence
dates = value.schedule.occurrences({ reverse: true, take: 1 }).toArray();
}

if (dates.length > 0) {
let date = dates[0].date;
if (value.schedule.data.skipWeekend) {
if (value.schedule.data.skipWeekend && !noSkipWeekend) {
date = getDateWithSkippedWeekend(
date,
value.schedule.data.weekendSolve,
Expand Down Expand Up @@ -567,7 +577,10 @@ app.events.on('sync', ({ type, subtype }) => {
}
});

function getDateWithSkippedWeekend(date, solveMode) {
export function getDateWithSkippedWeekend(
date: Date,
solveMode: 'after' | 'before',
) {
if (d.isWeekend(date)) {
if (solveMode === 'after') {
return d.nextMonday(date);
Expand Down
Loading

0 comments on commit 39d2846

Please sign in to comment.