Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add schedule end date/count field #1899

Merged
merged 19 commits into from
Dec 14, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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