Repository: obsidian-tasks-group/obsidian-tasks
Pull request #2985 (2024-07-24 03:52:14)

refactor: move `Occurrence` class separate to a separate file and minor improvements
# Types of changes

Internal changes:

- [x] **Refactor** (prefix: `refactor` - non-breaking change which only improves the design or structure of existing code, and making no changes to its external behaviour)
- [x] **Tests** (prefix: `test` - additions and improvements to unit tests and the smoke tests)

## Description

- move `Occurrence` class separate to a separate file
- make parameters of `Occurrence.constructor` optional and remove `null` values from tests
- use `Occurrence` class in `Task.ts`
- some renames in `Recurrence` to reflect use of `Occurrence`
- add jsdoc to `Occurrence` methods

## Motivation and Context

- separate concerns in `Recurrence`

## How has this been tested?

- unit tests

## Checklist

- [x] My code follows the code style of this project and passes `yarn run lint`.
- [x] My change has adequate [Unit Test coverage](https://publish.obsidian.md/tasks-contributing/Testing/About+Testing).

## Terms

<!--
By submitting this pull request, you must agree to follow our
[contributing guide](https://publish.obsidian.md/tasks-contributing) and
[Code of Conduct](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CODE_OF_CONDUCT.md).
Put an x in the boxes to confirm you agree.
-->

- [x] My contribution follows this project's [contributing guide](https://publish.obsidian.md/tasks-contributing)
- [x] I agree to follow this project's [Code of Conduct](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CODE_OF_CONDUCT.md)



Patch:
diff --git a/src/Suggestor/Suggestor.ts b/src/Suggestor/Suggestor.ts
index c3cce272..07e7b9c3 100644
--- a/src/Suggestor/Suggestor.ts
+++ b/src/Suggestor/Suggestor.ts
@@ -2,7 +2,8 @@ import type { Editor, EditorPosition } from 'obsidian';
 import type { Settings } from '../Config/Settings';
 import { DateParser } from '../Query/DateParser';
 import { doAutocomplete } from '../lib/DateAbbreviations';
-import { Occurrence, Recurrence } from '../Task/Recurrence';
+import { Occurrence } from '../Task/Occurrence';
+import { Recurrence } from '../Task/Recurrence';
 import {
     type DefaultTaskSerializerSymbols,
     allTaskPluginEmojis,
diff --git a/src/Task/Occurrence.ts b/src/Task/Occurrence.ts
new file mode 100644
index 00000000..d6977f0c
--- /dev/null
+++ b/src/Task/Occurrence.ts
@@ -0,0 +1,124 @@
+import type { Moment } from 'moment/moment';
+import { compareByDate } from '../lib/DateTools';
+
+export class Occurrence {
+    public readonly startDate: Moment | null;
+    public readonly scheduledDate: Moment | null;
+    public readonly dueDate: Moment | null;
+
+    /**
+     * The reference date is used to calculate future occurrences.
+     *
+     * Future occurrences will recur based on the reference date.
+     * The reference date is the due date, if it is given.
+     * Otherwise the scheduled date, if it is given. And so on.
+     *
+     * Recurrence of all dates will be kept relative to the reference date.
+     * For example: if the due date and the start date are given, the due date
+     * is the reference date. Future occurrences will have a start date with the
+     * same relative distance to the due date as the original task. For example
+     * "starts one week before it is due".
+     */
+    public readonly referenceDate: Moment | null;
+
+    constructor({
+        startDate = null,
+        scheduledDate = null,
+        dueDate = null,
+    }: {
+        startDate?: Moment | null;
+        scheduledDate?: Moment | null;
+        dueDate?: Moment | null;
+    }) {
+        this.startDate = startDate ?? null;
+        this.scheduledDate = scheduledDate ?? null;
+        this.dueDate = dueDate ?? null;
+        this.referenceDate = this.getReferenceDate();
+    }
+
+    /**
+     *  Pick the reference date for occurrence based on importance.
+     *  Assuming due date has the highest priority, then scheduled date,
+     *  then start date.
+     *
+     *  The Moment objects are cloned.
+     *
+     * @private
+     */
+    private getReferenceDate(): Moment | null {
+        if (this.dueDate) {
+            return window.moment(this.dueDate);
+        }
+
+        if (this.scheduledDate) {
+            return window.moment(this.scheduledDate);
+        }
+
+        if (this.startDate) {
+            return window.moment(this.startDate);
+        }
+
+        return null;
+    }
+
+    public isIdenticalTo(other: Occurrence): boolean {
+        // Compare Date fields
+        if (compareByDate(this.startDate, other.startDate) !== 0) {
+            return false;
+        }
+        if (compareByDate(this.scheduledDate, other.scheduledDate) !== 0) {
+            return false;
+        }
+        if (compareByDate(this.dueDate, other.dueDate) !== 0) {
+            return false;
+        }
+
+        return true;
+    }
+
+    /**
+     * Provides an {@link Occurrence} with the dates calculated relative to a new reference date.
+     *
+     * If the occurrence has no reference date, an empty {@link Occurrence} will be returned.
+     *
+     * @param nextReferenceDate
+     */
+    public next(nextReferenceDate: Date): Occurrence {
+        // Only if a reference date is given. A reference date will exist if at
+        // least one of the other dates is set.
+        if (this.referenceDate === null) {
+            return new Occurrence({
+                startDate: null,
+                scheduledDate: null,
+                dueDate: null,
+            });
+        }
+
+        return new Occurrence({
+            startDate: this.nextOccurrenceDate(this.startDate, nextReferenceDate),
+            scheduledDate: this.nextOccurrenceDate(this.scheduledDate, nextReferenceDate),
+            dueDate: this.nextOccurrenceDate(this.dueDate, nextReferenceDate),
+        });
+    }
+
+    /**
+     * Gets next occurrence (start/scheduled/due date) keeping the relative distance
+     * with the reference date
+     *
+     * @param nextReferenceDate
+     * @param currentOccurrenceDate start/scheduled/due date
+     * @private
+     */
+    private nextOccurrenceDate(currentOccurrenceDate: Moment | null, nextReferenceDate: Date): Moment | null {
+        if (currentOccurrenceDate === null) {
+            return null;
+        }
+        const originalDifference = window.moment.duration(currentOccurrenceDate.diff(this.referenceDate));
+
+        // Cloning so that original won't be manipulated:
+        const nextOccurrence = window.moment(nextReferenceDate);
+        // Rounding days to handle cross daylight-savings-time recurrences.
+        nextOccurrence.add(Math.round(originalDifference.asDays()), 'days');
+        return nextOccurrence;
+    }
+}
diff --git a/src/Task/Recurrence.ts b/src/Task/Recurrence.ts
index 5581a494..dee1a113 100644
--- a/src/Task/Recurrence.ts
+++ b/src/Task/Recurrence.ts
@@ -2,112 +2,7 @@
 import type { Moment } from 'moment';
 // end-snippet
 import { RRule } from 'rrule';
-import { compareByDate } from '../lib/DateTools';
-
-export class Occurrence {
-    readonly startDate: Moment | null;
-    readonly scheduledDate: Moment | null;
-    readonly dueDate: Moment | null;
-
-    /**
-     * The reference date is used to calculate future occurrences.
-     *
-     * Future occurrences will recur based on the reference date.
-     * The reference date is the due date, if it is given.
-     * Otherwise the scheduled date, if it is given. And so on.
-     *
-     * Recurrence of all dates will be kept relative to the reference date.
-     * For example: if the due date and the start date are given, the due date
-     * is the reference date. Future occurrences will have a start date with the
-     * same relative distance to the due date as the original task. For example
-     * "starts one week before it is due".
-     */
-    readonly referenceDate: Moment | null;
-
-    constructor({
-        startDate,
-        scheduledDate,
-        dueDate,
-    }: {
-        startDate: Moment | null;
-        scheduledDate: Moment | null;
-        dueDate: Moment | null;
-    }) {
-        this.startDate = startDate;
-        this.scheduledDate = scheduledDate;
-        this.dueDate = dueDate;
-        this.referenceDate = this.getReferenceDate();
-    }
-
-    public getReferenceDate(): Moment | null {
-        // Pick the reference date for recurrence based on importance.
-        // Assuming due date has the highest priority.
-        let referenceDate: Moment | null = null;
-        // Clone the moment objects.
-        if (this.dueDate) {
-            referenceDate = window.moment(this.dueDate);
-        } else if (this.scheduledDate) {
-            referenceDate = window.moment(this.scheduledDate);
-        } else if (this.startDate) {
-            referenceDate = window.moment(this.startDate);
-        }
-        return referenceDate;
-    }
-
-    public isIdenticalTo(other: Occurrence) {
-        // Compare Date fields
-        if (compareByDate(this.startDate, other.startDate) !== 0) {
-            return false;
-        }
-        if (compareByDate(this.scheduledDate, other.scheduledDate) !== 0) {
-            return false;
-        }
-        if (compareByDate(this.dueDate, other.dueDate) !== 0) {
-            return false;
-        }
-
-        return true;
-    }
-
-    /**
-     * Gets next occurrence (start/scheduled/due date) keeping the relative distance
-     * with the reference date
-     *
-     * @param nextReferenceDate
-     * @param currentOccurrence start/scheduled/due date
-     * @private
-     */
-    public nextOccurrenceDate(currentOccurrence: Moment | null, nextReferenceDate: Date) {
-        if (currentOccurrence === null) {
-            return null;
-        }
-        const originalDifference = window.moment.duration(currentOccurrence.diff(this.referenceDate));
-
-        // Cloning so that original won't be manipulated:
-        const nextOccurrence = window.moment(nextReferenceDate);
-        // Rounding days to handle cross daylight-savings-time recurrences.
-        nextOccurrence.add(Math.round(originalDifference.asDays()), 'days');
-        return nextOccurrence;
-    }
-
-    public nextOccurrence(next: Date): Occurrence {
-        // Only if a reference date is given. A reference date will exist if at
-        // least one of the other dates is set.
-        if (this.referenceDate === null) {
-            return new Occurrence({
-                startDate: null,
-                scheduledDate: null,
-                dueDate: null,
-            });
-        }
-
-        return new Occurrence({
-            startDate: this.nextOccurrenceDate(this.startDate, next),
-            scheduledDate: this.nextOccurrenceDate(this.scheduledDate, next),
-            dueDate: this.nextOccurrenceDate(this.dueDate, next),
-        });
-    }
-}
+import type { Occurrence } from './Occurrence';
 
 export class Recurrence {
     private readonly rrule: RRule;
@@ -179,13 +74,13 @@ export class Recurrence {
      * @param today - Optional date representing the completion date. Defaults to today.
      */
     public next(today = window.moment()): Occurrence | null {
-        const next = this.nextReferenceDate(today);
+        const nextReferenceDate = this.nextReferenceDate(today);
 
-        if (next === null) {
+        if (nextReferenceDate === null) {
             return null;
         }
 
-        return this.occurrence.nextOccurrence(next);
+        return this.occurrence.next(nextReferenceDate);
     }
 
     public identicalTo(other: Recurrence) {
diff --git a/src/Task/Task.ts b/src/Task/Task.ts
index adc6228b..9f8369d2 100644
--- a/src/Task/Task.ts
+++ b/src/Task/Task.ts
@@ -12,6 +12,7 @@ import { logging } from '../lib/logging';
 import { logEndOfTaskEdit, logStartOfTaskEdit } from '../lib/LogTasksHelper';
 import { DateFallback } from './DateFallback';
 import { ListItem } from './ListItem';
+import type { Occurrence } from './Occurrence';
 import { Urgency } from './Urgency';
 import type { Recurrence } from './Recurrence';
 import type { TaskLocation } from './TaskLocation';
@@ -360,11 +361,7 @@ export class Task extends ListItem {
             today,
         );
 
-        let nextOccurrence: {
-            startDate: Moment | null;
-            scheduledDate: Moment | null;
-            dueDate: Moment | null;
-        } | null = null;
+        let nextOccurrence: Occurrence | null = null;
         if (newStatus.isCompleted()) {
             if (!this.status.isCompleted() && this.recurrence !== null) {
                 nextOccurrence = this.recurrence.next(today);
@@ -419,14 +416,7 @@ export class Task extends ListItem {
         return newDate;
     }
 
-    private createNextOccurrence(
-        newStatus: Status,
-        nextOccurrence: {
-            startDate: moment.Moment | null;
-            scheduledDate: moment.Moment | null;
-            dueDate: moment.Moment | null;
-        },
-    ) {
+    private createNextOccurrence(newStatus: Status, nextOccurrence: Occurrence) {
         const { setCreatedDate } = getSettings();
         let createdDate: moment.Moment | null = null;
         if (setCreatedDate) {
diff --git a/src/TaskSerializer/DefaultTaskSerializer.ts b/src/TaskSerializer/DefaultTaskSerializer.ts
index b66ff8ba..700da15b 100644
--- a/src/TaskSerializer/DefaultTaskSerializer.ts
+++ b/src/TaskSerializer/DefaultTaskSerializer.ts
@@ -1,6 +1,7 @@
 import type { Moment } from 'moment';
 import { TaskLayoutComponent, TaskLayoutOptions } from '../Layout/TaskLayoutOptions';
-import { Occurrence, Recurrence } from '../Task/Recurrence';
+import { Occurrence } from '../Task/Occurrence';
+import { Recurrence } from '../Task/Recurrence';
 import { Task } from '../Task/Task';
 import { Priority } from '../Task/Priority';
 import { TaskRegularExpressions } from '../Task/TaskRegularExpressions';
diff --git a/src/ui/EditableTask.ts b/src/ui/EditableTask.ts
index 0e384564..54da4f91 100644
--- a/src/ui/EditableTask.ts
+++ b/src/ui/EditableTask.ts
@@ -2,8 +2,9 @@ import { GlobalFilter } from '../Config/GlobalFilter';
 import { parseTypedDateForSaving } from '../lib/DateTools';
 import { replaceTaskWithTasks } from '../Obsidian/File';
 import type { Status } from '../Statuses/Status';
+import { Occurrence } from '../Task/Occurrence';
 import { Priority } from '../Task/Priority';
-import { Occurrence, Recurrence } from '../Task/Recurrence';
+import { Recurrence } from '../Task/Recurrence';
 import { Task } from '../Task/Task';
 import { addDependencyToParent, ensureTaskHasId, generateUniqueId, removeDependency } from '../Task/TaskDependency';
 


Test patch:
diff --git a/tests/Task/Recurrence.test.ts b/tests/Task/Recurrence.test.ts
index d1096b01..25c6b757 100644
--- a/tests/Task/Recurrence.test.ts
+++ b/tests/Task/Recurrence.test.ts
@@ -2,7 +2,8 @@
  * @jest-environment jsdom
  */
 import moment from 'moment';
-import { Occurrence, Recurrence } from '../../src/Task/Recurrence';
+import { Occurrence } from '../../src/Task/Occurrence';
+import { Recurrence } from '../../src/Task/Recurrence';
 import { RecurrenceBuilder } from '../TestingTools/RecurrenceBuilder';
 
 window.moment = moment;
@@ -12,11 +13,7 @@ describe('Recurrence', () => {
         // Arrange
         const recurrence = Recurrence.fromText({
             recurrenceRuleText: 'every week',
-            occurrence: new Occurrence({
-                startDate: null,
-                scheduledDate: null,
-                dueDate: null,
-            }),
+            occurrence: new Occurrence({}),
         });
 
         // Act
@@ -32,8 +29,6 @@ describe('Recurrence', () => {
         const recurrence = Recurrence.fromText({
             recurrenceRuleText: 'every month',
             occurrence: new Occurrence({
-                startDate: null,
-                scheduledDate: null,
                 dueDate: moment('2022-01-31').startOf('day'),
             }),
         });
@@ -52,8 +47,6 @@ describe('Recurrence', () => {
         const recurrence = Recurrence.fromText({
             recurrenceRuleText: 'every 3 months',
             occurrence: new Occurrence({
-                startDate: null,
-                scheduledDate: null,
                 dueDate: moment('2022-01-31').startOf('day'),
             }),
         });
@@ -72,8 +65,6 @@ describe('Recurrence', () => {
         const recurrence = Recurrence.fromText({
             recurrenceRuleText: 'every 2 months',
             occurrence: new Occurrence({
-                startDate: null,
-                scheduledDate: null,
                 dueDate: moment('2023-12-31').startOf('day'),
             }),
         });
@@ -92,8 +83,6 @@ describe('Recurrence', () => {
         const recurrence = Recurrence.fromText({
             recurrenceRuleText: 'every 2 years',
             occurrence: new Occurrence({
-                startDate: null,
-                scheduledDate: null,
                 dueDate: moment('2024-02-29').startOf('day'),
             }),
         });
@@ -112,8 +101,6 @@ describe('Recurrence', () => {
         const recurrence = Recurrence.fromText({
             recurrenceRuleText: 'every 11 months',
             occurrence: new Occurrence({
-                startDate: null,
-                scheduledDate: null,
                 dueDate: moment('2020-03-31').startOf('day'),
             }),
         });
@@ -132,8 +119,6 @@ describe('Recurrence', () => {
         const recurrence = Recurrence.fromText({
             recurrenceRuleText: 'every 13 months',
             occurrence: new Occurrence({
-                startDate: null,
-                scheduledDate: null,
                 dueDate: moment('2020-01-31').startOf('day'),
             }),
         });
@@ -156,8 +141,6 @@ describe('Recurrence - with invalid dates in tasks', () => {
         const recurrence = Recurrence.fromText({
             recurrenceRuleText: 'every day',
             occurrence: new Occurrence({
-                startDate: null,
-                scheduledDate: null,
                 dueDate: moment('2022-02-30').startOf('day'), // 30th February: invalid date
             }),
         });
@@ -178,7 +161,6 @@ describe('Recurrence - with invalid dates in tasks', () => {
         const recurrence = Recurrence.fromText({
             recurrenceRuleText: 'every day',
             occurrence: new Occurrence({
-                startDate: null,
                 scheduledDate: moment('2022-02-30').startOf('day'), // 30th February: invalid date
                 dueDate: moment('2022-02-27').startOf('day'),
             }),
diff --git a/tests/TestingTools/RecurrenceBuilder.ts b/tests/TestingTools/RecurrenceBuilder.ts
index b8118c5f..0e2f92d0 100644
--- a/tests/TestingTools/RecurrenceBuilder.ts
+++ b/tests/TestingTools/RecurrenceBuilder.ts
@@ -1,6 +1,7 @@
 // Builder
 import type { Moment } from 'moment';
-import { Occurrence, Recurrence } from '../../src/Task/Recurrence';
+import { Occurrence } from '../../src/Task/Occurrence';
+import { Recurrence } from '../../src/Task/Recurrence';
 import { DateParser } from '../../src/Query/DateParser';
 
 /**
diff --git a/tests/TestingTools/SampleTasks.ts b/tests/TestingTools/SampleTasks.ts
index b84b9ee6..3b46cb94 100644
--- a/tests/TestingTools/SampleTasks.ts
+++ b/tests/TestingTools/SampleTasks.ts
@@ -1,5 +1,6 @@
+import { Occurrence } from '../../src/Task/Occurrence';
 import { Task } from '../../src/Task/Task';
-import { Occurrence, Recurrence } from '../../src/Task/Recurrence';
+import { Recurrence } from '../../src/Task/Recurrence';
 import { Status } from '../../src/Statuses/Status';
 import { StatusType } from '../../src/Statuses/StatusConfiguration';
 import { Priority } from '../../src/Task/Priority';
diff --git a/tests/TestingTools/TaskBuilder.ts b/tests/TestingTools/TaskBuilder.ts
index d4b90181..60f8d9b0 100644
--- a/tests/TestingTools/TaskBuilder.ts
+++ b/tests/TestingTools/TaskBuilder.ts
@@ -2,8 +2,9 @@
 import type { Moment } from 'moment';
 import { TasksFile } from '../../src/Scripting/TasksFile';
 import { Status } from '../../src/Statuses/Status';
+import { Occurrence } from '../../src/Task/Occurrence';
 import { Task } from '../../src/Task/Task';
-import { Occurrence, Recurrence } from '../../src/Task/Recurrence';
+import { Recurrence } from '../../src/Task/Recurrence';
 import { DateParser } from '../../src/Query/DateParser';
 import { StatusConfiguration, StatusType } from '../../src/Statuses/StatusConfiguration';
 import { TaskLocation } from '../../src/Task/TaskLocation';