mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-11 12:43:09 -05:00
Add schedule end date/count field (#1899)
* 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
This commit is contained in:
committed by
GitHub
parent
f90fe04b2b
commit
c09a85f340
@@ -19,6 +19,7 @@ type SelectProps<Value extends string> = {
|
||||
style?: CSSProperties;
|
||||
wrapperStyle?: CSSProperties;
|
||||
line?: number;
|
||||
disabled?: boolean;
|
||||
disabledKeys?: Value[];
|
||||
};
|
||||
|
||||
@@ -46,6 +47,7 @@ export default function Select<Value extends string>({
|
||||
style,
|
||||
wrapperStyle,
|
||||
line,
|
||||
disabled,
|
||||
disabledKeys = [],
|
||||
}: SelectProps<Value>) {
|
||||
const arrowSize = 7;
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -57,6 +57,9 @@ function parseConfig(config) {
|
||||
patterns: [createMonthlyRecurrence(monthUtils.currentDay())],
|
||||
skipWeekend: false,
|
||||
weekendSolveMode: 'before',
|
||||
endMode: 'never',
|
||||
endOccurrences: '1',
|
||||
endDate: monthUtils.currentDay(),
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -65,6 +68,7 @@ function unparseConfig(parsed) {
|
||||
return {
|
||||
...parsed,
|
||||
interval: validInterval(parsed.interval),
|
||||
endOccurrences: validInterval(parsed.endOccurrences),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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' }}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
@@ -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);
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
@@ -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 } =
|
||||
@@ -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,
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
@@ -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,
|
||||
@@ -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);
|
||||
|
||||
@@ -9,149 +9,279 @@ describe('recurring date description', () => {
|
||||
|
||||
it('describes weekly interval', () => {
|
||||
expect(
|
||||
getRecurringDescription({ start: '2021-05-17', frequency: 'weekly' }),
|
||||
getRecurringDescription(
|
||||
{ start: '2021-05-17', frequency: 'weekly' },
|
||||
'MM/dd/yyyy',
|
||||
),
|
||||
).toBe('Every week on Monday');
|
||||
|
||||
expect(
|
||||
getRecurringDescription({
|
||||
start: '2021-05-17',
|
||||
frequency: 'weekly',
|
||||
interval: 2,
|
||||
}),
|
||||
getRecurringDescription(
|
||||
{
|
||||
start: '2021-05-17',
|
||||
frequency: 'weekly',
|
||||
interval: 2,
|
||||
},
|
||||
'MM/dd/yyyy',
|
||||
),
|
||||
).toBe('Every 2 weeks on Monday');
|
||||
});
|
||||
|
||||
it('describes monthly interval', () => {
|
||||
expect(
|
||||
getRecurringDescription({ start: '2021-04-25', frequency: 'monthly' }),
|
||||
getRecurringDescription(
|
||||
{ start: '2021-04-25', frequency: 'monthly' },
|
||||
'MM/dd/yyyy',
|
||||
),
|
||||
).toBe('Every month on the 25th');
|
||||
|
||||
expect(
|
||||
getRecurringDescription({
|
||||
start: '2021-04-25',
|
||||
frequency: 'monthly',
|
||||
interval: 2,
|
||||
}),
|
||||
getRecurringDescription(
|
||||
{
|
||||
start: '2021-04-25',
|
||||
frequency: 'monthly',
|
||||
interval: 2,
|
||||
},
|
||||
'MM/dd/yyyy',
|
||||
),
|
||||
).toBe('Every 2 months on the 25th');
|
||||
|
||||
expect(
|
||||
getRecurringDescription({
|
||||
start: '2021-04-25',
|
||||
frequency: 'monthly',
|
||||
patterns: [{ type: 'day', value: 25 }],
|
||||
}),
|
||||
getRecurringDescription(
|
||||
{
|
||||
start: '2021-04-25',
|
||||
frequency: 'monthly',
|
||||
patterns: [{ type: 'day', value: 25 }],
|
||||
},
|
||||
'MM/dd/yyyy',
|
||||
),
|
||||
).toBe('Every month on the 25th');
|
||||
|
||||
expect(
|
||||
getRecurringDescription({
|
||||
start: '2021-04-25',
|
||||
frequency: 'monthly',
|
||||
interval: 2,
|
||||
patterns: [{ type: 'day', value: 25 }],
|
||||
}),
|
||||
getRecurringDescription(
|
||||
{
|
||||
start: '2021-04-25',
|
||||
frequency: 'monthly',
|
||||
interval: 2,
|
||||
patterns: [{ type: 'day', value: 25 }],
|
||||
},
|
||||
'MM/dd/yyyy',
|
||||
),
|
||||
).toBe('Every 2 months on the 25th');
|
||||
|
||||
// Last day should work
|
||||
expect(
|
||||
getRecurringDescription({
|
||||
start: '2021-04-25',
|
||||
frequency: 'monthly',
|
||||
patterns: [{ type: 'day', value: 31 }],
|
||||
}),
|
||||
getRecurringDescription(
|
||||
{
|
||||
start: '2021-04-25',
|
||||
frequency: 'monthly',
|
||||
patterns: [{ type: 'day', value: 31 }],
|
||||
},
|
||||
'MM/dd/yyyy',
|
||||
),
|
||||
).toBe('Every month on the 31st');
|
||||
|
||||
// -1 should work, representing the last day
|
||||
expect(
|
||||
getRecurringDescription({
|
||||
start: '2021-04-25',
|
||||
frequency: 'monthly',
|
||||
patterns: [{ type: 'day', value: -1 }],
|
||||
}),
|
||||
getRecurringDescription(
|
||||
{
|
||||
start: '2021-04-25',
|
||||
frequency: 'monthly',
|
||||
patterns: [{ type: 'day', value: -1 }],
|
||||
},
|
||||
'MM/dd/yyyy',
|
||||
),
|
||||
).toBe('Every month on the last day');
|
||||
|
||||
// Day names should work
|
||||
expect(
|
||||
getRecurringDescription({
|
||||
start: '2021-04-25',
|
||||
frequency: 'monthly',
|
||||
patterns: [{ type: 'FR', value: 2 }],
|
||||
}),
|
||||
getRecurringDescription(
|
||||
{
|
||||
start: '2021-04-25',
|
||||
frequency: 'monthly',
|
||||
patterns: [{ type: 'FR', value: 2 }],
|
||||
},
|
||||
'MM/dd/yyyy',
|
||||
),
|
||||
).toBe('Every month on the 2nd Friday');
|
||||
|
||||
expect(
|
||||
getRecurringDescription({
|
||||
start: '2021-04-25',
|
||||
frequency: 'monthly',
|
||||
patterns: [{ type: 'FR', value: -1 }],
|
||||
}),
|
||||
getRecurringDescription(
|
||||
{
|
||||
start: '2021-04-25',
|
||||
frequency: 'monthly',
|
||||
patterns: [{ type: 'FR', value: -1 }],
|
||||
},
|
||||
'MM/dd/yyyy',
|
||||
),
|
||||
).toBe('Every month on the last Friday');
|
||||
});
|
||||
|
||||
it('describes monthly interval with multiple days', () => {
|
||||
// Note how order doesn't matter - the day should be sorted
|
||||
expect(
|
||||
getRecurringDescription({
|
||||
start: '2021-04-25',
|
||||
frequency: 'monthly',
|
||||
patterns: [
|
||||
{ type: 'day', value: 15 },
|
||||
{ type: 'day', value: 3 },
|
||||
{ type: 'day', value: 20 },
|
||||
],
|
||||
}),
|
||||
getRecurringDescription(
|
||||
{
|
||||
start: '2021-04-25',
|
||||
frequency: 'monthly',
|
||||
patterns: [
|
||||
{ type: 'day', value: 15 },
|
||||
{ type: 'day', value: 3 },
|
||||
{ type: 'day', value: 20 },
|
||||
],
|
||||
},
|
||||
'MM/dd/yyyy',
|
||||
),
|
||||
).toBe('Every month on the 3rd, 15th, and 20th');
|
||||
|
||||
expect(
|
||||
getRecurringDescription({
|
||||
start: '2021-04-25',
|
||||
frequency: 'monthly',
|
||||
patterns: [
|
||||
{ type: 'day', value: 3 },
|
||||
{ type: 'day', value: -1 },
|
||||
{ type: 'day', value: 20 },
|
||||
],
|
||||
}),
|
||||
getRecurringDescription(
|
||||
{
|
||||
start: '2021-04-25',
|
||||
frequency: 'monthly',
|
||||
patterns: [
|
||||
{ type: 'day', value: 3 },
|
||||
{ type: 'day', value: -1 },
|
||||
{ type: 'day', value: 20 },
|
||||
],
|
||||
},
|
||||
'MM/dd/yyyy',
|
||||
),
|
||||
).toBe('Every month on the 3rd, 20th, and last day');
|
||||
|
||||
// Mix days and day names
|
||||
expect(
|
||||
getRecurringDescription({
|
||||
start: '2021-04-25',
|
||||
frequency: 'monthly',
|
||||
patterns: [
|
||||
{ type: 'day', value: 3 },
|
||||
{ type: 'day', value: -1 },
|
||||
{ type: 'FR', value: 2 },
|
||||
],
|
||||
}),
|
||||
getRecurringDescription(
|
||||
{
|
||||
start: '2021-04-25',
|
||||
frequency: 'monthly',
|
||||
patterns: [
|
||||
{ type: 'day', value: 3 },
|
||||
{ type: 'day', value: -1 },
|
||||
{ type: 'FR', value: 2 },
|
||||
],
|
||||
},
|
||||
'MM/dd/yyyy',
|
||||
),
|
||||
).toBe('Every month on the 2nd Friday, 3rd, and last day');
|
||||
|
||||
// When there is a mixture of types, day names should always come first
|
||||
expect(
|
||||
getRecurringDescription({
|
||||
start: '2021-04-25',
|
||||
frequency: 'monthly',
|
||||
patterns: [
|
||||
{ type: 'SA', value: 1 },
|
||||
{ type: 'day', value: 2 },
|
||||
{ type: 'FR', value: 3 },
|
||||
{ type: 'day', value: 10 },
|
||||
],
|
||||
}),
|
||||
getRecurringDescription(
|
||||
{
|
||||
start: '2021-04-25',
|
||||
frequency: 'monthly',
|
||||
patterns: [
|
||||
{ type: 'SA', value: 1 },
|
||||
{ type: 'day', value: 2 },
|
||||
{ type: 'FR', value: 3 },
|
||||
{ type: 'day', value: 10 },
|
||||
],
|
||||
},
|
||||
'MM/dd/yyyy',
|
||||
),
|
||||
).toBe('Every month on the 1st Saturday, 3rd Friday, 2nd, and 10th');
|
||||
});
|
||||
|
||||
it('describes yearly interval', () => {
|
||||
expect(
|
||||
getRecurringDescription({ start: '2021-05-17', frequency: 'yearly' }),
|
||||
getRecurringDescription(
|
||||
{ start: '2021-05-17', frequency: 'yearly' },
|
||||
'MM/dd/yyyy',
|
||||
),
|
||||
).toBe('Every year on May 17th');
|
||||
|
||||
expect(
|
||||
getRecurringDescription({
|
||||
start: '2021-05-17',
|
||||
frequency: 'yearly',
|
||||
interval: 2,
|
||||
}),
|
||||
getRecurringDescription(
|
||||
{
|
||||
start: '2021-05-17',
|
||||
frequency: 'yearly',
|
||||
interval: 2,
|
||||
},
|
||||
'MM/dd/yyyy',
|
||||
),
|
||||
).toBe('Every 2 years on May 17th');
|
||||
});
|
||||
|
||||
it('describes intervals with limited occurrences', () => {
|
||||
expect(
|
||||
getRecurringDescription(
|
||||
{
|
||||
start: '2021-05-17',
|
||||
frequency: 'weekly',
|
||||
interval: 2,
|
||||
endMode: 'after_n_occurrences',
|
||||
endOccurrences: 2,
|
||||
},
|
||||
'MM/dd/yyyy',
|
||||
),
|
||||
).toBe('Every 2 weeks on Monday, 2 times');
|
||||
|
||||
expect(
|
||||
getRecurringDescription(
|
||||
{
|
||||
start: '2021-05-17',
|
||||
frequency: 'weekly',
|
||||
interval: 2,
|
||||
endMode: 'after_n_occurrences',
|
||||
endOccurrences: 1,
|
||||
},
|
||||
'MM/dd/yyyy',
|
||||
),
|
||||
).toBe('Every 2 weeks on Monday, once');
|
||||
|
||||
expect(
|
||||
getRecurringDescription(
|
||||
{
|
||||
start: '2021-05-17',
|
||||
frequency: 'monthly',
|
||||
interval: 2,
|
||||
endMode: 'after_n_occurrences',
|
||||
endOccurrences: 2,
|
||||
},
|
||||
'MM/dd/yyyy',
|
||||
),
|
||||
).toBe('Every 2 months on the 17th, 2 times');
|
||||
|
||||
expect(
|
||||
getRecurringDescription(
|
||||
{
|
||||
start: '2021-05-17',
|
||||
frequency: 'yearly',
|
||||
interval: 2,
|
||||
endMode: 'after_n_occurrences',
|
||||
endOccurrences: 2,
|
||||
},
|
||||
'MM/dd/yyyy',
|
||||
),
|
||||
).toBe('Every 2 years on May 17th, 2 times');
|
||||
});
|
||||
|
||||
it('describes intervals with an end date', () => {
|
||||
expect(
|
||||
getRecurringDescription(
|
||||
{
|
||||
start: '2021-05-17',
|
||||
frequency: 'weekly',
|
||||
interval: 2,
|
||||
endMode: 'on_date',
|
||||
endDate: '2021-06-01',
|
||||
},
|
||||
'MM/dd/yyyy',
|
||||
),
|
||||
).toBe('Every 2 weeks on Monday, until 06/01/2021');
|
||||
|
||||
expect(
|
||||
getRecurringDescription(
|
||||
{
|
||||
start: '2021-05-17',
|
||||
frequency: 'monthly',
|
||||
interval: 2,
|
||||
endMode: 'on_date',
|
||||
endDate: '2021-06-01',
|
||||
},
|
||||
'yyyy-MM-dd',
|
||||
),
|
||||
).toBe('Every 2 months on the 17th, until 2021-06-01');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,24 +63,43 @@ function prettyDayName(day) {
|
||||
return days[day];
|
||||
}
|
||||
|
||||
export function getRecurringDescription(config) {
|
||||
export function getRecurringDescription(config, dateFormat) {
|
||||
const interval = config.interval || 1;
|
||||
|
||||
let endModeSuffix = '';
|
||||
switch (config.endMode) {
|
||||
case 'after_n_occurrences':
|
||||
if (config.endOccurrences === 1) {
|
||||
endModeSuffix = `, once`;
|
||||
} else {
|
||||
endModeSuffix = `, ${config.endOccurrences} times`;
|
||||
}
|
||||
break;
|
||||
case 'on_date':
|
||||
endModeSuffix = `, until ${monthUtils.format(
|
||||
config.endDate,
|
||||
dateFormat,
|
||||
)}`;
|
||||
break;
|
||||
default:
|
||||
}
|
||||
|
||||
const weekendSolveSuffix = config.skipWeekend
|
||||
? ` (${config.weekendSolveMode} weekend) `
|
||||
: '';
|
||||
const suffix = endModeSuffix + weekendSolveSuffix;
|
||||
|
||||
switch (config.frequency) {
|
||||
case 'daily': {
|
||||
let desc = 'Every ';
|
||||
desc += interval !== 1 ? `${interval} days` : 'day';
|
||||
return desc + weekendSolveSuffix;
|
||||
return desc + suffix;
|
||||
}
|
||||
case 'weekly': {
|
||||
let desc = 'Every ';
|
||||
desc += interval !== 1 ? `${interval} weeks` : 'week';
|
||||
desc += ' on ' + monthUtils.format(config.start, 'EEEE');
|
||||
return desc + weekendSolveSuffix;
|
||||
return desc + suffix;
|
||||
}
|
||||
case 'monthly': {
|
||||
let desc = 'Every ';
|
||||
@@ -149,13 +168,13 @@ export function getRecurringDescription(config) {
|
||||
desc += ' on the ' + monthUtils.format(config.start, 'do');
|
||||
}
|
||||
|
||||
return desc + weekendSolveSuffix;
|
||||
return desc + suffix;
|
||||
}
|
||||
case 'yearly': {
|
||||
let desc = 'Every ';
|
||||
desc += interval !== 1 ? `${interval} years` : 'year';
|
||||
desc += ' on ' + monthUtils.format(config.start, 'LLL do');
|
||||
return desc + weekendSolveSuffix;
|
||||
return desc + suffix;
|
||||
}
|
||||
default:
|
||||
return 'Recurring error';
|
||||
@@ -173,6 +192,16 @@ export function recurConfigToRSchedule(config) {
|
||||
base.interval = config.interval;
|
||||
}
|
||||
|
||||
switch (config.endMode) {
|
||||
case 'after_n_occurrences':
|
||||
base.count = config.endOccurrences;
|
||||
break;
|
||||
case 'on_date':
|
||||
base.end = monthUtils.parseDate(config.endDate);
|
||||
break;
|
||||
default:
|
||||
}
|
||||
|
||||
const abbrevDay = name => name.slice(0, 2).toUpperCase();
|
||||
|
||||
switch (config.frequency) {
|
||||
|
||||
@@ -25,6 +25,9 @@ export interface ScheduleEntity {
|
||||
}[];
|
||||
skipWeekend: boolean;
|
||||
start: string;
|
||||
endMode: 'never' | 'after_n_occurrences' | 'on_date';
|
||||
endOccurrences: number;
|
||||
endDate: string;
|
||||
weekendSolveMode: 'before' | 'after';
|
||||
frequency: 'daily' | 'weekly' | 'monthly' | 'yearly';
|
||||
};
|
||||
|
||||
6
upcoming-release-notes/1899.md
Normal file
6
upcoming-release-notes/1899.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Features
|
||||
authors: [jfdoming]
|
||||
---
|
||||
|
||||
Add end date/max occurrences field to schedules, useful for things like installments
|
||||
Reference in New Issue
Block a user