diff --git a/.gitignore b/.gitignore index b46578861a..7533d8a623 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,12 @@ yarn-error.log # Backup files. *.bak + +# vscode files +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets +main.css diff --git a/.vscode/extensions.json b/.vscode/extensions.json deleted file mode 100644 index 212e327868..0000000000 --- a/.vscode/extensions.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "recommendations": [ - "DavidAnson.vscode-markdownlint" - ] -} diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 5623ddbe2e..0000000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "npm.packageManager": "yarn" -} diff --git a/docs/Advanced/Notifications.md b/docs/Advanced/Notifications.md index 02f88d8daf..a1a4169f22 100644 --- a/docs/Advanced/Notifications.md +++ b/docs/Advanced/Notifications.md @@ -6,6 +6,9 @@ publish: true #plugin/reminder +> [!Tip] +> Since Tasks X.Y.Z, the Tasks plugin has built-in support for reminders. See [[Task-Reminders]] and [[Dates#Reminder date and time]]. + Within Tasks, notifications can be made possible by utilizing [obsidian-reminder](https://github.com/uphy/obsidian-reminder). This utilizes the standard Tasks date (as the due date) and can be extended with an additional reminder date by including a ⏰ and a date/time in the format `⏰ YYYY-MM-DD HH:MM`. Further, a default reminder can be enabled based on the Tasks' 'Due Date'. diff --git a/docs/Getting Started/Dates.md b/docs/Getting Started/Dates.md index c4d9f35070..928e81b0d8 100644 --- a/docs/Getting Started/Dates.md +++ b/docs/Getting Started/Dates.md @@ -81,6 +81,25 @@ starts before tomorrow --- +### Reminder date and time + +You can ask Tasks for a reminder to do a task. And unlike all the other dates, reminders can have times. + +Reminder dates use an alarm clock emoji: ⏰ + +```markdown +- [ ] take out the trash ⏰ 2021-04-09 09:50 +``` + +This differs from the [[Notifications|Reminders plugin]], which uses ⏰. + +For more information on the Reminders facility in Tasks, see [[Task-Reminders]]. + +> [!released] +> Reminder support was introduced in Tasks X.Y.Z. + +--- + ## Track task histories This section explains the types of dates that Tasks can add for you automatically. diff --git a/docs/Queries/Sorting.md b/docs/Queries/Sorting.md index 0d91095c88..fa499c1fad 100644 --- a/docs/Queries/Sorting.md +++ b/docs/Queries/Sorting.md @@ -35,13 +35,15 @@ You can sort tasks by the following properties. 1. `created` (the date when the task was created) 1. `start` (the date when the task starts) 1. `scheduled` (the date when the task is scheduled) +1. `reminder` (the reminder date and optional time) 1. `due` (the date when the task is due) 1. `done` (the date when the task was done) 1. `happens` (the earliest of start date, scheduled date, and due date) > [!released] `sort by happens` was introduced in Tasks 1.21.0.
-`sort by created` was introduced in Tasks 2.0.0. +`sort by created` was introduced in Tasks 2.0.0.
+`sort by reminder` was introduced in Tasks X.Y.Z. ### Task statuses diff --git a/docs/advanced/Task-Reminders.md b/docs/advanced/Task-Reminders.md new file mode 100644 index 0000000000..4f0fd1f7d7 --- /dev/null +++ b/docs/advanced/Task-Reminders.md @@ -0,0 +1,31 @@ +--- +publish: true +--- + +# Tasks Reminders + +#plugin/reminder + +> [!released] +> Reminder support was introduced in Tasks X.Y.Z. + +Within Tasks, reminder notifications can be set using either of two formats: + +- the standard Tasks format `⏰️ YYYY-MM-DD` for daily notifications at a set time +- or by specifying the time as well, in 24-hour clock `⏰️ YYYY-MM-DD HH:mm`. + +## Limitations + +- It's not possible to set a reminder for midnight 12:00 am. This due to a limitation with how tasks creates dates using `momentjs` which sets the defaults time to midnight when one isn't provided. +- System notifications don't work on Mobile because Obsidian doesn't provide an API. + +## How to complete the reminder + +The reminder date doesn't change when completing the task, the date will change only when you complete it from the reminder popup or from the notification. + +![image](https://user-images.githubusercontent.com/38974541/143463881-e4af4b91-426f-48e8-938e-4a1053b06677.png) +![image](https://user-images.githubusercontent.com/38974541/143464983-542675ae-a467-41c0-aaca-1075c42f8328.png) + +## Acknowledgment + +This feature was created using code from uphy's [obsidian-reminder](https://github.com/uphy/obsidian-reminder) plugin. diff --git a/src/Commands/CreateOrEditTaskParser.ts b/src/Commands/CreateOrEditTaskParser.ts index d97ed8a890..f45c31a2f1 100644 --- a/src/Commands/CreateOrEditTaskParser.ts +++ b/src/Commands/CreateOrEditTaskParser.ts @@ -60,6 +60,7 @@ export const taskFromLine = ({ line, path }: { line: string; path: string }): Ta recurrence: null, blockLink: '', tags: [], + reminder: null, originalMarkdown: '', scheduledDateIsInferred: false, }); @@ -95,6 +96,7 @@ export const taskFromLine = ({ line, path }: { line: string; path: string }): Ta doneDate: null, recurrence: null, tags: [], + reminder: null, originalMarkdown: '', // Not needed since the inferred status is always re-computed after submitting. scheduledDateIsInferred: false, diff --git a/src/Config/Settings.ts b/src/Config/Settings.ts index bc71f2b265..696766f008 100644 --- a/src/Config/Settings.ts +++ b/src/Config/Settings.ts @@ -6,6 +6,7 @@ import { Status } from '../Status'; import { DefaultTaskSerializer, type TaskSerializer } from '../TaskSerializer'; import type { SuggestionBuilder } from '../Suggestor'; import { DataviewTaskSerializer } from '../TaskSerializer/DataviewTaskSerializer'; +import { ReminderSettings } from '../Reminders/Reminder'; import { DebugSettings } from './DebugSettings'; import { StatusSettings } from './StatusSettings'; import { Feature } from './Feature'; @@ -49,6 +50,11 @@ export const TASK_FORMATS = { export type TASK_FORMATS = typeof TASK_FORMATS; // For convenience to make some typing easier +// Time formats for the reminder settings. +export const TIME_FORMATS = { + twentyFourHour: 'YYYY-MM-DD HH:mm', +}; + export interface Settings { globalQuery: string; globalFilter: string; @@ -74,6 +80,8 @@ export interface Settings { // dynamically generated. generalSettings: SettingsMap; + reminderSettings: ReminderSettings; + // Tracks the stage of the headings in the settings UI. headingOpened: HeadingState; debugSettings: DebugSettings; @@ -106,6 +114,7 @@ const defaultSettings: Settings = { }, headingOpened: {}, debugSettings: new DebugSettings(), + reminderSettings: new ReminderSettings(), }; let settings: Settings = { ...defaultSettings }; diff --git a/src/Config/SettingsTab.ts b/src/Config/SettingsTab.ts index eab3f68c68..d3edb5decf 100644 --- a/src/Config/SettingsTab.ts +++ b/src/Config/SettingsTab.ts @@ -223,6 +223,46 @@ export class SettingsTab extends PluginSettingTab { }); }); + // --------------------------------------------------------------------------- + containerEl.createEl('h4', { text: 'Reminder Settings' }); + // --------------------------------------------------------------------------- + + new Setting(containerEl) + .setName('Daily Reminder Time') + .setDesc( + SettingsTab.createFragmentWithHTML( + '

When daily reminders should be triggered. Should be in the 24-hour clock, for example 09:00 or 21:00.

', + ), + ) + .addText((text) => { + const settings = getSettings().reminderSettings; + text.setPlaceholder('10') + .setValue(settings.dailyReminderTime) + .onChange(async (value) => { + settings.dailyReminderTime = value; + updateSettings({ reminderSettings: settings }); + await this.plugin.saveSettings(); + }); + }); + + new Setting(containerEl) + .setName('Check Reminders interval') + .setDesc( + SettingsTab.createFragmentWithHTML('

How often Tasks should check for reminders in Seconds.

'), + ) + .addSlider((slider) => { + const settings = getSettings().reminderSettings; + slider + .setLimits(1, 30, 1) + .setValue(settings.refreshIntervalMilliseconds / 1000) // convert from miliseconds to seconds + .setDynamicTooltip() + .onChange(async (value) => { + settings.refreshIntervalMilliseconds = value * 1000; // convert from seconds to miliseconds + updateSettings({ reminderSettings: settings }); + await this.plugin.saveSettings(); + }); + }); + // --------------------------------------------------------------------------- containerEl.createEl('h4', { text: 'Recurring task Settings' }); // --------------------------------------------------------------------------- diff --git a/src/Query/Filter/ReminderDateField.ts b/src/Query/Filter/ReminderDateField.ts new file mode 100644 index 0000000000..26b7afa3c6 --- /dev/null +++ b/src/Query/Filter/ReminderDateField.ts @@ -0,0 +1,69 @@ +import type { Moment } from 'moment'; +import type { Task } from '../../Task'; +import type { Comparator } from '../Sorter'; +import { compareByDate } from '../../lib/DateTools'; +import { DateField } from './DateField'; + +/** + * ReminderDateField provides filter, sorting and grouping of tasks by their reminder value. + * + * **Filtering** by reminder ignores times completely. Only the dates in the task reminders + * and in the filter line are used. It is not possible to search for reminders at a particular + * time of day. + * + * **Sorting** by reminder does use the reminder times, if supplied. + */ +export class ReminderDateField extends DateField { + public fieldName(): string { + return 'reminder'; + } + + /** + * Return the reminder date **with any time stripped off**. + * + * In order for filtering by reminder to work correctly, this returns the reminder date + * with any time stripped off, because the date-filtering works in whole days currently, + * due to all the other date fields not supporting time. + * + * If you want the reminder date and time, use {@link dateWithTime}. + * @param task + * @see dateWithTime + */ + public date(task: Task): Moment | null { + if (task.reminder) { + return task.reminder.time.startOf('day'); + } else { + return null; + } + } + + /** + * Return the reminder date including its time. + * + * If you want just the date, with time stripped off, use {@link date}. + * @param task + * @see date + */ + public dateWithTime(task: Task): Moment | null { + if (task.reminder) { + return task.reminder.time; + } else { + return null; + } + } + + protected filterResultIfFieldMissing() { + return false; + } + + /** + * Return a function to compare two Task objects by their reminder. + * + * @note This does use any time on reminders, for more precise sorting. + */ + public comparator(): Comparator { + return (a: Task, b: Task) => { + return compareByDate(this.dateWithTime(a), this.dateWithTime(b)); + }; + } +} diff --git a/src/Query/FilterParser.ts b/src/Query/FilterParser.ts index c53662445e..9548a054fc 100644 --- a/src/Query/FilterParser.ts +++ b/src/Query/FilterParser.ts @@ -3,6 +3,7 @@ import { DescriptionField } from './Filter/DescriptionField'; import { CreatedDateField } from './Filter/CreatedDateField'; import { DoneDateField } from './Filter/DoneDateField'; import { DueDateField } from './Filter/DueDateField'; +import { ReminderDateField } from './Filter/ReminderDateField'; import { ExcludeSubItemsField } from './Filter/ExcludeSubItemsField'; import { HeadingField } from './Filter/HeadingField'; import { PathField } from './Filter/PathField'; @@ -44,6 +45,7 @@ const fieldCreators: EndsWith = [ () => new ScheduledDateField(), () => new DueDateField(), () => new DoneDateField(), + () => new ReminderDateField(), () => new PathField(), () => new FolderField(), () => new RootField(), diff --git a/src/Query/Query.ts b/src/Query/Query.ts index ca644c8c8c..65f07c6537 100644 --- a/src/Query/Query.ts +++ b/src/Query/Query.ts @@ -21,7 +21,7 @@ export class Query implements IQuery { private _grouping: Grouper[] = []; private readonly hideOptionsRegexp = - /^(hide|show) (task count|backlink|priority|created date|start date|scheduled date|done date|due date|recurrence rule|edit button|urgency)/; + /^(hide|show) (task count|backlink|priority|created date|start date|scheduled date|done date|due date|recurrence rule|edit button|urgency|reminder date)/; private readonly shortModeRegexp = /^short/; private readonly explainQueryRegexp = /^explain/; @@ -216,6 +216,9 @@ export class Query implements IQuery { case 'due date': this._layoutOptions.hideDueDate = hide; break; + case 'reminder date': + this._layoutOptions.hideReminderDate = hide; + break; case 'done date': this._layoutOptions.hideDoneDate = hide; break; diff --git a/src/Recurrence.ts b/src/Recurrence.ts index 18bbb2c948..81e067bd09 100644 --- a/src/Recurrence.ts +++ b/src/Recurrence.ts @@ -1,6 +1,7 @@ import type { Moment } from 'moment'; import { RRule } from 'rrule'; import { compareByDate } from './lib/DateTools'; +import { Reminder, isReminderSame } from './Reminders/Reminder'; export class Recurrence { private readonly rrule: RRule; @@ -8,6 +9,7 @@ export class Recurrence { private readonly startDate: Moment | null; private readonly scheduledDate: Moment | null; private readonly dueDate: Moment | null; + private readonly reminder: Reminder | null; /** * The reference date is used to calculate future occurrences. @@ -31,6 +33,7 @@ export class Recurrence { startDate, scheduledDate, dueDate, + reminder, }: { rrule: RRule; baseOnToday: boolean; @@ -38,6 +41,7 @@ export class Recurrence { startDate: Moment | null; scheduledDate: Moment | null; dueDate: Moment | null; + reminder: Reminder | null; }) { this.rrule = rrule; this.baseOnToday = baseOnToday; @@ -45,6 +49,7 @@ export class Recurrence { this.startDate = startDate; this.scheduledDate = scheduledDate; this.dueDate = dueDate; + this.reminder = reminder; } public static fromText({ @@ -52,11 +57,13 @@ export class Recurrence { startDate, scheduledDate, dueDate, + reminder, }: { recurrenceRuleText: string; startDate: Moment | null; scheduledDate: Moment | null; dueDate: Moment | null; + reminder: Reminder | null; }): Recurrence | null { try { const match = recurrenceRuleText.match(/^([a-zA-Z0-9, !]+?)( when done)?$/i); @@ -79,6 +86,8 @@ export class Recurrence { referenceDate = window.moment(scheduledDate); } else if (startDate) { referenceDate = window.moment(startDate); + } else if (reminder) { + referenceDate = window.moment(reminder.time.format('YYYY-MM-DD')); } if (!baseOnToday && referenceDate !== null) { @@ -95,6 +104,7 @@ export class Recurrence { startDate, scheduledDate, dueDate, + reminder: reminder, }); } } catch (error) { @@ -120,6 +130,7 @@ export class Recurrence { startDate: Moment | null; scheduledDate: Moment | null; dueDate: Moment | null; + reminder: Reminder | null; } | null { const next = this.nextReferenceDate(); @@ -129,6 +140,7 @@ export class Recurrence { let startDate: Moment | null = null; let scheduledDate: Moment | null = null; let dueDate: Moment | null = null; + let reminders: Reminder | null = null; // Only if a reference date is given. A reference date will exist if at // least one of the other dates is set. @@ -157,12 +169,26 @@ export class Recurrence { // Rounding days to handle cross daylight-savings-time recurrences. dueDate.add(Math.round(originalDifference.asDays()), 'days'); } + if (this.reminder) { + { + // TODO Remove braces - added to minimise diffs in large change + const reminder = this.reminder; // TODO Inline reminder + const originalDifference = window.moment.duration(reminder.time.diff(this.referenceDate)); + const remTime = window.moment(next); + + remTime.add(Math.floor(originalDifference.asDays()), 'days'); + // add back time + remTime.set({ hour: reminder.time.hour(), minute: reminder.time.minute() }); + reminders = new Reminder(remTime, reminder.type); + } + } } return { startDate, scheduledDate, dueDate, + reminder: reminders, }; } @@ -185,6 +211,10 @@ export class Recurrence { return false; } + if (!isReminderSame(this.reminder, other.reminder)) { + return false; + } + return this.toText() === other.toText(); // this also checks baseOnToday } diff --git a/src/Reminders/Components/Icon.svelte b/src/Reminders/Components/Icon.svelte new file mode 100644 index 0000000000..60b8589efd --- /dev/null +++ b/src/Reminders/Components/Icon.svelte @@ -0,0 +1,18 @@ + + + + + diff --git a/src/Reminders/Components/Markdown.svelte b/src/Reminders/Components/Markdown.svelte new file mode 100644 index 0000000000..0d3a8b1712 --- /dev/null +++ b/src/Reminders/Components/Markdown.svelte @@ -0,0 +1,20 @@ + + + diff --git a/src/Reminders/Components/Reminder.svelte b/src/Reminders/Components/Reminder.svelte new file mode 100644 index 0000000000..d8db028846 --- /dev/null +++ b/src/Reminders/Components/Reminder.svelte @@ -0,0 +1,82 @@ + + +
+

+ +

+ + + {task.filename ?? "filename was null"} + +
+ + +
+
+ + diff --git a/src/Reminders/Notification.ts b/src/Reminders/Notification.ts new file mode 100644 index 0000000000..0b6e605ace --- /dev/null +++ b/src/Reminders/Notification.ts @@ -0,0 +1,190 @@ +import { App, Modal } from 'obsidian'; +import type { Moment } from 'moment'; +import { TIME_FORMATS, getSettings } from '../Config/Settings'; +import type { Task } from '../Task'; +import { Query } from '../Query/Query'; +import { sameDateTime } from '../lib/DateTools'; +import type { Cache } from '../Cache'; +import { getTaskLineAndFile } from '../File'; +import ReminderView from './Components/Reminder.svelte'; +import { Reminder, ReminderType } from './Reminder'; + +export class TaskNotification { + // this is the default reminder settings not getting updated + constructor(private app: App) {} + + public show(task: Task) { + const { reminderSettings } = getSettings(); + const electron = require('electron'); + const Notification = electron.remote.Notification; + + // if election notification is supported, aka desktop app + if (Notification.isSupported()) { + // Show system notification + const n = new Notification({ + title: reminderSettings.notificationTitle, + body: task.description, + }); + n.on('click', () => { + console.log('Notification clicked'); + n.close(); + this.showBuiltinReminder(task); + }); + // Notification actions only supported in macOS + { + n.on('action', (_: any, index: any) => { + if (index === 0) { + this.onDone(task); + return; + } + }); + const actions = [{ type: 'button', text: 'Mark as Done' }]; + n.actions = actions as any; + } + + n.show(); + } else { + // Show obsidian modal notification for mobile users, must be in app + this.showBuiltinReminder(task); + } + } + + public watcher(cache: Cache): number | undefined { + let intervalTaskRunning = false; + const { reminderSettings } = getSettings(); + // Set up the recurring check for reminders. + return window.setInterval(() => { + if (intervalTaskRunning) { + // console.log('Skip reminder interval task because task is already running.'); + return; + } + intervalTaskRunning = true; + + const tasks = cache.getTasks(); + this.reminderEvent(tasks).finally(() => { + intervalTaskRunning = false; + }); + }, reminderSettings.refreshIntervalMilliseconds); + } + + private async reminderEvent(tasks: Task[]): Promise { + if (!tasks?.length) { + return; // No tasks, nothing to do. + } + + // get list of all future reminders that are not done + const input = 'reminder after yesterday\nnot done'; + const query = new Query({ source: input }); + + // Get list of tasks with reminders + let reminderTasks = [...tasks]; + query.filters.forEach((filter) => { + reminderTasks = reminderTasks.filter(filter.filterFunction); + }); + + const { reminderSettings } = getSettings(); + for (const task of reminderTasks) { + const dailyReminderTime = window.moment( + `${window.moment().format('YYYY-MM-DD')} ${reminderSettings.dailyReminderTime}`, + TIME_FORMATS.twentyFourHour, + ); + + if (task.reminder) { + const rDate = task.reminder; // TODO Inline rDate + const curTime = window.moment(); + if (TaskNotification.shouldNotifiy(rDate, dailyReminderTime, curTime)) { + rDate.notified = true; + this.show(task); + } + } + } + } + + public static shouldNotifiy(rDate: Reminder, dailyReminderTime: Moment, curTime: Moment) { + if (!rDate.notified) { + // daily reminder + if (rDate.type === ReminderType.Date && sameDateTime(dailyReminderTime, curTime)) { + return true; + } else if (sameDateTime(rDate.time, curTime)) { + // specific time reminder + return true; + } + } + return false; + } + + private showBuiltinReminder( + reminder: any, + //onRemindMeLater: (time: DateTime) => void, + ) { + new ObsidianNotificationModal( + this.app, + [1, 2, 3, 4, 5], + reminder, + //onRemindMeLater, + this.onDone, + this.onOpenFile, + ).open(); + } + + // TODO How does this work with recurring tasks? + private onDone(task: Task) { + if (!task.status.isCompleted()) { + task.toggleUpdate(); + } + } + + // TODO Understand and test this. + // TODO What happens if this is invoked in a way that fails to find the task??? + private async onOpenFile(task: Task) { + const result = await getTaskLineAndFile(task, app.vault); + if (result) { + const [line, file] = result; + const leaf = this.app.workspace.getLeaf('tab'); + await leaf.openFile(file, { eState: { line: line } }); + } + } +} + +class ObsidianNotificationModal extends Modal { + constructor( + app: App, + private laters: Array, + private task: Task, + //private onRemindMeLater: (time: any) => void, + private onDone: (task: Task) => void, + private onOpenFile: (task: Task) => void, + ) { + super(app); + } + + override onOpen() { + const { contentEl } = this; + new ReminderView({ + target: contentEl, + props: { + task: this.task, + laters: this.laters, + component: this, + onRemindMeLater: () => { + // this.onRemindMeLater(time); + this.close(); + }, + onDone: () => { + this.onDone(this.task); + this.close(); + }, + onOpenFile: () => { + this.onOpenFile(this.task); + this.close(); + }, + }, + }); + } + + override onClose() { + // Unset the reminder from being displayed. This lets other parts of the plugin continue. + const { contentEl } = this; + contentEl.empty(); + } +} diff --git a/src/Reminders/Reminder.ts b/src/Reminders/Reminder.ts new file mode 100644 index 0000000000..ac35fde2a9 --- /dev/null +++ b/src/Reminders/Reminder.ts @@ -0,0 +1,75 @@ +import type { Moment } from 'moment'; +import { TIME_FORMATS, getSettings } from '../Config/Settings'; + +export class ReminderSettings { + notificationTitle: string = 'Task Reminder'; + dateFormat: string = 'YYYY-MM-DD'; // TODO Do not put format strings in user settings + dailyReminderTime: string = '09:00'; + refreshIntervalMilliseconds: number = 5 * 1000; + + constructor() {} +} + +export enum ReminderType { + Date, + DateTime, +} + +export class Reminder { + public time: Moment; + public type: ReminderType; + public notified: boolean = false; + + constructor(time: Moment, type: ReminderType) { + this.time = time; + this.type = type; + } + + public toString(): string { + const reminderSettings = getSettings().reminderSettings; + if (this.type === ReminderType.Date) { + return this.time.format(reminderSettings.dateFormat); + } + return this.time.format(TIME_FORMATS.twentyFourHour); + } + + // TODO Rename this to identicalTo() - for consistency with similar methods...?? Check how Task does it + isSame(other: Reminder | null) { + return isReminderSame(this, other); + } +} + +// TODO Move this to a named constructor +export function parseDateTime(dateTime: string): Reminder { + const reminder = window.moment(dateTime, TIME_FORMATS.twentyFourHour); + return parseMoment(reminder); +} + +// TODO Move this to a named constructor +export function parseMoment(reminder: Moment): Reminder { + if (reminder.format('h:mm a') === '12:00 am') { + //aka .startOf(day) which is the default time for reminders + return new Reminder(reminder, ReminderType.Date); + } else { + return new Reminder(reminder, ReminderType.DateTime); + } +} + +// TODO Add detailed tests +// TODO Move to Reminder class +export function isReminderSame(a: Reminder | null, b: Reminder | null) { + if (a === null && b !== null) { + return false; + } else if (a !== null && b === null) { + return false; + } else if (a !== null && b !== null) { + if (!a.time.isSame(b.time)) { + return false; + } + if (a.type != b.type) { + return false; + } + } + + return true; +} diff --git a/src/Suggestor/Suggestor.ts b/src/Suggestor/Suggestor.ts index b30d2e1d74..ad61b285c7 100644 --- a/src/Suggestor/Suggestor.ts +++ b/src/Suggestor/Suggestor.ts @@ -273,6 +273,7 @@ function addRecurrenceSuggestions(line: string, cursorPos: number, settings: Set startDate: null, scheduledDate: null, dueDate: null, + reminder: null, })?.toText(); if (parsedRecurrence) { const appendedText = `${recurrencePrefix} ${parsedRecurrence} `; diff --git a/src/Task.ts b/src/Task.ts index 741fb5d5ff..6f5b5b4abc 100644 --- a/src/Task.ts +++ b/src/Task.ts @@ -10,6 +10,9 @@ import { renderTaskLine } from './TaskLineRenderer'; import type { TaskLineRenderDetails } from './TaskLineRenderer'; import { DateFallback } from './DateFallback'; import { compareByDate } from './lib/DateTools'; +import type { Reminder } from './Reminders/Reminder'; +import { replaceTaskWithTasks } from './File'; +import { isReminderSame } from './Reminders/Reminder'; /** * When sorting, make sure low always comes after none. This way any tasks with low will be below any exiting @@ -101,6 +104,7 @@ export class Task { public readonly taskLocation: TaskLocation; public readonly tags: string[]; + public readonly reminder: Reminder | null; // TODO Move this to after doneDate?? public readonly priority: Priority; @@ -139,6 +143,7 @@ export class Task { recurrence, blockLink, tags, + reminder, originalMarkdown, scheduledDateIsInferred, }: { @@ -156,6 +161,7 @@ export class Task { recurrence: Recurrence | null; blockLink: string; tags: string[] | []; + reminder: Reminder | null; originalMarkdown: string; scheduledDateIsInferred: boolean; }) { @@ -166,6 +172,7 @@ export class Task { this.taskLocation = taskLocation; this.tags = tags; + this.reminder = reminder; this.priority = priority; @@ -315,6 +322,7 @@ export class Task { startDate: Moment | null; scheduledDate: Moment | null; dueDate: Moment | null; + reminder: Reminder | null; } | null = null; if (newStatus.isCompleted()) { @@ -388,6 +396,18 @@ export class Task { return recurrenceOnNextLine ? newTasks.reverse() : newTasks; } + // toggle task status and update + // TODO Understand why this method exists. + // TODO Check that this method has tests. + public toggleUpdate() { + const newTasks = this.toggle(); + + replaceTaskWithTasks({ + originalTask: this, + newTasks: newTasks, + }); + } + public get urgency(): number { if (this._urgency === null) { this._urgency = Urgency.calculate(this); @@ -526,6 +546,11 @@ export class Task { return false; } + // compare reminders + if (!isReminderSame(this.reminder, other.reminder)) { + return false; + } + // Compare Date fields args = ['createdDate', 'startDate', 'scheduledDate', 'dueDate', 'doneDate']; for (const el of args) { diff --git a/src/TaskLayout.ts b/src/TaskLayout.ts index 2597e7ec7f..8640dac634 100644 --- a/src/TaskLayout.ts +++ b/src/TaskLayout.ts @@ -11,6 +11,7 @@ export class LayoutOptions { hideScheduledDate: boolean = false; hideDoneDate: boolean = false; hideDueDate: boolean = false; + hideReminderDate: boolean = false; hideRecurrenceRule: boolean = false; hideEditButton: boolean = false; hideUrgency: boolean = true; @@ -26,6 +27,7 @@ export type TaskLayoutComponent = | 'startDate' | 'scheduledDate' | 'dueDate' + | 'reminders' | 'doneDate' | 'blockLink'; @@ -43,6 +45,7 @@ export class TaskLayout { 'startDate', 'scheduledDate', 'dueDate', + 'reminders', 'doneDate', 'blockLink', ]; @@ -100,6 +103,7 @@ export class TaskLayout { newComponents = removeIf(newComponents, layoutOptions.hideScheduledDate, 'scheduledDate'); newComponents = removeIf(newComponents, layoutOptions.hideDueDate, 'dueDate'); newComponents = removeIf(newComponents, layoutOptions.hideDoneDate, 'doneDate'); + newComponents = removeIf(newComponents, layoutOptions.hideReminderDate, 'reminders'); // The following components are handled in QueryRenderer.ts and thus are not part of the same flow that // hides TaskLayoutComponent items. However, we still want to have 'tasks-layout-hide' items for them // (see https://github.com/obsidian-tasks-group/obsidian-tasks/issues/1866). diff --git a/src/TaskLineRenderer.ts b/src/TaskLineRenderer.ts index dd94f78d13..f604549beb 100644 --- a/src/TaskLineRenderer.ts +++ b/src/TaskLineRenderer.ts @@ -25,6 +25,7 @@ export const LayoutClasses: { [c in TaskLayoutComponent]: string } = { createdDate: 'task-created', scheduledDate: 'task-scheduled', doneDate: 'task-done', + reminders: 'task-reminders', recurrenceRule: 'task-recurring', blockLink: '', }; diff --git a/src/TaskSerializer/DataviewTaskSerializer.ts b/src/TaskSerializer/DataviewTaskSerializer.ts index b7f2519d3a..22007bd6b1 100644 --- a/src/TaskSerializer/DataviewTaskSerializer.ts +++ b/src/TaskSerializer/DataviewTaskSerializer.ts @@ -71,6 +71,7 @@ export const DATAVIEW_SYMBOLS = { dueDateSymbol: 'due::', doneDateSymbol: 'completion::', recurrenceSymbol: 'repeat::', + reminderDateSymbol: 'remind::', TaskFormatRegularExpressions: { priorityRegex: toInlineFieldRegex(/priority:: *(high|medium|low)/), startDateRegex: toInlineFieldRegex(/start:: *(\d{4}-\d{2}-\d{2})/), @@ -79,6 +80,11 @@ export const DATAVIEW_SYMBOLS = { dueDateRegex: toInlineFieldRegex(/due:: *(\d{4}-\d{2}-\d{2})/), doneDateRegex: toInlineFieldRegex(/completion:: *(\d{4}-\d{2}-\d{2})/), recurrenceRegex: toInlineFieldRegex(/repeat:: *([a-zA-Z0-9, !]+)/), + // TODO Remove the duplication of regex from emoji parsing code + // TODO Add tests and then actually hook up reminder reading in dataview format + reminderRegex: toInlineFieldRegex( + /remind:: *((\d{4}-\d{2}-\d{2}(?: \d{1,2}:\d{2} (?:am|pm|PM|AM))?\s*(?:,\s*)?)+)\b/, + ), }, } as const; diff --git a/src/TaskSerializer/DefaultTaskSerializer.ts b/src/TaskSerializer/DefaultTaskSerializer.ts index 73ce50fc0a..56b631d814 100644 --- a/src/TaskSerializer/DefaultTaskSerializer.ts +++ b/src/TaskSerializer/DefaultTaskSerializer.ts @@ -1,4 +1,6 @@ import type { Moment } from 'moment'; +import { TIME_FORMATS } from '../Config/Settings'; +import { Reminder, parseMoment } from '../Reminders/Reminder'; import { TaskLayout } from '../TaskLayout'; import type { TaskLayoutComponent } from '../TaskLayout'; import { Recurrence } from '../Recurrence'; @@ -24,6 +26,7 @@ export interface DefaultTaskSerializerSymbols { readonly dueDateSymbol: string; readonly doneDateSymbol: string; readonly recurrenceSymbol: string; + readonly reminderDateSymbol: string; readonly TaskFormatRegularExpressions: { priorityRegex: RegExp; startDateRegex: RegExp; @@ -32,6 +35,7 @@ export interface DefaultTaskSerializerSymbols { dueDateRegex: RegExp; doneDateRegex: RegExp; recurrenceRegex: RegExp; + reminderRegex: RegExp; }; } @@ -52,6 +56,7 @@ export const DEFAULT_SYMBOLS: DefaultTaskSerializerSymbols = { dueDateSymbol: '📅', doneDateSymbol: '✅', recurrenceSymbol: '🔁', + reminderDateSymbol: '⏰️', TaskFormatRegularExpressions: { // The following regex's end with `$` because they will be matched and // removed from the end until none are left. @@ -62,6 +67,7 @@ export const DEFAULT_SYMBOLS: DefaultTaskSerializerSymbols = { dueDateRegex: /[📅📆🗓] *(\d{4}-\d{2}-\d{2})$/u, doneDateRegex: /✅ *(\d{4}-\d{2}-\d{2})$/u, recurrenceRegex: /🔁 ?([a-zA-Z0-9, !]+)$/iu, + reminderRegex: /⏰️ *(\d{4}-\d{2}-\d{2}(?: \d{2}:\d{2})?)/u, }, } as const; @@ -95,6 +101,7 @@ export class DefaultTaskSerializer implements TaskSerializer { doneDateSymbol, recurrenceSymbol, dueDateSymbol, + reminderDateSymbol, } = this.symbols; switch (component) { @@ -137,6 +144,11 @@ export class DefaultTaskSerializer implements TaskSerializer { return layout.options.shortMode ? ' ' + dueDateSymbol : ` ${dueDateSymbol} ${task.dueDate.format(TaskRegularExpressions.dateFormat)}`; + case 'reminders': // TODO Rename to singular + if (!task.reminder) return ''; + return layout.options.shortMode + ? ' ' + reminderDateSymbol + : ` ${reminderDateSymbol} ${task.reminder.toString()}`; case 'recurrenceRule': if (!task.recurrence) return ''; return layout.options.shortMode @@ -189,6 +201,7 @@ export class DefaultTaskSerializer implements TaskSerializer { let scheduledDate: Moment | null = null; let dueDate: Moment | null = null; let doneDate: Moment | null = null; + let rList: Reminder | null = null; // TODO Rename to reminder let createdDate: Moment | null = null; let recurrenceRule: string = ''; let recurrence: Recurrence | null = null; @@ -265,6 +278,14 @@ export class DefaultTaskSerializer implements TaskSerializer { trailingTags = trailingTags.length > 0 ? [tagName, trailingTags].join(' ') : tagName; } + const reminderMatch = line.match(TaskFormatRegularExpressions.reminderRegex); + if (reminderMatch !== null) { + line = line.replace(TaskFormatRegularExpressions.reminderRegex, '').trim(); + const reminderDate2 = reminderMatch[1]; + const reminder = window.moment(reminderDate2, TIME_FORMATS.twentyFourHour); + rList = parseMoment(reminder); + matched = true; + } runs++; } while (matched && runs <= maxRuns); @@ -275,6 +296,7 @@ export class DefaultTaskSerializer implements TaskSerializer { startDate, scheduledDate, dueDate, + reminder: rList, }); } // Add back any trailing tags to the description. We removed them so we can parse the rest of the @@ -293,6 +315,7 @@ export class DefaultTaskSerializer implements TaskSerializer { doneDate, recurrence, tags: Task.extractHashtags(line), + reminder: rList, }; } } diff --git a/src/TaskSerializer/index.ts b/src/TaskSerializer/index.ts index 85d6820122..301f67c2d6 100644 --- a/src/TaskSerializer/index.ts +++ b/src/TaskSerializer/index.ts @@ -20,6 +20,7 @@ export type TaskDetails = Writeable< | 'doneDate' | 'recurrence' | 'tags' + | 'reminder' > >; diff --git a/src/lib/DateTools.ts b/src/lib/DateTools.ts index f6581527f9..ffbed6241a 100644 --- a/src/lib/DateTools.ts +++ b/src/lib/DateTools.ts @@ -21,3 +21,7 @@ export function compareByDate(a: moment.Moment | null, b: moment.Moment | null): return 0; } } + +export function sameDateTime(a: moment.Moment, b: moment.Moment) { + return a.format('YYYY-MM-DD HH:mm') === b.format('YYYY-MM-DD HH:mm'); +} diff --git a/src/main.ts b/src/main.ts index c55f403e86..1217611c40 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,6 @@ import { Plugin } from 'obsidian'; +import { TaskNotification } from './Reminders/Notification'; import { Cache } from './Cache'; import { Commands } from './Commands'; import { TasksEvents } from './TasksEvents'; @@ -20,6 +21,7 @@ export default class TasksPlugin extends Plugin { private cache: Cache | undefined; public inlineRenderer: InlineRenderer | undefined; public queryRenderer: QueryRenderer | undefined; + private taskNotification: TaskNotification | undefined; get apiV1() { return tasksApiV1(app); @@ -28,6 +30,7 @@ export default class TasksPlugin extends Plugin { async onload() { logging.registerConsoleLogger(); console.log('loading plugin "tasks"'); + this.taskNotification = new TaskNotification(this.app); await this.loadSettings(); this.addSettingTab(new SettingsTab({ plugin: this })); @@ -53,6 +56,14 @@ export default class TasksPlugin extends Plugin { this.registerEditorExtension(newLivePreviewExtension()); this.registerEditorSuggest(new EditorSuggestor(this.app, getSettings())); new Commands({ plugin: this }); + + // Register the watcher for reminders. + this.app.workspace.onLayoutReady(async () => { + if (this.taskNotification && this.cache) { + this.registerInterval(this.taskNotification.watcher(this.cache) ?? 0); + } + // TODO Is it possible for this.cache to not yet be set up? + }); } async loadTaskStatuses() { diff --git a/src/ui/EditTask.svelte b/src/ui/EditTask.svelte index ea48fbc471..6506867fcd 100644 --- a/src/ui/EditTask.svelte +++ b/src/ui/EditTask.svelte @@ -2,11 +2,12 @@ import * as chrono from 'chrono-node'; import { onMount } from 'svelte'; import { Recurrence } from '../Recurrence'; - import { getSettings, TASK_FORMATS } from '../Config/Settings'; + import { getSettings, TASK_FORMATS, TIME_FORMATS} from '../Config/Settings'; import { GlobalFilter } from '../Config/GlobalFilter'; import { Status } from '../Status'; import { Priority, Task } from '../Task'; import { doAutocomplete } from '../DateAbbreviations'; + import { parseMoment } from '../Reminders/Reminder'; // These exported variables are passed in as props by TaskModal.onOpen(): export let task: Task; @@ -19,6 +20,7 @@ startDateSymbol, scheduledDateSymbol, dueDateSymbol, + reminderDateSymbol, } = TASK_FORMATS.tasksPluginEmoji.taskSerializer.symbols; let descriptionInput: HTMLTextAreaElement; @@ -32,6 +34,7 @@ scheduledDate: string; dueDate: string; doneDate: string; + reminderDate: string; forwardOnly: boolean; } = { description: '', @@ -43,6 +46,7 @@ scheduledDate: '', dueDate: '', doneDate: '', + reminderDate: '', forwardOnly: true }; @@ -54,6 +58,8 @@ let isScheduledDateValid: boolean = true; let parsedDueDate: string = ''; let isDueDateValid: boolean = true; + let parsedReminderDate: string = ''; + let isReminderDateValid: boolean = true; let parsedRecurrence: string = ''; let isRecurrenceValid: boolean = true; let parsedDone: string = ''; @@ -110,7 +116,7 @@ * @returns the parsed date string. Includes "invalid" if {@code typedDate} was invalid. */ function parseTypedDateForDisplay( - fieldName: 'created' | 'start' | 'scheduled' | 'due' | 'done', + fieldName: 'created' | 'start' | 'scheduled' | 'due' | 'reminder' | 'done', typedDate: string, forwardDate: Date | undefined = undefined, ): string { @@ -121,7 +127,11 @@ forwardDate: forwardDate != undefined, }); if (parsed !== null) { - return window.moment(parsed).format('YYYY-MM-DD'); + if (fieldName === 'reminder') { + return window.moment(parsed).format(TIME_FORMATS.twentyFourHour); + }else{ + return window.moment(parsed).format('YYYY-MM-DD'); + } } return `invalid ${fieldName} date`; } @@ -132,7 +142,7 @@ * @param typedDate - what the user has entered, such as '2023-01-23' or 'tomorrow' * @returns the parsed date string. Includes "invalid" if {@code typedDate} was invalid. */ - function parseTypedDateForDisplayUsingFutureDate(fieldName: 'start' | 'scheduled' | 'due' | 'done', typedDate: string): string { + function parseTypedDateForDisplayUsingFutureDate(fieldName: 'start' | 'scheduled' | 'due' | 'done' | 'reminder', typedDate: string): string { return parseTypedDateForDisplay( fieldName, typedDate, @@ -158,7 +168,7 @@ } $: accesskey = (key: string) => withAccessKeys ? key : null; - $: formIsValid = isDueDateValid && isRecurrenceValid && isScheduledDateValid && isStartDateValid && isDescriptionValid; + $: formIsValid = isDueDateValid && isRecurrenceValid && isScheduledDateValid && isStartDateValid && isReminderDateValid && isDescriptionValid; $: isDescriptionValid = editableTask.description.trim() !== ''; $: { @@ -179,6 +189,12 @@ isDueDateValid = !parsedDueDate.includes('invalid'); } + $: { + editableTask.reminderDate = doAutocomplete(editableTask.reminderDate); + parsedReminderDate = parseTypedDateForDisplayUsingFutureDate('reminder', editableTask.reminderDate); + isReminderDateValid = !parsedReminderDate.includes('invalid'); + } + $: { isRecurrenceValid = true; if (!editableTask.recurrenceRule) { @@ -190,6 +206,7 @@ startDate: null, scheduledDate: null, dueDate: null, + reminder: null, })?.toText(); if (!recurrenceFromText) { parsedRecurrence = 'invalid recurrence rule'; @@ -239,6 +256,7 @@ ? task.scheduledDate.format('YYYY-MM-DD') : '', dueDate: task.dueDate ? task.dueDate.format('YYYY-MM-DD') : '', + reminderDate: task.reminder? task.reminder!.time.format(TIME_FORMATS.twentyFourHour) : '', doneDate: task.doneDate ? task.doneDate.format('YYYY-MM-DD') : '', forwardOnly: true, }; @@ -287,6 +305,9 @@ const dueDate = parseTypedDateForSaving(editableTask.dueDate); + // TODO Add tests for all the reminder handling inside the Edit modal tests + const reminderDate = parseTypedDateForSaving(editableTask.reminderDate); + let recurrence: Recurrence | null = null; if (editableTask.recurrenceRule) { recurrence = Recurrence.fromText({ @@ -294,6 +315,7 @@ startDate, scheduledDate, dueDate, + reminder: reminderDate ? parseMoment(reminderDate) : null, }); } @@ -321,6 +343,7 @@ startDate, scheduledDate, dueDate, + reminder: reminderDate ? parseMoment(reminderDate) : null, doneDate: window .moment(editableTask.doneDate, 'YYYY-MM-DD') .isValid() @@ -443,6 +466,21 @@ /> {startDateSymbol} {@html parsedStartDate} + + + + + + + {reminderDateSymbol} {@html parsedReminderDate} + diff --git a/tests/CustomMatchers/CustomMatchersForFilters.ts b/tests/CustomMatchers/CustomMatchersForFilters.ts index c4c6c849a5..705f577a45 100644 --- a/tests/CustomMatchers/CustomMatchersForFilters.ts +++ b/tests/CustomMatchers/CustomMatchersForFilters.ts @@ -138,13 +138,17 @@ export function toMatchTask(filter: FilterOrErrorMessage, task: Task) { const matches = filter.filterFunction!(task); if (!matches) { return { - message: () => `unexpected failure to match task: ${task.toFileLineString()}`, + message: () => `unexpected failure to match +task: "${task.toFileLineString()}" +with filter: "${filter.instruction}"`, pass: false, }; } return { - message: () => `filter should not have matched task: ${task.toFileLineString()}`, + message: () => `filter should not have matched +task: "${task.toFileLineString()}" +with filter: "${filter.instruction}"`, pass: true, }; } diff --git a/tests/CustomMatchers/CustomMatchersForTaskSerializer.ts b/tests/CustomMatchers/CustomMatchersForTaskSerializer.ts index 2c9814cdbf..865d0cbede 100644 --- a/tests/CustomMatchers/CustomMatchersForTaskSerializer.ts +++ b/tests/CustomMatchers/CustomMatchersForTaskSerializer.ts @@ -80,6 +80,7 @@ function summarizeTaskDetails(t: TaskDetails | null): SummarizedTaskDetails | nu dueDate: t.dueDate?.format(TaskRegularExpressions.dateFormat) ?? null, doneDate: t.doneDate?.format(TaskRegularExpressions.dateFormat) ?? null, recurrence: t.recurrence?.toText() ?? null, + reminder: t.reminder?.toString() ?? null, }; } @@ -103,6 +104,7 @@ function tryBuildTaskDetails(t: object): TaskDetails | null { doneDate: null, recurrence: null, tags: [], + reminder: null, ...t, }; if (!isTaskDetails(toReturn)) return null; diff --git a/tests/Query.test.ts b/tests/Query.test.ts index bcc93a1056..f829d87e4f 100644 --- a/tests/Query.test.ts +++ b/tests/Query.test.ts @@ -50,6 +50,7 @@ describe('Query parsing', () => { 'has done date', 'has due date', 'has happens date', + 'has reminder date', 'has scheduled date', 'has start date', 'has tags', @@ -63,6 +64,7 @@ describe('Query parsing', () => { 'no due date', 'no due date', 'no happens date', + 'no reminder date', 'no scheduled date', 'no start date', 'no tags', @@ -79,6 +81,13 @@ describe('Query parsing', () => { 'priority is none', 'recurrence does not include wednesday', 'recurrence includes wednesday', + 'reminder after 2021-12-27', + 'reminder after yesterday', + 'reminder before 2021-12-27', + 'reminder date is invalid', + 'reminder in 2021-12-27 2021-12-29', + 'reminder on 2021-12-27', + 'reminder this week', 'scheduled after 2021-12-27', 'scheduled before 2021-12-27', 'scheduled date is invalid', @@ -169,6 +178,8 @@ describe('Query parsing', () => { 'sort by priority reverse', 'sort by priority', 'sort by recurring', + 'sort by reminder reverse', + 'sort by reminder', 'sort by scheduled reverse', 'sort by scheduled', 'sort by start reverse', @@ -224,6 +235,8 @@ describe('Query parsing', () => { 'group by recurrence reverse', 'group by recurring', 'group by recurring reverse', + 'group by reminder', + 'group by reminder reverse', 'group by root', 'group by root reverse', 'group by scheduled', @@ -264,6 +277,7 @@ describe('Query parsing', () => { 'hide edit button', 'hide priority', 'hide recurrence rule', + 'hide reminder date', 'hide scheduled date', 'hide start date', 'hide task count', @@ -280,6 +294,7 @@ describe('Query parsing', () => { 'show edit button', 'show priority', 'show recurrence rule', + 'show reminder date', 'show scheduled date', 'show created date', 'show start date', @@ -342,6 +357,7 @@ describe('Query', () => { recurrence: null, blockLink: '', tags: [], + reminder: null, originalMarkdown: '', scheduledDateIsInferred: false, createdDate: null, @@ -360,6 +376,7 @@ describe('Query', () => { recurrence: null, blockLink: '', tags: [], + reminder: null, originalMarkdown: '', scheduledDateIsInferred: false, createdDate: null, @@ -425,6 +442,21 @@ describe('Query', () => { ], }, ], + [ + 'by reminder date presence', + { + filters: ['has reminder date'], + tasks: [ + '- [ ] task 1', + '- [ ] task 2 🛫 2022-04-20 ⏳ 2022-04-20 ⏰️ 2022-04-20', + '- [ ] task 3 ⏰️ 2022-04-20', + ], + expectedResult: [ + '- [ ] task 2 🛫 2022-04-20 ⏳ 2022-04-20 ⏰️ 2022-04-20', + '- [ ] task 3 ⏰️ 2022-04-20', + ], + }, + ], [ 'by due date absence', { @@ -461,6 +493,18 @@ describe('Query', () => { expectedResult: ['- [ ] task 1'], }, ], + [ + 'by reminder date absence', + { + filters: ['no reminder date'], + tasks: [ + '- [ ] task 1', + '- [ ] task 2 🛫 2022-04-20 ⏳ 2022-04-20 ⏰️ 2022-04-20', + '- [ ] task 3 ⏰️ 2022-04-20', + ], + expectedResult: ['- [ ] task 1'], + }, + ], [ 'by start date (before)', { diff --git a/tests/Query/Filter/ReminderDateField.test.ts b/tests/Query/Filter/ReminderDateField.test.ts new file mode 100644 index 0000000000..61c6fe9ad1 --- /dev/null +++ b/tests/Query/Filter/ReminderDateField.test.ts @@ -0,0 +1,155 @@ +/** + * @jest-environment jsdom + */ +import moment from 'moment'; +import { ReminderDateField } from '../../../src/Query/Filter/ReminderDateField'; +import { TaskBuilder } from '../../TestingTools/TaskBuilder'; +import { expectTaskComparesAfter, expectTaskComparesBefore } from '../../CustomMatchers/CustomMatchersForSorting'; +import type { FilterOrErrorMessage } from '../../../src/Query/Filter/Filter'; +import { testFilter } from '../../TestingTools/FilterTestHelpers'; + +window.moment = moment; + +function testTaskFilterForTaskWithReminderDate( + filter: FilterOrErrorMessage, + reminderDateTime: string | null, + expected: boolean, +) { + const builder = new TaskBuilder(); + testFilter(filter, builder.reminder(reminderDateTime), expected); +} + +describe('reminder date', () => { + afterAll(() => { + jest.useRealTimers(); + }); + + it('by reminder date (before)', () => { + // Arrange + const filter = new ReminderDateField().createFilterOrErrorMessage('reminder before 2022-04-20 11:45'); + + // Act, Assert + testTaskFilterForTaskWithReminderDate(filter, null, false); + testTaskFilterForTaskWithReminderDate(filter, '2022-04-15', true); + testTaskFilterForTaskWithReminderDate(filter, '2022-04-19 23:59', true); + + // Filter matches whole day, even though it was supplied with a time, so reminders + // with any time on the filter's day should match. + testTaskFilterForTaskWithReminderDate(filter, '2022-04-20', false); + testTaskFilterForTaskWithReminderDate(filter, '2022-04-20 11:44', false); + testTaskFilterForTaskWithReminderDate(filter, '2022-04-20 11:45', false); + testTaskFilterForTaskWithReminderDate(filter, '2022-04-20 11:46', false); + + testTaskFilterForTaskWithReminderDate(filter, '2022-04-21', false); + }); + + it('by reminder date (after)', () => { + // Arrange + const filter = new ReminderDateField().createFilterOrErrorMessage('reminder after 2022-04-19 11:45'); + + // Act, Assert + testTaskFilterForTaskWithReminderDate(filter, null, false); + testTaskFilterForTaskWithReminderDate(filter, '2022-04-15', false); + testTaskFilterForTaskWithReminderDate(filter, '2022-04-18 23:59', false); + + // Filter matches whole day, even though it was supplied with a time, so reminders + // with any time on the filter's day should match. + testTaskFilterForTaskWithReminderDate(filter, '2022-04-20', true); + testTaskFilterForTaskWithReminderDate(filter, '2022-04-20 11:44', true); + testTaskFilterForTaskWithReminderDate(filter, '2022-04-20 11:45', true); + testTaskFilterForTaskWithReminderDate(filter, '2022-04-20 11:46', true); + + testTaskFilterForTaskWithReminderDate(filter, '2022-04-21', true); + }); + + it('by reminder date (on) - with filter containing date only', () => { + // Arrange + const filter = new ReminderDateField().createFilterOrErrorMessage('reminder on 2022-04-20'); + + // Act, Assert + testTaskFilterForTaskWithReminderDate(filter, null, false); + testTaskFilterForTaskWithReminderDate(filter, '2022-04-15', false); + testTaskFilterForTaskWithReminderDate(filter, '2022-04-20 09:15', true); + testTaskFilterForTaskWithReminderDate(filter, '2022-04-20', true); + testTaskFilterForTaskWithReminderDate(filter, '2022-04-25', false); + }); + + it('by reminder date (on) - with filter containing date and time', () => { + // Arrange + const filter = new ReminderDateField().createFilterOrErrorMessage('reminder on 2022-04-20 15:43'); + + // Act, Assert + testTaskFilterForTaskWithReminderDate(filter, null, false); + testTaskFilterForTaskWithReminderDate(filter, '2022-04-15', false); + + // Filter matches whole day, even though it was supplied with a time, so reminders + // with any time on the filter's day should match. + testTaskFilterForTaskWithReminderDate(filter, '2022-04-20', true); + testTaskFilterForTaskWithReminderDate(filter, '2022-04-20 09:15', true); + testTaskFilterForTaskWithReminderDate(filter, '2022-04-20 15:43', true); + + testTaskFilterForTaskWithReminderDate(filter, '2022-04-25', false); + }); +}); + +describe('explain reminder date queries', () => { + it('should explain explicit date', () => { + const filterOrMessage = new ReminderDateField().createFilterOrErrorMessage('reminder before 2023-01-02'); + expect(filterOrMessage).toHaveExplanation('reminder date is before 2023-01-02 (Monday 2nd January 2023)'); + }); + + it('should show that times in reminder filters are ignored', () => { + const filterOrMessage = new ReminderDateField().createFilterOrErrorMessage('reminder on 2023-01-02 15:43'); + expect(filterOrMessage).toHaveExplanation('reminder date is on 2023-01-02 (Monday 2nd January 2023)'); + }); + + it('implicit "on" gets added to explanation', () => { + const filterOrMessage = new ReminderDateField().createFilterOrErrorMessage('reminder 2023-01-02'); + expect(filterOrMessage).toHaveExplanation('reminder date is on 2023-01-02 (Monday 2nd January 2023)'); + }); +}); + +describe('sorting by reminder', () => { + it('supports Field sorting methods correctly', () => { + const field = new ReminderDateField(); + expect(field.supportsSorting()).toEqual(true); + }); + + // These are minimal tests just to confirm basic behaviour is set up for this field. + // Thorough testing is done in DueDateField.test.ts. + + // Because Reminder is the first field to support times, we do need to test + // that sorting takes account of differences in reminder time. + const date1 = new TaskBuilder().reminder('2021-01-12').build(); + const date2 = new TaskBuilder().reminder('2022-12-23').build(); + const date3 = new TaskBuilder().reminder('2022-12-23 09:27').build(); + const date4 = new TaskBuilder().reminder('2022-12-23 13:59').build(); + + it('sort by reminder', () => { + const sorter = new ReminderDateField().createNormalSorter(); + expectTaskComparesBefore(sorter, date1, date2); + expectTaskComparesBefore(sorter, date2, date3); + expectTaskComparesBefore(sorter, date3, date4); + }); + + it('sort by reminder reverse', () => { + expectTaskComparesAfter(new ReminderDateField().createReverseSorter(), date1, date2); + }); +}); + +describe('grouping by reminder date', () => { + it('supports Field grouping methods correctly', () => { + expect(new ReminderDateField()).toSupportGroupingWithProperty('reminder'); + }); + + it('group by reminder date', () => { + // Arrange + const grouper = new ReminderDateField().createNormalGrouper(); + const taskWithDate = new TaskBuilder().reminder('1970-01-01').build(); + const taskWithoutDate = new TaskBuilder().build(); + + // Assert + expect(grouper.grouper(taskWithDate)).toEqual(['1970-01-01 Thursday']); + expect(grouper.grouper(taskWithoutDate)).toEqual(['No reminder date']); + }); +}); diff --git a/tests/Recurrence.test.ts b/tests/Recurrence.test.ts index 9892613774..cb5a33f821 100644 --- a/tests/Recurrence.test.ts +++ b/tests/Recurrence.test.ts @@ -2,7 +2,9 @@ * @jest-environment jsdom */ import moment from 'moment'; +import { parseMoment } from '../src/Reminders/Reminder'; import { Recurrence } from '../src/Recurrence'; +import { TIME_FORMATS } from '../src/Config/Settings'; import { RecurrenceBuilder } from './TestingTools/RecurrenceBuilder'; jest.mock('obsidian'); @@ -16,6 +18,7 @@ describe('Recurrence', () => { startDate: null, scheduledDate: null, dueDate: null, + reminder: null, }); // Act @@ -26,6 +29,7 @@ describe('Recurrence', () => { startDate: null, scheduledDate: null, dueDate: null, + reminder: null, }); }); @@ -36,6 +40,7 @@ describe('Recurrence', () => { startDate: null, scheduledDate: null, dueDate: moment('2022-01-31').startOf('day'), + reminder: null, }); // Act @@ -54,6 +59,7 @@ describe('Recurrence', () => { startDate: null, scheduledDate: null, dueDate: moment('2022-01-31').startOf('day'), + reminder: null, }); // Act @@ -72,6 +78,7 @@ describe('Recurrence', () => { startDate: null, scheduledDate: null, dueDate: moment('2023-12-31').startOf('day'), + reminder: null, }); // Act @@ -90,6 +97,7 @@ describe('Recurrence', () => { startDate: null, scheduledDate: null, dueDate: moment('2024-02-29').startOf('day'), + reminder: null, }); // Act @@ -108,6 +116,7 @@ describe('Recurrence', () => { startDate: null, scheduledDate: null, dueDate: moment('2020-03-31').startOf('day'), + reminder: null, }); // Act @@ -126,6 +135,7 @@ describe('Recurrence', () => { startDate: null, scheduledDate: null, dueDate: moment('2020-01-31').startOf('day'), + reminder: null, }); // Act @@ -148,6 +158,7 @@ describe('Recurrence - with invalid dates in tasks', () => { startDate: null, scheduledDate: null, dueDate: moment('2022-02-30').startOf('day'), // 30th February: invalid date + reminder: null, }); // Assert @@ -168,6 +179,7 @@ describe('Recurrence - with invalid dates in tasks', () => { startDate: null, scheduledDate: moment('2022-02-30').startOf('day'), // 30th February: invalid date dueDate: moment('2022-02-27').startOf('day'), + reminder: null, }); // Act @@ -231,4 +243,44 @@ describe('identicalTo', () => { expect(date1Recurrence?.identicalTo(date1Recurrence)).toBe(true); expect(date1Recurrence?.identicalTo(date2Recurrence)).toBe(false); }); + + it('differing only in reminder', () => { + const date1Recurrence = new RecurrenceBuilder().reminders('2021-10-21').build(); + + const date2Recurrence = new RecurrenceBuilder().reminders('1998-03-13').build(); + + const nullRecurrence = new RecurrenceBuilder().reminders('').build(); + + expect(date1Recurrence?.identicalTo(date1Recurrence)).toBe(true); + expect(date1Recurrence?.identicalTo(date2Recurrence)).toBe(false); + expect(date1Recurrence?.identicalTo(nullRecurrence)).toBe(false); + expect(nullRecurrence?.identicalTo(date1Recurrence)).toBe(false); + }); +}); + +describe('Recurrence - with reminders', () => { + it('creates a recurring instance with single 24h reminders', () => { + // Arrange + const originalReminder = parseMoment(moment('2021-06-20 13:00', TIME_FORMATS.twentyFourHour)); + const originalReminderAsString = originalReminder.toString(); + const recurrence = Recurrence.fromText({ + recurrenceRuleText: 'every week', + startDate: null, + scheduledDate: null, + dueDate: null, + reminder: originalReminder, + }); + + // Act + const next = recurrence!.next(); + + // Assert + expect( + next!.reminder!.isSame(parseMoment(moment('2021-06-27 13:00', TIME_FORMATS.twentyFourHour))), + ).toStrictEqual(true); + expect(next!.reminder!.toString()).toStrictEqual('2021-06-27 13:00'); + + // Confirm that the original date has not been modified + expect(originalReminder.toString()).toStrictEqual(originalReminderAsString); + }); }); diff --git a/tests/Reminder/Notifications.test.ts b/tests/Reminder/Notifications.test.ts new file mode 100644 index 0000000000..1a8f116cf8 --- /dev/null +++ b/tests/Reminder/Notifications.test.ts @@ -0,0 +1,37 @@ +/** + * @jest-environment jsdom + */ +jest.mock('obsidian'); +import moment from 'moment'; +import { Reminder, ReminderType, parseMoment } from '../../src/Reminders/Reminder'; +//import { TaskNotification } from '../../src/Reminders/Notification'; +window.moment = moment; + +describe('Notifications ', () => { + it('testing parsing Moment() datetime', () => { + const reminder = moment('2023-04-30 11:44 am'); + + expect(parseMoment(reminder)).toStrictEqual(new Reminder(moment('2023-04-30 11:44 am'), ReminderType.DateTime)); + }); + + // it('testing shouldNotifiy', () => { + // const curTime = moment('2023-04-30 11:50 am'); + // const dailyReminder = parseMoment(moment('2023-04-30')); + // const timeReminder = parseMoment(moment('2023-04-30 11:50 am')); + // const oldReminder = parseMoment(moment('2023-04-30 11:49 am')); + // const futureRinder = parseMoment(moment('2023-04-30 11:51 am')); + + // expect(TaskNotification.shouldNotifiy(dailyReminder, curTime, curTime)).toBe(true); + // expect(TaskNotification.shouldNotifiy(timeReminder, curTime, curTime)).toBe(true); + // expect(TaskNotification.shouldNotifiy(oldReminder, curTime, curTime)).toBe(false); + // expect(TaskNotification.shouldNotifiy(futureRinder, curTime, curTime)).toBe(false); + + // /* Fails: TypeError: Class extends value undefined is not a constructor or null + // > 145 | class ObsidianNotificationModal extends Modal { + // | ^ + // at Object. (src/Reminders/Notification.ts:145:41) + // at Object. (tests/Reminder/Notifications.test.ts:8:1) + + // */ + // }); +}); diff --git a/tests/Reminder/Reminder.test.ts b/tests/Reminder/Reminder.test.ts new file mode 100644 index 0000000000..627b1552e4 --- /dev/null +++ b/tests/Reminder/Reminder.test.ts @@ -0,0 +1,54 @@ +/** + * @jest-environment jsdom + */ +jest.mock('obsidian'); +import moment from 'moment'; +import { TIME_FORMATS, resetSettings } from '../../src/Config/Settings'; +import { Reminder, ReminderType, parseDateTime, parseMoment } from '../../src/Reminders/Reminder'; +import { fromLine } from '../TestHelpers'; + +window.moment = moment; + +function checkParsedDateTime(input: string, output: string) { + const dateTime = parseDateTime(input); + expect(dateTime).not.toBeNull(); + expect(dateTime!.toString()).toEqual(output); +} + +describe('should parse Moment() dates & times as reminder: ', () => { + it('test Moment() datetime 24hr', () => { + const reminder = moment('2023-04-30 13:45', TIME_FORMATS.twentyFourHour); + expect(parseMoment(reminder)).toStrictEqual( + new Reminder(moment('2023-04-30 13:45', TIME_FORMATS.twentyFourHour), ReminderType.DateTime), + ); + }); + + it('test Moment() date', () => { + const reminder = moment('2023-04-30'); + expect(parseMoment(reminder)).toStrictEqual(new Reminder(moment('2023-04-30'), ReminderType.Date)); + }); +}); + +describe('should parse dates & times string as reminder: ', () => { + afterEach(function () { + resetSettings(); + }); + + it('test 24-hour format', () => { + checkParsedDateTime('2023-01-15', '2023-01-15'); + checkParsedDateTime('2023-01-15 13:45', '2023-01-15 13:45'); + checkParsedDateTime('2023-01-15 1:45 pm', '2023-01-15 01:45'); // the pm & leading 0 are ignored + }); +}); + +describe('should parse task strings: ', () => { + afterEach(function () { + resetSettings(); + }); + + it('valid task - in 24-hour format', () => { + const line = '- [ ] #task Reminder at 13:57 ⏰️ 2023-05-03 13:57'; + const task = fromLine({ line: line }); + expect(task.reminder).not.toBeNull(); + }); +}); diff --git a/tests/Task.test.ts b/tests/Task.test.ts index da22332b08..3c3e7bf047 100644 --- a/tests/Task.test.ts +++ b/tests/Task.test.ts @@ -1245,6 +1245,14 @@ describe('identicalTo', () => { expect(lhs).toBeIdenticalTo(new TaskBuilder().tags([])); expect(lhs).not.toBeIdenticalTo(new TaskBuilder().tags(['#stuff'])); }); + + it('should check reminders', () => { + const lhs = new TaskBuilder().reminder('2023-03-07 09:25 am'); + expect(lhs).toBeIdenticalTo(new TaskBuilder().reminder('2023-03-07 09:25 am')); + expect(lhs).not.toBeIdenticalTo(new TaskBuilder().reminder(null)); + expect(lhs).not.toBeIdenticalTo(new TaskBuilder().reminder('2023-03-07')); + expect(lhs).not.toBeIdenticalTo(new TaskBuilder().reminder('2023-03-07 09:27 am')); + }); }); describe('checking if task lists are identical', () => { diff --git a/tests/TaskSerializer/DefaultTaskSerializer.test.ts b/tests/TaskSerializer/DefaultTaskSerializer.test.ts index e9c1774b91..51b4168c4b 100644 --- a/tests/TaskSerializer/DefaultTaskSerializer.test.ts +++ b/tests/TaskSerializer/DefaultTaskSerializer.test.ts @@ -3,11 +3,12 @@ */ import moment from 'moment'; import { Priority } from '../../src/Task'; -import type { Settings } from '../../src/Config/Settings'; +import { type Settings, TIME_FORMATS, resetSettings } from '../../src/Config/Settings'; import { DefaultTaskSerializer } from '../../src/TaskSerializer'; import { RecurrenceBuilder } from '../TestingTools/RecurrenceBuilder'; import { DEFAULT_SYMBOLS, type DefaultTaskSerializerSymbols } from '../../src/TaskSerializer/DefaultTaskSerializer'; import { TaskBuilder } from '../TestingTools/TaskBuilder'; +import { parseMoment } from '../../src/Reminders/Reminder'; jest.mock('obsidian'); window.moment = moment; @@ -23,8 +24,15 @@ describe.each(symbolMap)("DefaultTaskSerializer with '$taskFormat' symbols", ({ const taskSerializer = new DefaultTaskSerializer(symbols); const serialize = taskSerializer.serialize.bind(taskSerializer); const deserialize = taskSerializer.deserialize.bind(taskSerializer); - const { startDateSymbol, createdDateSymbol, recurrenceSymbol, scheduledDateSymbol, dueDateSymbol, doneDateSymbol } = - symbols; + const { + startDateSymbol, + createdDateSymbol, + recurrenceSymbol, + scheduledDateSymbol, + dueDateSymbol, + doneDateSymbol, + reminderDateSymbol, + } = symbols; describe('deserialize', () => { it('should parse an empty string', () => { @@ -65,7 +73,26 @@ describe.each(symbolMap)("DefaultTaskSerializer with '$taskFormat' symbols", ({ it('should parse tags', () => { const description = ' #hello #world #task'; const taskDetails = deserialize(description); - expect(taskDetails).toMatchTaskDetails({ tags: ['#hello', '#world', '#task'], description }); + expect(taskDetails).toMatchTaskDetails({ + tags: ['#hello', '#world', '#task'], + description, + }); + }); + }); + + describe('deserialize reminders', () => { + afterEach(function () { + resetSettings(); + }); + + it('should parse a single 24h reminder', () => { + const times = ['2021-06-20 13:45', '2021-06-20 01:45', '2021-06-20']; + times.forEach((time) => { + const taskDetails = deserialize(`${reminderDateSymbol} ${time}`); + expect(taskDetails).toMatchTaskDetails({ + reminder: parseMoment(moment(time, TIME_FORMATS.twentyFourHour)), + }); + }); }); }); @@ -115,5 +142,25 @@ describe.each(symbolMap)("DefaultTaskSerializer with '$taskFormat' symbols", ({ const serialized = serialize(task); expect(serialized).toEqual(' #hello #world #task'); }); + + it('should serialize a single reminder date', () => { + const serialized = serialize(new TaskBuilder().reminder('2021-06-20').description('').build()); + expect(serialized).toEqual(` ${reminderDateSymbol} 2021-06-20`); + }); + }); + + describe('serialize reminders', () => { + afterEach(function () { + resetSettings(); + }); + + it('should serialize a single 24h reminder', () => { + const times = ['2021-06-20 13:45', '2021-06-20 01:45', '2021-06-20']; + + times.forEach((time) => { + const serialized = serialize(new TaskBuilder().reminder(time).description('').build()); + expect(serialized).toEqual(` ${reminderDateSymbol} ${time}`); + }); + }); }); }); diff --git a/tests/TaskSerializer/TaskSerializer.test.ts b/tests/TaskSerializer/TaskSerializer.test.ts index 3798049d4d..fa154f2e9e 100644 --- a/tests/TaskSerializer/TaskSerializer.test.ts +++ b/tests/TaskSerializer/TaskSerializer.test.ts @@ -15,9 +15,9 @@ window.moment = moment; This file contains a tested, end-to-end example for implementing and using a {@link TaskSerializer}.
- This file should also contain any {@link TaskSerializer} tests that should be tested + This file should also contain any {@link TaskSerializer} tests that should be tested against all the {@link TaskSerializer}s defined in this repo. Tests that only - apply to one should be housed in that serializer's specific test file + apply to one should be housed in that serializer's specific test file */ describe('TaskSerializer Example', () => { @@ -68,6 +68,7 @@ describe('TaskSerializer Example', () => { scheduledDate: null, doneDate: null, recurrence: null, + reminder: null, }; } @@ -103,8 +104,12 @@ describe('TaskSerializer Example', () => { }); }); + // TODO Figure out why this changed. it('should parse a priority and description', () => { - expect(ts.deserialize('1 Wobble')).toMatchTaskDetails({ priority: Priority.High, description: 'Wobble' }); + expect(ts.deserialize('1 Wobble')).toMatchTaskDetails({ + priority: Priority.High, + description: 'Wobble', + }); }); it('should parse a full task', () => { diff --git a/tests/TestingTools/FilterTestHelpers.ts b/tests/TestingTools/FilterTestHelpers.ts index 1babf70a1b..2047a142bb 100644 --- a/tests/TestingTools/FilterTestHelpers.ts +++ b/tests/TestingTools/FilterTestHelpers.ts @@ -25,9 +25,12 @@ export function testFilter(filter: FilterOrErrorMessage, taskBuilder: TaskBuilde * @param expected true if the task should match the filter, and false otherwise. */ export function testTaskFilter(filter: FilterOrErrorMessage, task: Task, expected: boolean) { - expect(filter.filterFunction).toBeDefined(); - expect(filter.error).toBeUndefined(); - expect(filter.filterFunction!(task)).toEqual(expected); + expect(filter).toBeValid(); + if (expected) { + expect(filter).toMatchTask(task); + } else { + expect(filter).not.toMatchTask(task); + } } /** diff --git a/tests/TestingTools/RecurrenceBuilder.ts b/tests/TestingTools/RecurrenceBuilder.ts index bced56128c..940302b061 100644 --- a/tests/TestingTools/RecurrenceBuilder.ts +++ b/tests/TestingTools/RecurrenceBuilder.ts @@ -2,6 +2,7 @@ import type { Moment } from 'moment'; import { Recurrence } from '../../src/Recurrence'; import { DateParser } from '../../src/Query/DateParser'; +import { Reminder, parseDateTime } from '../../src/Reminders/Reminder'; /** * A fluent class for creating Recurrence objects for tests. @@ -20,6 +21,7 @@ export class RecurrenceBuilder { private _startDate: Moment | null = null; private _scheduledDate: Moment | null = null; private _dueDate: Moment | null = null; + private _reminder: Reminder | null = null; /** * Build a Recurrence @@ -38,6 +40,7 @@ export class RecurrenceBuilder { startDate: this._startDate, scheduledDate: this._scheduledDate, dueDate: this._dueDate, + reminder: this._reminder, }) as Recurrence; } @@ -61,6 +64,15 @@ export class RecurrenceBuilder { return this; } + public reminders(reminder: string): RecurrenceBuilder { + if (reminder.length > 0) { + this._reminder = parseDateTime(reminder); + } else { + this._reminder = null; + } + return this; + } + private static parseDate(date: string | null): Moment | null { if (date) { return DateParser.parseDate(date); diff --git a/tests/TestingTools/TaskBuilder.ts b/tests/TestingTools/TaskBuilder.ts index 0b5a781509..13fa886a5f 100644 --- a/tests/TestingTools/TaskBuilder.ts +++ b/tests/TestingTools/TaskBuilder.ts @@ -6,6 +6,7 @@ import type { Recurrence } from '../../src/Recurrence'; import { DateParser } from '../../src/Query/DateParser'; import { StatusConfiguration, StatusType } from '../../src/StatusConfiguration'; import { TaskLocation } from '../../src/TaskLocation'; +import { Reminder, parseDateTime } from '../../src/Reminders/Reminder'; /** * A fluent class for creating tasks for tests. @@ -39,6 +40,7 @@ export class TaskBuilder { private _scheduledDate: Moment | null = null; private _dueDate: Moment | null = null; private _doneDate: Moment | null = null; + private _reminders: Reminder | null = null; // TODO Rename to singular private _recurrence: Recurrence | null = null; private _blockLink: string = ''; @@ -83,6 +85,7 @@ export class TaskBuilder { recurrence: this._recurrence, blockLink: this._blockLink, tags: this._tags, + reminder: this._reminders, originalMarkdown: '', scheduledDateIsInferred: this._scheduledDateIsInferred, }); @@ -194,6 +197,15 @@ export class TaskBuilder { return this; } + public reminder(reminder: string | null): TaskBuilder { + if (reminder) { + this._reminders = parseDateTime(reminder); + } else { + this._reminders = null; + } + return this; + } + /** * See {@link RecurrenceBuilder} for easy construction of {@link Recurrence} objects in tests. * @param recurrence