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

fix: Improve next-date calculation for monthly and yearly recurring tasks #1197

Merged
merged 31 commits into from
Oct 8, 2022
Merged
Show file tree
Hide file tree
Changes from 29 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
a20fd20
Ensure monthly and yearly recurrences
schemar Sep 30, 2022
f64be7d
Adding code comments
schemar Sep 30, 2022
1ac2ffd
Adding tests from @claremacrae
schemar Sep 30, 2022
9b4ac16
Fixed edge cases across years
schemar Sep 30, 2022
1e17359
Document recurrence edge cases
schemar Sep 30, 2022
e698ce4
Merge branch 'main' into monthly_recurrence
claremacrae Oct 3, 2022
13afc74
test: Test recurrence corrects next date with 'when done'
claremacrae Oct 3, 2022
b9c9335
vault: Add Recurrence handling invalid dates.md
claremacrae Oct 3, 2022
785a687
vault: Capture current behaviour of 'very month on the'
claremacrae Oct 3, 2022
3f2eca6
vault: Simplify re-testing of recurrence on fixed days
claremacrae Oct 3, 2022
a24abbf
vault: Demonstrate a bug that 'every month on the 31st' gets stuck
claremacrae Oct 3, 2022
a720af4
vault: Remove misleading task descriptions
claremacrae Oct 3, 2022
e3bbc5d
vault: Enable outline core plugin
claremacrae Oct 3, 2022
fdacb07
vault: Set up manual testing of 'every month on the last'
claremacrae Oct 3, 2022
7e3b7b0
vault: Capture current behaviour of 'every month on the last'
claremacrae Oct 3, 2022
d030c25
vault: Capture new behaviour of 'every month on the last'
claremacrae Oct 3, 2022
fc93ffd
test: Guard against recurrence tests with no expectations set
claremacrae Oct 3, 2022
accc340
work in progress on recurring - do
claremacrae Oct 3, 2022
74ace8d
work in progress on recurring - do
claremacrae Oct 3, 2022
4c9bae9
docs: Document behavior if reference date is invalid
claremacrae Oct 3, 2022
a599be8
docs: Remove obsolete instruction about ordering of recurrence rule.
claremacrae Oct 6, 2022
ec4ad8b
fix: Prevent 'every month on the 31st' sticking on 31st January
claremacrae Oct 6, 2022
affa9dc
vault: Update sample file with fixed behavior for 'every month on the…
claremacrae Oct 6, 2022
ef51126
docs: Add major new section: 'How the New Date is Calculated: Repeati…
claremacrae Oct 6, 2022
f4870c0
docs: Add new section: 'Technical Details'
claremacrae Oct 6, 2022
1b7cbf9
docs: Add markdown codeblocks to tasks in new sections
claremacrae Oct 6, 2022
695abf3
docs: Use more representative done does in sample tasks
claremacrae Oct 6, 2022
d0d1e4e
docs: Warn against use of 'every month on the 31st'
claremacrae Oct 6, 2022
98ac13f
docs: Warn near top of file about skipped recurrences
claremacrae Oct 7, 2022
eac9896
test: Add test to see whether 'every year on' needs special case. It …
claremacrae Oct 8, 2022
24862ba
refactor: code review: Remove repeated calls to Recurrence.toText()
claremacrae Oct 8, 2022
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
130 changes: 129 additions & 1 deletion docs/getting-started/recurring-tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,34 @@ The next Sunday after 25 April 2021 is on 2 May.
Important
{: .label .label-yellow }

A recurring task should have a due date and the recurrence rule must appear before the due date on the line.
A recurring task should have a due date. The due date and the recurrence rule must appear after the task's description.

<hr />
Important
{: .label .label-yellow }

There are edge cases for tasks that recur monthly or yearly.
For example, a task may be due `2022-01-31` and recur `every 3 months`.
The next recurrence date of `2022-04-31` does not exist.

In that case, Tasks moves the next occurrence **backwards** to the next valid date.
In this case, that would be `2022-04-30`.

From then on, the due date will be based on the 30th day of the month, unless changed manually.
So the next occurrence would happen on `2022-07-30`, even though July has 31 days.

<hr />
Important
{: .label .label-yellow }

With edge cases for tasks that recur monthly or yearly, **if the rule states the actual date of the next recurrence, Tasks will honour that instruction, skipping recurrence dates that do not exist**.

For example, a task may be due `2022-01-31` and recur `every month on the 31st`.
The next recurrence date of `2022-02-31` does not exist.

In that case, Tasks moves the next occurrence **forwards** to the next valid date,
skipping over recurrences with invalid dates.
In this case, that would be `2022-03-31`.

</div>

Expand Down Expand Up @@ -88,6 +115,98 @@ Now the newly created task is scheduled 1 week after the task was completed rath

---

## How the New Date is Calculated: Repeating Monthly

Because calendar months differ in length, there are some pitfalls in monthly recurrence rules.

Below are some representative examples to demonstrate the differences in behavior, to help you choose which approach to use.

Note that there are several more month-based options in the [Examples](#examples) section below.

### every month on the last: reliable and safe

Suppose we want a sequence of tasks to be due on the last day of each month.

The safest way to achieve that goal is to use `every month on the last`. This is specific about which day of the month to use, and so Tasks (or rather, the [rrule](https://github.com/jakubroztocil/rrule) library), calculates the new due date as intended.

Consider this task:

```markdown
- [ ] do stuff 🔁 every month on the last 📅 2022-01-31
```

When completing it several times, we would see that each new task is due on the last day of the next month:

```markdown
- [ ] do stuff 🔁 every month on the last 📅 2022-06-30
- [x] do stuff 🔁 every month on the last 📅 2022-05-31 ✅ 2022-05-31
- [x] do stuff 🔁 every month on the last 📅 2022-04-30 ✅ 2022-04-30
- [x] do stuff 🔁 every month on the last 📅 2022-03-31 ✅ 2022-03-31
- [x] do stuff 🔁 every month on the last 📅 2022-02-28 ✅ 2022-02-28
- [x] do stuff 🔁 every month on the last 📅 2022-01-31 ✅ 2022-01-31
```

### every month: if next calculated date does not exist, move new due date earlier

Suppose we start with this task:

```markdown
- [ ] do stuff 🔁 every month 📅 2021-10-31
```

Here, the recurrence rule `every month` has no opinion on the date, and so Tasks looks at the due date of the task being completed to calculate the next due date.

When completing it several times, we would see this:

```markdown
- [ ] do stuff 🔁 every month 📅 2022-03-28
- [x] do stuff 🔁 every month 📅 2022-02-28 ✅ 2022-02-28
- [x] do stuff 🔁 every month 📅 2022-01-30 ✅ 2022-01-30
- [x] do stuff 🔁 every month 📅 2021-12-30 ✅ 2021-12-30
- [x] do stuff 🔁 every month 📅 2021-11-30 ✅ 2021-11-30
- [x] do stuff 🔁 every month 📅 2021-10-31 ✅ 2021-10-31
```

Note how because `2021-11-31` does not exist, the due date is moved earlier, to `2021-11-30`.
From then on, the due date will be based on the 30th day of the month, unless changed manually.
Once February is reached, from then on, the due date will be based on the 28th day of the month.

This moving to earlier dates instead of skipping to the following month is especially important for recurrence patterns such as `every month when done`, which would otherwise sometimes skip occurrences when completing monthly tasks at the end of months with 31 days.

### every month on the 31st: skips months with fewer than 31 days

**Beware**: This is probably not the option you are looking for. If using it, be sure that you understand how it skips over months with fewer than 31 days.

Suppose we start with this task:

```markdown
- [ ] do stuff 🔁 every month on the 31st 📅 2022-01-31
```

Here, the user has specifically requested that the task happens on the 31st of the month.

In this case, if the new due date falls on a month with fewer than 31 days, [rrule](https://github.com/jakubroztocil/rrule) skips forward to the next month until a valid date is found.

So, when completing the above task several times, we would see this, which skips over February, April and June:

```markdown
- [ ] do stuff 🔁 every month on the 31st 📅 2022-08-31
- [x] do stuff 🔁 every month on the 31st 📅 2022-07-31 ✅ 2022-07-31
- [x] do stuff 🔁 every month on the 31st 📅 2022-05-31 ✅ 2022-05-31
- [x] do stuff 🔁 every month on the 31st 📅 2022-03-31 ✅ 2022-03-31
- [x] do stuff 🔁 every month on the 31st 📅 2022-01-31 ✅ 2022-01-31
```

This is intentional. As well as matching what the user requested, it matches the [specification](https://datatracker.ietf.org/doc/html/rfc5545#section-3.3.10) of the [iCalendar RFC](https://tools.ietf.org/html/rfc5545) which the [rrule](https://github.com/jakubroztocil/rrule) library implements:

> Recurrence rules may generate recurrence instances with an invalid
date (e.g., February 30) or nonexistent local time (e.g., 1:30 AM
on a day where the local time is moved forward by an hour at 1:00
AM). Such recurrence instances MUST be ignored and MUST NOT be
counted as part of the recurrence set.

---

## Priority of Dates

A task can have [various dates]({{ site.baseurl }}{% link getting-started/dates.md %}).
Expand Down Expand Up @@ -120,6 +239,7 @@ The new task will have the due date advanced by two weeks and a scheduled date t

1. You can _not_ use rules where recurrence happens a certain number of times (`for x times`). Tasks doesn't link the tasks and doesn't know how often it occurred.
2. You can _not_ use rules where recurrence ends on a specific date (`until "date"`). There is a bug in [`rrule`](https://github.com/jakubroztocil/rrule) where `until "date"` rules are not converted to the correct text. As a consequence, every subsequent task's "until" date will be one day earlier than the one before.
3. If the highest priority date in a task does not exist (for example, due date is February 30th), when the task is completed the recurrence rule will disappear, and no new task will be created. This is detectable prior to completing the task by viewing the task in Live Preview: the recurrence rule will be hidden, and the date will be displayed as 'Invalid date'.

---

Expand All @@ -136,8 +256,16 @@ Examples of possible recurrence rules (mix and match as desired; these should be
- `🔁 every 2 months`
- `🔁 every month on the 1st`
- `🔁 every month on the last`
- `🔁 every month on the last Friday`
- `🔁 every month on the 2nd last Friday`
- `🔁 every 6 months on the 2nd Wednesday`
- `🔁 every January on the 15th`
- `🔁 every February on the last`
- `🔁 every April and December on the 1st and 24th` (meaning every _April 1st_ and _December 24th_)
- `🔁 every year`

---

## Technical Details

Tasks uses the [rrule](https://github.com/jakubroztocil/rrule) library to calculate the next date when completing a recurring task.
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"command-palette",
"editor-status",
"markdown-importer",
"outline",
"word-count",
"file-recovery"
]
17,308 changes: 17,280 additions & 28 deletions resources/sample_vaults/Tasks-Demo/.obsidian/plugins/obsidian-tasks-plugin/main.js

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"id": "obsidian-tasks-plugin",
"name": "Tasks",
"version": "1.14.0",
"version": "1.15.0",
"minAppVersion": "0.14.6",
"description": "Task management for Obsidian",
"author": "Martin Schenck and Clare Macrae",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,8 @@
.tasks-modal-date {
margin-bottom: 10px;
}

.tasks-modal label + input[type=checkbox] {
margin-left: 0.67em;
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@

# Recurrence handling invalid dates

This file captures some exploratory tests of [issue 1087: Recurrence with 6 months recurs every year](https://github.com/obsidian-tasks-group/obsidian-tasks/issues/1087).

## With Tasks 1.14.0

### 1.14.0: every month on the 25th

Each next increment is valid, so it uniformly skips forward each month.

- [ ] #task do stuff 🔁 every month on the 25th 📅 2022-04-25
- [x] #task do stuff 🔁 every month on the 25th 📅 2022-03-25 ✅ 2022-10-03
- [x] #task do stuff 🔁 every month on the 25th 📅 2022-02-25 ✅ 2022-10-03
- [x] #task do stuff 🔁 every month on the 25th 📅 2022-01-25 ✅ 2022-10-03
- [x] #task do stuff 🔁 every month on the 25th 📅 2021-12-25 ✅ 2022-10-03
- [x] #task do stuff 🔁 every month on the 25th 📅 2021-11-25 ✅ 2022-10-03

### 1.14.0: every month on the 31st

Some next increments are invalid dates, in which case rrule skips forward to the next month.

- [ ] #task do stuff 🔁 every month on the 31st 📅 2022-07-31
- [x] #task do stuff 🔁 every month on the 31st 📅 2022-05-31 ✅ 2022-10-03
- [x] #task do stuff 🔁 every month on the 31st 📅 2022-03-31 ✅ 2022-10-03
- [x] #task do stuff 🔁 every month on the 31st 📅 2022-01-31 ✅ 2022-10-03
- [x] #task do stuff 🔁 every month on the 31st 📅 2021-12-31 ✅ 2022-10-03
- [x] #task do stuff 🔁 every month on the 31st 📅 2021-11-30 ✅ 2022-10-03

### 1.14.0: every month on the last

- [ ] #task do stuff 🔁 every month on the last 📅 2022-04-30
- [x] #task do stuff 🔁 every month on the last 📅 2022-03-31 ✅ 2022-10-03
- [x] #task do stuff 🔁 every month on the last 📅 2022-02-28 ✅ 2022-10-03
- [x] #task do stuff 🔁 every month on the last 📅 2022-01-31 ✅ 2022-10-03
- [x] #task do stuff 🔁 every month on the last 📅 2021-12-31 ✅ 2022-10-03
- [x] #task do stuff 🔁 every month on the last 📅 2021-11-30 ✅ 2022-10-03

## With #1197

There are two copies of each test here.

- The first will remain unchanged, and is the initial starting point for a recurring task.
- The second shows the most-recently tested behaviour, with a comment describing any issues with that behaviour.

The intention is to make it easy to re-start testing if testing on a newer version of the plugin,

### #1197: every month on the 25th - starting point

- [ ] #task do stuff 🔁 every month on the 25th 📅 2021-11-25

### #1197: every month on the 25th - current behaviour

This works fine.

- [ ] #task do stuff 🔁 every month on the 25th 📅 2022-04-25
- [x] #task do stuff 🔁 every month on the 25th 📅 2022-03-25 ✅ 2022-10-03
- [x] #task do stuff 🔁 every month on the 25th 📅 2022-02-25 ✅ 2022-10-03
- [x] #task do stuff 🔁 every month on the 25th 📅 2022-01-25 ✅ 2022-10-03
- [x] #task do stuff 🔁 every month on the 25th 📅 2021-12-25 ✅ 2022-10-03
- [x] #task do stuff 🔁 every month on the 25th 📅 2021-11-25 ✅ 2022-10-03

### #1197: every month on the 31st - starting point

- [ ] #task do stuff 🔁 every month on the 31st 📅 2021-11-30

### #1197: every month on the 31st - current behaviour

Because a specific date has been requested, if the new recurrence lands on an invalid date, it skips forward to the next increment, until a valid due date is reached.

- [ ] #task do stuff 🔁 every month on the 31st 📅 2022-07-31
- [x] #task do stuff 🔁 every month on the 31st 📅 2022-05-31 ✅ 2022-10-03
- [x] #task do stuff 🔁 every month on the 31st 📅 2022-03-31 ✅ 2022-10-03
- [x] #task do stuff 🔁 every month on the 31st 📅 2022-01-31 ✅ 2022-10-03
- [x] #task do stuff 🔁 every month on the 31st 📅 2021-12-31 ✅ 2022-10-03
- [x] #task do stuff 🔁 every month on the 31st 📅 2021-11-30 ✅ 2022-10-03

### #1197: every month on the last - starting point

- [ ] #task do stuff 🔁 every month on the last 📅 2021-11-30

### #1197: every month on the last - current behaviour

- [ ] #task do stuff 🔁 every month on the last 📅 2022-04-30
- [x] #task do stuff 🔁 every month on the last 📅 2022-03-31 ✅ 2022-10-03
- [x] #task do stuff 🔁 every month on the last 📅 2022-02-28 ✅ 2022-10-03
- [x] #task do stuff 🔁 every month on the last 📅 2022-01-31 ✅ 2022-10-03
- [x] #task do stuff 🔁 every month on the last 📅 2021-12-31 ✅ 2022-10-03
- [x] #task do stuff 🔁 every month on the last 📅 2021-11-30 ✅ 2022-10-03
Loading