diff --git a/src/Commands/CreateOrEditTaskParser.ts b/src/Commands/CreateOrEditTaskParser.ts index 45b91f8472..0f43b1042f 100644 --- a/src/Commands/CreateOrEditTaskParser.ts +++ b/src/Commands/CreateOrEditTaskParser.ts @@ -8,6 +8,7 @@ import { TaskLocation } from '../Task/TaskLocation'; import { getSettings } from '../Config/Settings'; import { GlobalFilter } from '../Config/GlobalFilter'; import { Priority } from '../Task/Priority'; +import { Duration } from '../Task/Duration'; import { TaskRegularExpressions } from '../Task/TaskRegularExpressions'; function getDefaultCreatedDate() { @@ -89,6 +90,7 @@ export const taskFromLine = ({ line, path }: { line: string; path: string }): Ta createdDate, startDate: null, scheduledDate: null, + duration: Duration.None, dueDate: null, doneDate: null, cancelledDate: null, @@ -130,6 +132,7 @@ export const taskFromLine = ({ line, path }: { line: string; path: string }): Ta createdDate, startDate: null, scheduledDate: null, + duration: Duration.None, dueDate: null, doneDate: null, cancelledDate: null, diff --git a/src/Layout/TaskLayoutOptions.ts b/src/Layout/TaskLayoutOptions.ts index c53b1abb23..fb7b5d0cac 100644 --- a/src/Layout/TaskLayoutOptions.ts +++ b/src/Layout/TaskLayoutOptions.ts @@ -15,6 +15,7 @@ export enum TaskLayoutComponent { CreatedDate = 'createdDate', StartDate = 'startDate', ScheduledDate = 'scheduledDate', + Duration = 'duration', DueDate = 'dueDate', CancelledDate = 'cancelledDate', DoneDate = 'doneDate', @@ -111,6 +112,7 @@ export function parseTaskShowHideOptions(taskLayoutOptions: TaskLayoutOptions, o ['depends on', TaskLayoutComponent.DependsOn], ['done date', TaskLayoutComponent.DoneDate], ['due date', TaskLayoutComponent.DueDate], + ['duration', TaskLayoutComponent.Duration], ['id', TaskLayoutComponent.Id], ['on completion', TaskLayoutComponent.OnCompletion], ['priority', TaskLayoutComponent.Priority], diff --git a/src/Query/Filter/DurationField.ts b/src/Query/Filter/DurationField.ts new file mode 100644 index 0000000000..9c46a110ff --- /dev/null +++ b/src/Query/Filter/DurationField.ts @@ -0,0 +1,181 @@ +import type { Task } from '../../Task/Task'; +import { Duration } from '../../Task/Duration'; +import { Explanation } from '../Explain/Explanation'; +import type { Comparator } from '../Sort/Sorter'; +import type { GrouperFunction } from '../Group/Grouper'; +import { TemplatingPluginTools } from '../../lib/TemplatingPluginTools'; +import { Field } from './Field'; +import { Filter, type FilterFunction } from './Filter'; +import { FilterInstructions } from './FilterInstructions'; +import { FilterOrErrorMessage } from './FilterOrErrorMessage'; + +export type DurationFilterFunction = (duration: Duration) => boolean; + +export class DurationField extends Field { + protected readonly filterInstructions: FilterInstructions; + + constructor(filterInstructions: FilterInstructions | null = null) { + super(); + if (filterInstructions !== null) { + this.filterInstructions = filterInstructions; + } else { + this.filterInstructions = new FilterInstructions(); + this.filterInstructions.add(`has ${this.fieldName()}`, (task: Task) => task.duration !== Duration.None); + this.filterInstructions.add(`no ${this.fieldName()}`, (task: Task) => task.duration === Duration.None); + } + } + + public fieldName(): string { + return 'duration'; + } + + public canCreateFilterForLine(line: string): boolean { + if (this.filterInstructions.canCreateFilterForLine(line)) { + return true; + } + + return super.canCreateFilterForLine(line); + } + + public createFilterOrErrorMessage(line: string): FilterOrErrorMessage { + // There have been multiple "bug reports", where the query had un-expanded + // template text to signify the search duration. + // Enough to explicitly trap any such text for duration searches: + const errorText = this.checkForUnexpandedTemplateText(line); + if (errorText) { + return FilterOrErrorMessage.fromError(line, errorText); + } + + const filterResult = this.filterInstructions.createFilterOrErrorMessage(line); + if (filterResult.isValid()) { + return filterResult; + } + + const fieldNameKeywordDuration = Field.getMatch(this.filterRegExp(), line); + if (fieldNameKeywordDuration === null) { + return FilterOrErrorMessage.fromError(line, 'do not understand query filter (' + this.fieldName() + ')'); + } + + const fieldKeyword = fieldNameKeywordDuration[2]?.toLowerCase(); // 'is', 'above', 'under' + const fieldDurationString = fieldNameKeywordDuration[3]; // The remainder of the instruction + + // Try interpreting everything after the keyword as a duration: + const fieldDuration = Duration.fromText(fieldDurationString); + + if (!fieldDuration) { + return FilterOrErrorMessage.fromError(line, 'do not understand ' + this.fieldName()); + } + + const filterFunction = this.buildFilterFunction(fieldKeyword, fieldDuration); + + const explanation = DurationField.buildExplanation( + this.fieldNameForExplanation(), + fieldKeyword, + this.filterResultIfFieldMissing(), + fieldDuration, + ); + return FilterOrErrorMessage.fromFilter(new Filter(line, filterFunction, explanation)); + } + + /** + * Builds function that actually filters the tasks depending on the duration + * @param fieldKeyword relationship to be held with the duration 'under', 'is', 'above' + * @param fieldDuration the duration to be used by the filter function + * @returns the function that filters the tasks + */ + protected buildFilterFunction(fieldKeyword: string, fieldDuration: Duration): FilterFunction { + let durationFilter: DurationFilterFunction; + switch (fieldKeyword) { + case 'under': + durationFilter = (duration) => this.compare(duration, fieldDuration) < 0; + break; + case 'above': + durationFilter = (duration) => this.compare(duration, fieldDuration) > 0; + break; + case 'is': + default: + durationFilter = (duration) => this.compare(duration, fieldDuration) === 0; + break; + } + return this.getFilter(durationFilter); + } + + protected getFilter(durationFilterFunction: DurationFilterFunction): FilterFunction { + return (task: Task) => { + return durationFilterFunction(task.duration); + }; + } + + protected filterRegExp(): RegExp { + return new RegExp('^duration( expectation)? (is|above|under) ?(.*)', 'i'); + } + + /** + * Constructs an Explanation for a duration-based filter + * @param fieldName - for example, 'due' + * @param fieldKeyword - one of the keywords like 'before' or 'after' + * @param filterResultIfFieldMissing - whether the search matches tasks without the requested duration value + * @param filterDurations - the duration range used in the filter + */ + public static buildExplanation( + fieldName: string, + fieldKeyword: string, + filterResultIfFieldMissing: boolean, + filterDurations: Duration, + ): Explanation { + const fieldKeywordVerbose = fieldKeyword === 'is' ? 'is' : 'is ' + fieldKeyword; + let oneLineExplanation = `${fieldName} ${fieldKeywordVerbose} ${filterDurations.toText()}`; + if (filterResultIfFieldMissing) { + oneLineExplanation += ` OR no ${fieldName}`; + } + return new Explanation(oneLineExplanation); + } + + protected fieldNameForExplanation() { + return this.fieldName(); + } + + /** + * Determine whether a task that does not have a duration value + * should be treated as a match. + * @protected + */ + protected filterResultIfFieldMissing(): boolean { + return false; + } + + public supportsSorting(): boolean { + return true; + } + + public compare(a: Duration, b: Duration): number { + if (a === Duration.None || b === Duration.None) { + return 0; + } + return a.hours * 60 + a.minutes - (b.hours * 60 + b.minutes); + } + + public comparator(): Comparator { + return (a: Task, b: Task) => { + return this.compare(a.duration, b.duration); + }; + } + + public supportsGrouping(): boolean { + return true; + } + + public grouper(): GrouperFunction { + return (task: Task) => { + const duration = task.duration; + if (!duration || duration === Duration.None) { + return ['No ' + this.fieldName()]; + } + return [duration.toText()]; + }; + } + + private checkForUnexpandedTemplateText(line: string): null | string { + return new TemplatingPluginTools().findUnexpandedDateText(line); + } +} diff --git a/src/Query/FilterParser.ts b/src/Query/FilterParser.ts index 0b8360e97c..9359d1b265 100644 --- a/src/Query/FilterParser.ts +++ b/src/Query/FilterParser.ts @@ -9,6 +9,7 @@ import { HeadingField } from './Filter/HeadingField'; import { PathField } from './Filter/PathField'; import { PriorityField } from './Filter/PriorityField'; import { ScheduledDateField } from './Filter/ScheduledDateField'; +import { DurationField } from './Filter/DurationField'; import { StartDateField } from './Filter/StartDateField'; import { HappensDateField } from './Filter/HappensDateField'; import { RecurringField } from './Filter/RecurringField'; @@ -50,6 +51,7 @@ export const fieldCreators: EndsWith = [ () => new CreatedDateField(), () => new StartDateField(), () => new ScheduledDateField(), + () => new DurationField(), () => new DueDateField(), () => new DoneDateField(), () => new PathField(), diff --git a/src/Renderer/Renderer.scss b/src/Renderer/Renderer.scss index d6c50da160..4840bee874 100644 --- a/src/Renderer/Renderer.scss +++ b/src/Renderer/Renderer.scss @@ -1,6 +1,5 @@ :root { --tasks-details-icon: url("data:image/svg+xml;charset=utf-8,"); - } /* Fix indentation of wrapped task lines in Tasks search results, when in Live Preview. */ @@ -8,7 +7,7 @@ ul.contains-task-list .task-list-item-checkbox { margin-inline-start: calc(var(--checkbox-size) * -1.5) !important; } -.plugin-tasks-query-explanation{ +.plugin-tasks-query-explanation { /* Prevent long explanation lines wrapping, so they are more readable, especially on small screens. @@ -53,6 +52,7 @@ ul.contains-task-list .task-list-item-checkbox { .task-done, .task-due, .task-scheduled, +.task-duration, .task-start { cursor: pointer; user-select: none; @@ -61,11 +61,12 @@ ul.contains-task-list .task-list-item-checkbox { } /* Edit and postpone */ -.tasks-edit, .tasks-postpone { +.tasks-edit, +.tasks-postpone { width: 1em; height: 1em; vertical-align: middle; - margin-left: .33em; + margin-left: 0.33em; cursor: pointer; font-family: var(--font-interface); color: var(--text-accent); @@ -74,7 +75,8 @@ ul.contains-task-list .task-list-item-checkbox { -webkit-touch-callout: none; } -a.tasks-edit, a.tasks-postpone { +a.tasks-edit, +a.tasks-postpone { text-decoration: none; } @@ -124,6 +126,6 @@ a.tasks-edit, a.tasks-postpone { /* Workaround for issue #2073: Enabling the plugin causes blockIds to be not hidden in reading view https://github.com/obsidian-tasks-group/obsidian-tasks/issues/2073 */ -.task-list-item .task-block-link{ +.task-list-item .task-block-link { display: none; } diff --git a/src/Renderer/TaskFieldRenderer.ts b/src/Renderer/TaskFieldRenderer.ts index 4f62f20ad8..3ef289d06e 100644 --- a/src/Renderer/TaskFieldRenderer.ts +++ b/src/Renderer/TaskFieldRenderer.ts @@ -97,7 +97,7 @@ export class TaskFieldHTMLData { // TS2345: Argument of type 'string[] | Moment' is not assignable to parameter of type 'Moment'. // Type 'string[]' is missing the following properties from type 'Moment': format, startOf, endOf, add, and 78 more. if (!Array.isArray(date) && date instanceof window.moment) { - const attributeValue = dateToAttribute(date); + const attributeValue = dateToAttribute(date as Moment); if (attributeValue) { return attributeValue; } @@ -183,6 +183,9 @@ const taskFieldHTMLData: { [c in TaskLayoutComponent]: TaskFieldHTMLData } = { return PriorityTools.priorityNameUsingNormal(task.priority).toLocaleLowerCase(); }), + duration: new TaskFieldHTMLData('task-duration', 'taskDuration', (_component, task) => { + return task.duration.toText(); + }), description: createFieldWithoutDataAttributes('task-description'), recurrenceRule: createFieldWithoutDataAttributes('task-recurring'), onCompletion: createFieldWithoutDataAttributes('task-onCompletion'), diff --git a/src/Renderer/TaskLineRenderer.ts b/src/Renderer/TaskLineRenderer.ts index 0d3d13bff1..c990948d8e 100644 --- a/src/Renderer/TaskLineRenderer.ts +++ b/src/Renderer/TaskLineRenderer.ts @@ -7,6 +7,7 @@ import { TaskLayoutComponent, type TaskLayoutOptions } from '../Layout/TaskLayou import { replaceTaskWithTasks } from '../Obsidian/File'; import { StatusRegistry } from '../Statuses/StatusRegistry'; import { Task } from '../Task/Task'; +import { Duration } from '../Task/Duration'; import { TaskRegularExpressions } from '../Task/TaskRegularExpressions'; import { StatusMenu } from '../ui/Menus/StatusMenu'; import type { AllTaskDateFields } from '../DateTime/DateFieldTypes'; @@ -225,6 +226,10 @@ export class TaskLineRenderer { this.queryLayoutOptions.shortMode, component, ); + // skip empty duration + if (component == TaskLayoutComponent.Duration && task.duration === Duration.None) { + continue; + } if (componentString) { // Create the text span that will hold the rendered component const span = createAndAppendElement('span', parentElement); @@ -281,6 +286,10 @@ export class TaskLineRenderer { if (li.dataset.taskPriority === undefined) { fieldRenderer.addDataAttribute(li, task, TaskLayoutComponent.Priority); } + // Same logic for duration of 0h0m. It will not be rendered, but data-attribute should be present. + if (li.dataset.taskDuration === undefined) { + fieldRenderer.addDataAttribute(li, task, TaskLayoutComponent.Duration); + } } /* diff --git a/src/Suggestor/Suggestor.ts b/src/Suggestor/Suggestor.ts index 798b9b988e..7d48d335d3 100644 --- a/src/Suggestor/Suggestor.ts +++ b/src/Suggestor/Suggestor.ts @@ -4,6 +4,7 @@ import type { Settings } from '../Config/Settings'; import { DateParser } from '../DateTime/DateParser'; import { doAutocomplete } from '../DateTime/DateAbbreviations'; import { Occurrence } from '../Task/Occurrence'; +import { Duration } from '../Task/Duration'; import { Recurrence } from '../Task/Recurrence'; import { type DefaultTaskSerializerSymbols, @@ -80,6 +81,9 @@ export function makeDefaultSuggestionBuilder( // add date suggestions if relevant suggestions = suggestions.concat(addDatesSuggestions(datePrefixRegex, maxGenericSuggestions, parameters)); + // add duration suggestions if relevant + suggestions = suggestions.concat(addDurationValueSuggestions(symbols.durationSymbol, parameters)); + // add recurrence suggestions if relevant suggestions = suggestions.concat(addRecurrenceValueSuggestions(symbols.recurrenceSymbol, parameters)); @@ -152,6 +156,7 @@ function addTaskPropertySuggestions( addField(genericSuggestions, line, symbols.dueDateSymbol, 'due date'); addField(genericSuggestions, line, symbols.startDateSymbol, 'start date'); addField(genericSuggestions, line, symbols.scheduledDateSymbol, 'scheduled date'); + addField(genericSuggestions, line, symbols.durationSymbol, 'duration'); addPrioritySuggestions(genericSuggestions, symbols, parameters); addField(genericSuggestions, line, symbols.recurrenceSymbol, 'recurring (repeat)'); @@ -272,6 +277,95 @@ function dateExtractor(symbol: string, date: string) { return { displayText, appendText }; } +/* + * If the cursor is located in a section that should be followed by a duration description, suggest options + * for what to enter as a duration. + * This has two parts: either generic predefined suggestions, or a single suggestion that is a parsed result + * of what the user is typing. + * Generic predefined suggestions, in turn, also have two options: either filtered (if the user started typing + * something where a duration is expected) or unfiltered + */ +function addDurationValueSuggestions(durationSymbol: string, parameters: SuggestorParameters) { + let genericSuggestions = ['5m', '15m', '1h', '30m', '45m', '2h', '3h']; + const hourSuggestions = ['15m', '30m', '45m']; + + const results: SuggestInfo[] = []; + const durationRegex = new RegExp(`(${durationSymbol})\\s*([0-9hm]*)`, 'ug'); + const durationMatch = matchIfCursorInRegex(durationRegex, parameters); + if (durationMatch && durationMatch.length >= 1) { + const durationPrefix = durationMatch[1]; + let durationString = ''; + if (durationMatch[2]) { + durationString = durationMatch[2]; + } + if (durationString.length > 0) { + // If the text matches a valid duration, suggest logical continuations + let parsedduration = Duration.fromText(durationString); + if (!parsedduration) { + //not a valid duration string => no h/m yet. Suggest finishing with minutes or complete hour! + if (parseInt(durationString, 10) > 0) { + results.push({ + suggestionType: 'match', + displayText: `${durationPrefix} ${durationString}m`, + appendText: `${durationPrefix} ${durationString}m` + parameters.postfix, + insertAt: durationMatch.index, + insertSkip: calculateSkipValueForMatch(durationMatch[0], parameters), + }); + genericSuggestions = genericSuggestions.filter((s) => s != `${durationString}m`); + results.push({ + suggestionType: 'match', + displayText: `${durationPrefix} ${durationString}h`, + appendText: `${durationPrefix} ${durationString}h` + parameters.postfix, + insertAt: durationMatch.index, + insertSkip: calculateSkipValueForMatch(durationMatch[0], parameters), + }); + genericSuggestions = genericSuggestions.filter((s) => s != `${durationString}h`); + } + // also suggest that '2' implies '2h', thus also suggesting continuations like '2h30m' + parsedduration = Duration.fromText(durationString + 'h'); + } + if (parsedduration) { + // special suggestions on finished hour like '123h' + const genText = (sugg: string) => `${durationPrefix} ${parsedduration!.hours}h${sugg}`; + for (const suggestion of hourSuggestions.filter( + // suggestion is either all suggestions or the one with matching prefix + (s) => parsedduration?.minutes == 0 || s.startsWith(parsedduration!.minutes.toString(10)), + )) { + results.push({ + suggestionType: 'match', + displayText: `${genText(suggestion)}`, + appendText: genText(suggestion) + parameters.postfix, + insertAt: durationMatch.index, + insertSkip: calculateSkipValueForMatch(durationMatch[0], parameters), + }); + } + } + } + // Now to generic predefined suggestions. + // If we get a partial match with some of the suggestions (e.g. the user started typing "3"), + // we use that for matches to the generic example-list above (i.e. "3h"). + // Otherwise, we just display the list of suggestions, and either way, truncate them eventually to + // a max number. + // In the case of duration rules, the max number should be small enough to allow users to "escape" + // the mode of writing a duration rule, i.e. we should leave enough space for component suggestions + const maxGenericDurationSuggestions = parameters.settings.autoSuggestMaxItems / 2; + const genericMatches = filterGenericSuggestions( + genericSuggestions, + durationString, + maxGenericDurationSuggestions, + true, + ); + + const extractor = (durationPrefix: string, match: string) => { + const displayText = `${durationPrefix} ${match}`; + const appendText = `${durationPrefix} ${match}`; + return { displayText, appendText }; + }; + constructSuggestions(parameters, durationMatch, genericMatches, extractor, results); + } + + return results; +} /* * If the cursor is located in a section that should be followed by a date (due, start date or scheduled date), * suggest options for what to enter as a date. diff --git a/src/Task/Duration.ts b/src/Task/Duration.ts new file mode 100644 index 0000000000..a84d700d3c --- /dev/null +++ b/src/Task/Duration.ts @@ -0,0 +1,52 @@ +export class Duration { + readonly hours: number; + readonly minutes: number; + + constructor({ hours, minutes }: { hours: number; minutes: number }) { + this.hours = hours; + this.minutes = minutes; + } + + public static readonly None: Duration = new Duration({ hours: 0, minutes: 0 }); + public static readonly valueRegEx: string = '[0-9]+h[0-9]+m?|[0-9]+h|[0-9]+m?'; + + public static fromText(durationText: string): Duration | null { + try { + let match = durationText.match(/^(\d+)h$/i); + if (match != null) { + if (parseInt(match[1], 10) > 0) { + return new Duration({ hours: parseInt(match[1], 10), minutes: 0 }); + } + } + match = durationText.match(/^(\d+)m$/i); + if (match != null) { + if (parseInt(match[1], 10) > 0) { + return new Duration({ hours: 0, minutes: parseInt(match[1], 10) }); + } + } + match = durationText.match(/^(\d+)h(\d+)m?$/i); + if (match != null) { + if (parseInt(match[1], 10) > 0 || parseInt(match[2], 10) > 0) { + return new Duration({ hours: parseInt(match[1], 10), minutes: parseInt(match[2], 10) }); + } + } + return null; + } catch (e) { + // Could not read recurrence rule. User possibly not done typing. + // Print error message, as it is useful if a test file has not set up window.moment + if (e instanceof Error) { + console.log(e.message); + } + } + + return null; + } + + public toText(): string { + if (this.hours == 0 && this.minutes == 0) { + return ''; + } + + return this.hours + 'h' + this.minutes + 'm'; + } +} diff --git a/src/Task/Task.ts b/src/Task/Task.ts index d8c01b1c0d..4796e76e2d 100644 --- a/src/Task/Task.ts +++ b/src/Task/Task.ts @@ -16,6 +16,7 @@ import { Urgency } from './Urgency'; import type { Recurrence } from './Recurrence'; import type { TaskLocation } from './TaskLocation'; import type { Priority } from './Priority'; +import { Duration } from './Duration'; import { TaskRegularExpressions } from './TaskRegularExpressions'; import { OnCompletion, handleOnCompletion } from './OnCompletion'; @@ -49,6 +50,7 @@ export class Task extends ListItem { public readonly createdDate: Moment | null; public readonly startDate: Moment | null; public readonly scheduledDate: Moment | null; + public readonly duration: Duration; public readonly dueDate: Moment | null; public readonly doneDate: Moment | null; public readonly cancelledDate: Moment | null; @@ -78,6 +80,7 @@ export class Task extends ListItem { createdDate, startDate, scheduledDate, + duration, dueDate, doneDate, cancelledDate, @@ -101,6 +104,7 @@ export class Task extends ListItem { createdDate: moment.Moment | null; startDate: moment.Moment | null; scheduledDate: moment.Moment | null; + duration: Duration; dueDate: moment.Moment | null; doneDate: moment.Moment | null; cancelledDate: moment.Moment | null; @@ -133,6 +137,7 @@ export class Task extends ListItem { this.createdDate = createdDate; this.startDate = startDate; this.scheduledDate = scheduledDate; + this.duration = duration; this.dueDate = dueDate; this.doneDate = doneDate; this.cancelledDate = cancelledDate; @@ -653,6 +658,20 @@ export class Task extends ListItem { return new TasksDate(this.scheduledDate); } + /** + * Return {@link hours} from {@link Duration}. + */ + public get durationHours(): number | string { + return this.duration === Duration.None ? '' : this.duration.hours; + } + + /** + * Return {@link minutes} from {@link Duration}. + */ + public get durationMinutes(): number | string { + return this.duration === Duration.None ? '' : this.duration.minutes; + } + /** * Return {@link startDate} as a {@link TasksDate}, so the field names in scripting docs are consistent with the existing search instruction names, and null values are easy to deal with. */ @@ -780,6 +799,7 @@ export class Task extends ListItem { // happens more often than is ideal. let args: Array = [ 'priority', + 'duration', 'blockLink', 'scheduledDateIsInferred', 'id', @@ -819,6 +839,11 @@ export class Task extends ListItem { if (!this.recurrenceIdenticalTo(other)) { return false; } + // Compare duration. Only identical if their textual representation is identical. + // 1h30m may not be semantically equal to 90m. + if (this.duration.toText() != other.duration.toText()) { + return false; + } return this.file.rawFrontmatterIdenticalTo(other.file); } diff --git a/src/TaskSerializer/DataviewTaskSerializer.ts b/src/TaskSerializer/DataviewTaskSerializer.ts index d2a1cb69f5..57161dfccd 100644 --- a/src/TaskSerializer/DataviewTaskSerializer.ts +++ b/src/TaskSerializer/DataviewTaskSerializer.ts @@ -1,6 +1,7 @@ import { TaskLayoutComponent } from '../Layout/TaskLayoutOptions'; import { PriorityTools } from '../lib/PriorityTools'; import type { Priority } from '../Task/Priority'; +import { Duration } from '../Task/Duration'; import type { Task } from '../Task/Task'; import { DefaultTaskSerializer, taskIdRegex, taskIdSequenceRegex } from './DefaultTaskSerializer'; @@ -72,6 +73,7 @@ export const DATAVIEW_SYMBOLS = { startDateSymbol: 'start::', createdDateSymbol: 'created::', scheduledDateSymbol: 'scheduled::', + durationSymbol: 'duration::', dueDateSymbol: 'due::', doneDateSymbol: 'completion::', cancelledDateSymbol: 'cancelled::', @@ -84,6 +86,7 @@ export const DATAVIEW_SYMBOLS = { startDateRegex: toInlineFieldRegex(/start:: *(\d{4}-\d{2}-\d{2})/), createdDateRegex: toInlineFieldRegex(/created:: *(\d{4}-\d{2}-\d{2})/), scheduledDateRegex: toInlineFieldRegex(/scheduled:: *(\d{4}-\d{2}-\d{2})/), + durationRegex: toInlineFieldRegex(new RegExp('duration:: *(' + Duration.valueRegEx + ')')), dueDateRegex: toInlineFieldRegex(/due:: *(\d{4}-\d{2}-\d{2})/), doneDateRegex: toInlineFieldRegex(/completion:: *(\d{4}-\d{2}-\d{2})/), cancelledDateRegex: toInlineFieldRegex(/cancelled:: *(\d{4}-\d{2}-\d{2})/), diff --git a/src/TaskSerializer/DefaultTaskSerializer.ts b/src/TaskSerializer/DefaultTaskSerializer.ts index ee7ea03e82..c9898975c2 100644 --- a/src/TaskSerializer/DefaultTaskSerializer.ts +++ b/src/TaskSerializer/DefaultTaskSerializer.ts @@ -5,6 +5,7 @@ import { Occurrence } from '../Task/Occurrence'; import { Recurrence } from '../Task/Recurrence'; import { Task } from '../Task/Task'; import { Priority } from '../Task/Priority'; +import { Duration } from '../Task/Duration'; import { TaskRegularExpressions } from '../Task/TaskRegularExpressions'; import type { TaskDetails, TaskSerializer } from '.'; @@ -26,6 +27,7 @@ export interface DefaultTaskSerializerSymbols { readonly startDateSymbol: string; readonly createdDateSymbol: string; readonly scheduledDateSymbol: string; + readonly durationSymbol: string; readonly dueDateSymbol: string; readonly doneDateSymbol: string; readonly cancelledDateSymbol: string; @@ -38,6 +40,7 @@ export interface DefaultTaskSerializerSymbols { startDateRegex: RegExp; createdDateRegex: RegExp; scheduledDateRegex: RegExp; + durationRegex: RegExp; dueDateRegex: RegExp; doneDateRegex: RegExp; cancelledDateRegex: RegExp; @@ -87,6 +90,7 @@ export const DEFAULT_SYMBOLS: DefaultTaskSerializerSymbols = { startDateSymbol: 'πŸ›«', createdDateSymbol: 'βž•', scheduledDateSymbol: '⏳', + durationSymbol: '⏱', dueDateSymbol: 'πŸ“…', doneDateSymbol: 'βœ…', cancelledDateSymbol: '❌', @@ -99,6 +103,7 @@ export const DEFAULT_SYMBOLS: DefaultTaskSerializerSymbols = { startDateRegex: dateFieldRegex('πŸ›«'), createdDateRegex: dateFieldRegex('βž•'), scheduledDateRegex: dateFieldRegex('(?:⏳|βŒ›)'), + durationRegex: fieldRegex('⏱', '(' + Duration.valueRegEx + ')'), dueDateRegex: dateFieldRegex('(?:πŸ“…|πŸ“†|πŸ—“)'), doneDateRegex: dateFieldRegex('βœ…'), cancelledDateRegex: dateFieldRegex('❌'), @@ -170,6 +175,7 @@ export class DefaultTaskSerializer implements TaskSerializer { startDateSymbol, createdDateSymbol, scheduledDateSymbol, + durationSymbol, doneDateSymbol, cancelledDateSymbol, recurrenceSymbol, @@ -206,6 +212,9 @@ export class DefaultTaskSerializer implements TaskSerializer { case TaskLayoutComponent.ScheduledDate: if (task.scheduledDateIsInferred) return ''; return symbolAndDateValue(shortMode, scheduledDateSymbol, task.scheduledDate); + case TaskLayoutComponent.Duration: + if (!task.duration) return ''; + return symbolAndStringValue(shortMode, durationSymbol, task.duration.toText()); case TaskLayoutComponent.DoneDate: return symbolAndDateValue(shortMode, doneDateSymbol, task.doneDate); case TaskLayoutComponent.CancelledDate: @@ -274,6 +283,7 @@ export class DefaultTaskSerializer implements TaskSerializer { let priority: Priority = Priority.None; let startDate: Moment | null = null; let scheduledDate: Moment | null = null; + let duration: Duration = Duration.None; let dueDate: Moment | null = null; let doneDate: Moment | null = null; let cancelledDate: Moment | null = null; @@ -329,6 +339,15 @@ export class DefaultTaskSerializer implements TaskSerializer { matched = true; } + const durationMatch = line.match(TaskFormatRegularExpressions.durationRegex); + if (durationMatch !== null) { + line = line.replace(TaskFormatRegularExpressions.durationRegex, '').trim(); + //note: as TaskFormatRegularExpressions.durationRegex is derived from Duration.valueRegEx + //Duration.fromText() should never(!) return null. + duration = Duration.fromText(durationMatch[1].trim())!; + matched = true; + } + const startDateMatch = line.match(TaskFormatRegularExpressions.startDateRegex); if (startDateMatch !== null) { startDate = window.moment(startDateMatch[1], TaskRegularExpressions.dateFormat); @@ -414,6 +433,7 @@ export class DefaultTaskSerializer implements TaskSerializer { startDate, createdDate, scheduledDate, + duration, dueDate, doneDate, cancelledDate, diff --git a/src/TaskSerializer/index.ts b/src/TaskSerializer/index.ts index a658a7f702..76a66da2df 100644 --- a/src/TaskSerializer/index.ts +++ b/src/TaskSerializer/index.ts @@ -17,6 +17,7 @@ export type TaskDetails = Writeable< | 'startDate' | 'createdDate' | 'scheduledDate' + | 'duration' | 'dueDate' | 'doneDate' | 'cancelledDate' diff --git a/src/ui/DurationEditor.svelte b/src/ui/DurationEditor.svelte new file mode 100644 index 0000000000..02519d1c4e --- /dev/null +++ b/src/ui/DurationEditor.svelte @@ -0,0 +1,25 @@ + + + + + +{durationSymbol} {@html parsedDuration} diff --git a/src/ui/EditTask.svelte b/src/ui/EditTask.svelte index 1648ba32ca..9e56842fc3 100644 --- a/src/ui/EditTask.svelte +++ b/src/ui/EditTask.svelte @@ -9,6 +9,7 @@ import { EditableTask } from './EditableTask'; import { labelContentWithAccessKey } from './EditTaskHelpers'; import RecurrenceEditor from './RecurrenceEditor.svelte'; + import DurationEditor from './DurationEditor.svelte'; import StatusEditor from './StatusEditor.svelte'; // These exported variables are passed in as props by TaskModal.onOpen(): @@ -41,6 +42,7 @@ let isScheduledDateValid: boolean = true; let isStartDateValid: boolean = true; + let isDurationValid: boolean = true; let isRecurrenceValid: boolean = true; let withAccessKeys: boolean = true; @@ -102,6 +104,7 @@ $: accesskey = (key: string) => (withAccessKeys ? key : null); $: formIsValid = isDueDateValid && + isDurationValid && isRecurrenceValid && isScheduledDateValid && isStartDateValid && @@ -296,6 +299,14 @@ Availability of access keys: + + + +
+
+ +
+ diff --git a/src/ui/EditableTask.ts b/src/ui/EditableTask.ts index 25c60b325b..408e13d967 100644 --- a/src/ui/EditableTask.ts +++ b/src/ui/EditableTask.ts @@ -4,6 +4,7 @@ import { PriorityTools } from '../lib/PriorityTools'; import { replaceTaskWithTasks } from '../Obsidian/File'; import type { Status } from '../Statuses/Status'; import type { OnCompletion } from '../Task/OnCompletion'; +import { Duration } from '../Task/Duration'; import { Occurrence } from '../Task/Occurrence'; import { Priority } from '../Task/Priority'; import { Recurrence } from '../Task/Recurrence'; @@ -29,6 +30,7 @@ export class EditableTask { createdDate: string; startDate: string; scheduledDate: string; + duration: string; dueDate: string; doneDate: string; cancelledDate: string; @@ -49,6 +51,7 @@ export class EditableTask { createdDate: string; startDate: string; scheduledDate: string; + duration: string; dueDate: string; doneDate: string; cancelledDate: string; @@ -67,6 +70,7 @@ export class EditableTask { this.createdDate = editableTask.createdDate; this.startDate = editableTask.startDate; this.scheduledDate = editableTask.scheduledDate; + this.duration = editableTask.duration; this.dueDate = editableTask.dueDate; this.doneDate = editableTask.doneDate; this.cancelledDate = editableTask.cancelledDate; @@ -127,6 +131,7 @@ export class EditableTask { createdDate: task.created.formatAsDate(), startDate: task.start.formatAsDate(), scheduledDate: task.scheduled.formatAsDate(), + duration: task.duration.toText(), dueDate: task.due.formatAsDate(), doneDate: task.done.formatAsDate(), cancelledDate: task.cancelled.formatAsDate(), @@ -153,6 +158,7 @@ export class EditableTask { const startDate = parseTypedDateForSaving(this.startDate, this.forwardOnly); const scheduledDate = parseTypedDateForSaving(this.scheduledDate, this.forwardOnly); + const duration = Duration.fromText(this.duration) ?? Duration.None; const dueDate = parseTypedDateForSaving(this.dueDate, this.forwardOnly); const cancelledDate = parseTypedDateForSaving(this.cancelledDate, this.forwardOnly); @@ -201,6 +207,7 @@ export class EditableTask { recurrence, startDate, scheduledDate, + duration, dueDate, doneDate, createdDate, @@ -255,6 +262,20 @@ export class EditableTask { return window.moment(); } + public parseAndValidateDuration() { + if (!this.duration) { + return { parsedDuration: 'no duration', isDurationValid: true }; + } + + const durationFromText = Duration.fromText(this.duration)?.toText(); + + if (!durationFromText) { + return { parsedDuration: 'invalid duration. Only hours/minutes.', isDurationValid: false }; + } + + return { parsedDuration: durationFromText, isDurationValid: true }; + } + public parseAndValidateRecurrence() { // NEW_TASK_FIELD_EDIT_REQUIRED if (!this.recurrenceRule) { diff --git a/tests/Commands/CreateOrEditTaskParser.test.ts b/tests/Commands/CreateOrEditTaskParser.test.ts index b24fede8e3..c2127bfdc9 100644 --- a/tests/Commands/CreateOrEditTaskParser.test.ts +++ b/tests/Commands/CreateOrEditTaskParser.test.ts @@ -5,6 +5,7 @@ import moment from 'moment'; import { resetSettings, updateSettings } from '../../src/Config/Settings'; import { taskFromLine } from '../../src/Commands/CreateOrEditTaskParser'; import { GlobalFilter } from '../../src/Config/GlobalFilter'; +import { Duration } from '../../src/Task/Duration'; import { Priority } from '../../src/Task/Priority'; window.moment = moment; @@ -80,7 +81,7 @@ describe('CreateOrEditTaskParser - task recognition', () => { it('should recognize task details without global filter', () => { GlobalFilter.getInstance().set('#task'); const taskLine = - '- [ ] without global filter but with all the info ⏬ πŸ” every 2 days βž• 2022-03-10 πŸ›« 2022-01-31 ⏳ 2023-06-13 πŸ“… 2024-12-10 βœ… 2023-06-22'; + '- [ ] without global filter but with all the info ⏬ πŸ” every 2 days βž• 2022-03-10 πŸ›« 2022-01-31 ⏳ 2023-06-13 ⏱ 1h30m πŸ“… 2024-12-10 βœ… 2023-06-22'; const path = 'a/b/c.md'; const task = taskFromLine({ line: taskLine, path }); @@ -94,6 +95,7 @@ describe('CreateOrEditTaskParser - task recognition', () => { expect(task.createdDate).toEqualMoment(moment('2022-03-10')); expect(task.startDate).toEqualMoment(moment('2022-01-31')); expect(task.scheduledDate).toEqualMoment(moment('2023-06-13')); + expect(task.duration).toStrictEqual(new Duration({ hours: 1, minutes: 30 })); expect(task.dueDate).toEqualMoment(moment('2024-12-10')); expect(task.doneDate).toEqualMoment(moment('2023-06-22')); }); diff --git a/tests/CustomMatchers/CustomMatchersForTaskSerializer.ts b/tests/CustomMatchers/CustomMatchersForTaskSerializer.ts index 4213e97e39..003f646e38 100644 --- a/tests/CustomMatchers/CustomMatchersForTaskSerializer.ts +++ b/tests/CustomMatchers/CustomMatchersForTaskSerializer.ts @@ -2,6 +2,7 @@ import { diff } from 'jest-diff'; import type { MatcherFunction } from 'expect'; import moment from 'moment'; import type { TaskDetails } from '../../src/TaskSerializer'; +import { Duration } from '../../src/Task/Duration'; import { Recurrence } from '../../src/Task/Recurrence'; import { Priority } from '../../src/Task/Priority'; import { TaskRegularExpressions } from '../../src/Task/TaskRegularExpressions'; @@ -81,6 +82,7 @@ function summarizeTaskDetails(t: TaskDetails | null): SummarizedTaskDetails | nu startDate: t.startDate?.format(TaskRegularExpressions.dateFormat) ?? null, createdDate: t.createdDate?.format(TaskRegularExpressions.dateFormat) ?? null, scheduledDate: t.scheduledDate?.format(TaskRegularExpressions.dateFormat) ?? null, + duration: t.duration?.toText() ?? null, dueDate: t.dueDate?.format(TaskRegularExpressions.dateFormat) ?? null, doneDate: t.doneDate?.format(TaskRegularExpressions.dateFormat) ?? null, cancelledDate: t.cancelledDate?.format(TaskRegularExpressions.dateFormat) ?? null, @@ -106,6 +108,7 @@ function tryBuildTaskDetails(t: object): TaskDetails | null { startDate: null, createdDate: null, scheduledDate: null, + duration: Duration.None, dueDate: null, doneDate: null, cancelledDate: null, diff --git a/tests/DocumentationSamples/ValidateTasks.test.validate-tasks_all-emojis-emojis.approved.text b/tests/DocumentationSamples/ValidateTasks.test.validate-tasks_all-emojis-emojis.approved.text index 446ebaa3a6..c4ad1f0904 100644 --- a/tests/DocumentationSamples/ValidateTasks.test.validate-tasks_all-emojis-emojis.approved.text +++ b/tests/DocumentationSamples/ValidateTasks.test.validate-tasks_all-emojis-emojis.approved.text @@ -1 +1 @@ -πŸ”Ίβ«πŸ”ΌπŸ”½β¬πŸ›«βž•β³πŸ“…βœ…βŒπŸ”πŸβ›”πŸ†” +πŸ”Ίβ«πŸ”ΌπŸ”½β¬πŸ›«βž•β³β±πŸ“…βœ…βŒπŸ”πŸβ›”πŸ†” diff --git a/tests/DocumentationSamples/ValidateTasks.test.validate-tasks_find-unread-emojis.approved.text b/tests/DocumentationSamples/ValidateTasks.test.validate-tasks_find-unread-emojis.approved.text index d275634e8e..11b9795c6b 100644 --- a/tests/DocumentationSamples/ValidateTasks.test.validate-tasks_find-unread-emojis.approved.text +++ b/tests/DocumentationSamples/ValidateTasks.test.validate-tasks_find-unread-emojis.approved.text @@ -2,7 +2,7 @@ ````text ```tasks # These instructions need to be all on one line: -(description includes πŸ”Ί) OR (description includes ⏫) OR (description includes πŸ”Ό) OR (description includes πŸ”½) OR (description includes ⏬) OR (description includes πŸ›«) OR (description includes βž•) OR (description includes ⏳) OR (description includes πŸ“…) OR (description includes βœ…) OR (description includes ❌) OR (description includes πŸ”) OR (description includes 🏁) OR (description includes β›”) OR (description includes πŸ†”) +(description includes πŸ”Ί) OR (description includes ⏫) OR (description includes πŸ”Ό) OR (description includes πŸ”½) OR (description includes ⏬) OR (description includes πŸ›«) OR (description includes βž•) OR (description includes ⏳) OR (description includes ⏱) OR (description includes πŸ“…) OR (description includes βœ…) OR (description includes ❌) OR (description includes πŸ”) OR (description includes 🏁) OR (description includes β›”) OR (description includes πŸ†”) # Optionally, uncomment this line and exclude your templates location # path does not include _templates diff --git a/tests/Layout/TaskLayout.test.ts b/tests/Layout/TaskLayout.test.ts index ab6cc204a7..b5a49f755d 100644 --- a/tests/Layout/TaskLayout.test.ts +++ b/tests/Layout/TaskLayout.test.ts @@ -40,6 +40,7 @@ describe('TaskLayout tests', () => { tasks-layout-hide-createdDate tasks-layout-hide-startDate tasks-layout-hide-scheduledDate + tasks-layout-hide-duration tasks-layout-hide-dueDate tasks-layout-hide-cancelledDate tasks-layout-hide-doneDate diff --git a/tests/Layout/TaskLayoutOptions.test.ts b/tests/Layout/TaskLayoutOptions.test.ts index 60f0d5581b..1a34b91346 100644 --- a/tests/Layout/TaskLayoutOptions.test.ts +++ b/tests/Layout/TaskLayoutOptions.test.ts @@ -15,6 +15,7 @@ describe('TaskLayoutOptions', () => { createdDate startDate scheduledDate + duration dueDate cancelledDate doneDate @@ -71,6 +72,7 @@ describe('TaskLayoutOptions', () => { createdDate startDate scheduledDate + duration dueDate cancelledDate doneDate @@ -90,6 +92,7 @@ describe('TaskLayoutOptions', () => { createdDate startDate scheduledDate + duration cancelledDate doneDate" `); @@ -145,6 +148,7 @@ describe('TaskLayoutOptions', () => { createdDate startDate scheduledDate + duration dueDate cancelledDate doneDate" @@ -161,6 +165,7 @@ describe('parsing task show/hide layout options', () => { ['depends on', TaskLayoutComponent.DependsOn], ['done date', TaskLayoutComponent.DoneDate], ['due date', TaskLayoutComponent.DueDate], + ['duration', TaskLayoutComponent.Duration], ['id', TaskLayoutComponent.Id], ['on completion', TaskLayoutComponent.OnCompletion], ['priority', TaskLayoutComponent.Priority], diff --git a/tests/Query/Filter/DurationField.test.ts b/tests/Query/Filter/DurationField.test.ts new file mode 100644 index 0000000000..18c182e8e7 --- /dev/null +++ b/tests/Query/Filter/DurationField.test.ts @@ -0,0 +1,104 @@ +/** + * @jest-environment jsdom + */ + +import { DurationField } from '../../../src/Query/Filter/DurationField'; +import { TaskBuilder } from '../../TestingTools/TaskBuilder'; +import { expectTaskComparesAfter, expectTaskComparesBefore } from '../../CustomMatchers/CustomMatchersForSorting'; +import { SampleTasks } from '../../TestingTools/SampleTasks'; +import { Duration } from '../../../src/Task/Duration'; +import { toBeValid, toMatchTask } from '../../CustomMatchers/CustomMatchersForFilters'; + +expect.extend({ + toBeValid, +}); + +expect.extend({ + toMatchTask, +}); + +describe('DurationField', () => { + it('should reject duration search containing unexpanded template text', () => { + // Thorough checks are done in TemplatingPluginTools tests. + + // Arrange + const instruction = 'duration under <%+ tp.get_remaining_hours_in_day_example() %>'; + + // Act + const filter = new DurationField().createFilterOrErrorMessage(instruction); + + // Assert + expect(filter).not.toBeValid(); + expect(filter.error).toContain('Instruction contains unexpanded template text'); + }); + + it('should honour original case, when explaining simple filters', () => { + const filter = new DurationField().createFilterOrErrorMessage('HAS DURATION'); + expect(filter).toHaveExplanation('HAS DURATION'); + }); +}); + +describe('explain duration queries', () => { + it('should explain explicit date', () => { + const filterOrMessage = new DurationField().createFilterOrErrorMessage('duration under 5h'); + expect(filterOrMessage).toHaveExplanation('duration is under 5h0m'); + }); + + it('"is" gets not duplicated', () => { + const filterOrMessage = new DurationField().createFilterOrErrorMessage('duration is 5m'); + expect(filterOrMessage).toHaveExplanation('duration is 0h5m'); + }); +}); + +describe('sorting by duration', () => { + it('supports Field sorting methods correctly', () => { + const field = new DurationField(); + 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. + + const duration1 = new TaskBuilder().duration(new Duration({ hours: 1, minutes: 30 })).build(); + const duration2 = new TaskBuilder().duration(new Duration({ hours: 3, minutes: 0 })).build(); + + it('sort by duration', () => { + expectTaskComparesBefore(new DurationField().createNormalSorter(), duration1, duration2); + }); + + it('sort by duration reverse', () => { + expectTaskComparesAfter(new DurationField().createReverseSorter(), duration1, duration2); + }); +}); + +describe('grouping by duration', () => { + it('supports Field grouping methods correctly', () => { + expect(new DurationField()).toSupportGroupingWithProperty('duration'); + }); + + it('group by duration', () => { + // Arrange + const grouper = new DurationField().createNormalGrouper(); + const taskWithDuration = new TaskBuilder().duration(new Duration({ hours: 1, minutes: 30 })).build(); + const taskWithoutDuration = new TaskBuilder().build(); + + // Assert + expect({ grouper, tasks: [taskWithDuration] }).groupHeadingsToBe(['1h30m']); + expect({ grouper, tasks: [taskWithoutDuration] }).groupHeadingsToBe(['No duration']); + }); + + it('should sort groups for DurationField', () => { + const grouper = new DurationField().createNormalGrouper(); + const tasks = SampleTasks.withAllRepresentativeDurations(); + + expect({ grouper, tasks }).groupHeadingsToBe([ + '0h5m', + '0h90m', + '1h0m', + '3h25m', + '4h90m', + '96h0m', + 'No duration', + ]); + }); +}); diff --git a/tests/Query/Query.test.ts b/tests/Query/Query.test.ts index 4c9951c08c..f77b5938e9 100644 --- a/tests/Query/Query.test.ts +++ b/tests/Query/Query.test.ts @@ -20,6 +20,7 @@ import { createTasksFromMarkdown, fromLine } from '../TestingTools/TestHelpers'; import type { FilteringCase } from '../TestingTools/FilterTestHelpers'; import { shouldSupportFiltering } from '../TestingTools/FilterTestHelpers'; import { TaskBuilder } from '../TestingTools/TaskBuilder'; +import { Duration } from '../../src/Task/Duration'; import { Priority } from '../../src/Task/Priority'; import { TaskLayoutComponent } from '../../src/Layout/TaskLayoutOptions'; import query_using_properties from '../Obsidian/__test_data__/query_using_properties.json'; @@ -159,6 +160,9 @@ description includes \ 'due in 2021-12-27 2021-12-29', 'due on 2021-12-27', 'due this week', + 'duration above 1h30m', + 'duration is 5m', + 'duration under 2h', 'exclude sub-items', 'filename includes wibble', 'filter by function task.due.formatAsDate().includes("2024");', // The trailing ';' prevents 'Could not interpret the following instruction as a Boolean combination' @@ -345,6 +349,8 @@ description includes \ 'sort by done reverse', 'sort by due', 'sort by due reverse', + 'sort by duration', + 'sort by duration reverse', 'sort by filename', 'sort by filename reverse', 'sort by function reverse task.description.length', @@ -424,6 +430,8 @@ description includes \ 'group by done reverse', 'group by due', 'group by due reverse', + 'group by duration', + 'group by duration reverse', 'group by filename', 'group by filename reverse', 'group by folder', @@ -506,6 +514,7 @@ description includes \ 'hide depends on', 'hide done date', 'hide due date', + 'hide duration', 'hide edit button', 'hide id', 'hide on completion', @@ -531,6 +540,7 @@ description includes \ 'show depends on', 'show done date', 'show due date', + 'show duration', 'show edit button', 'show id', 'show on completion', @@ -1008,6 +1018,7 @@ describe('Query', () => { indentation: '', listMarker: '-', priority: Priority.None, + duration: Duration.None, startDate: null, scheduledDate: null, dueDate: null, @@ -1030,6 +1041,7 @@ describe('Query', () => { indentation: '', listMarker: '-', priority: Priority.None, + duration: Duration.None, startDate: null, scheduledDate: null, dueDate: null, @@ -1183,6 +1195,34 @@ describe('Query', () => { expectedResult: ['- [ ] I am done before filter, and should pass βœ… 2022-12-01'], }, ], + [ + 'by duration (under)', + { + filters: ['duration under 1h'], + tasks: [ + '- [ ] task 1', + '- [ ] task 2 ⏱ 45m', + '- [ ] task 3 ⏱ 90m', + '- [ ] task 4 ⏱ 1h30m', + '- [ ] task 5 ⏱ 2h', + ], + expectedResult: ['- [ ] task 2 ⏱ 0h45m'], + }, + ], + [ + 'by duration (above)', + { + filters: ['duration above 2h'], + tasks: [ + '- [ ] task 1', + '- [ ] task 2 ⏱ 45m', + '- [ ] task 3 ⏱ 123m', + '- [ ] task 4 ⏱ 1h30m', + '- [ ] task 5 ⏱ 2h', + ], + expectedResult: ['- [ ] task 3 ⏱ 0h123m'], + }, + ], ])('should support filtering %s', (_, { tasks: allTaskLines, filters, expectedResult }) => { shouldSupportFiltering(filters, allTaskLines, expectedResult); shouldSupportFiltering( diff --git a/tests/Renderer/QueryResultsRenderer.test.QueryResultsRenderer_tests_fully_populated_task.approved.html b/tests/Renderer/QueryResultsRenderer.test.QueryResultsRenderer_tests_fully_populated_task.approved.html index ea33bd251e..dd3f445943 100644 --- a/tests/Renderer/QueryResultsRenderer.test.QueryResultsRenderer_tests_fully_populated_task.approved.html +++ b/tests/Renderer/QueryResultsRenderer.test.QueryResultsRenderer_tests_fully_populated_task.approved.html @@ -1,5 +1,5 @@
@@ -10,6 +10,7 @@ data-task-created="past-4d" data-task-start="past-3d" data-task-scheduled="past-2d" + data-task-duration="26h59m" data-task-due="past-1d" data-task-cancelled="future-1d" data-task-done="today" @@ -43,6 +44,7 @@ title="Click to edit scheduled date, Right-click for more options"> ⏳ 2023-07-03 + ⏱ 26h59m πŸ“… 2023-07-04 diff --git a/tests/Renderer/QueryResultsRenderer.test.QueryResultsRenderer_tests_fully_populated_task_-_short_mode.approved.html b/tests/Renderer/QueryResultsRenderer.test.QueryResultsRenderer_tests_fully_populated_task_-_short_mode.approved.html index 6882ab6a24..00d7770386 100644 --- a/tests/Renderer/QueryResultsRenderer.test.QueryResultsRenderer_tests_fully_populated_task_-_short_mode.approved.html +++ b/tests/Renderer/QueryResultsRenderer.test.QueryResultsRenderer_tests_fully_populated_task_-_short_mode.approved.html @@ -1,5 +1,5 @@
@@ -10,6 +10,7 @@ data-task-created="past-4d" data-task-start="past-3d" data-task-scheduled="past-2d" + data-task-duration="26h59m" data-task-due="past-1d" data-task-cancelled="future-1d" data-task-done="today" @@ -43,6 +44,7 @@ title="Click to edit scheduled date, Right-click for more options"> ⏳ + ⏱ πŸ“… diff --git a/tests/Renderer/TaskLineRenderer.test.Visualise_HTML_Full_task_-_full_mode.approved.html b/tests/Renderer/TaskLineRenderer.test.Visualise_HTML_Full_task_-_full_mode.approved.html index 5360594f2c..d2e73d1bc5 100644 --- a/tests/Renderer/TaskLineRenderer.test.Visualise_HTML_Full_task_-_full_mode.approved.html +++ b/tests/Renderer/TaskLineRenderer.test.Visualise_HTML_Full_task_-_full_mode.approved.html @@ -1,5 +1,5 @@
  • ⏳ 2023-07-03 + ⏱ 26h59m πŸ“… 2023-07-04 diff --git a/tests/Renderer/TaskLineRenderer.test.Visualise_HTML_Full_task_-_short_mode.approved.html b/tests/Renderer/TaskLineRenderer.test.Visualise_HTML_Full_task_-_short_mode.approved.html index 378b57b86f..a327716ed8 100644 --- a/tests/Renderer/TaskLineRenderer.test.Visualise_HTML_Full_task_-_short_mode.approved.html +++ b/tests/Renderer/TaskLineRenderer.test.Visualise_HTML_Full_task_-_short_mode.approved.html @@ -1,5 +1,5 @@
  • ⏳ + ⏱ πŸ“… diff --git a/tests/Renderer/TaskLineRenderer.test.ts b/tests/Renderer/TaskLineRenderer.test.ts index 96c2963181..f28c7e8030 100644 --- a/tests/Renderer/TaskLineRenderer.test.ts +++ b/tests/Renderer/TaskLineRenderer.test.ts @@ -207,6 +207,7 @@ describe('task line rendering - layout options', () => { ' βž• 2023-07-01', ' πŸ›« 2023-07-02', ' ⏳ 2023-07-03', + ' ⏱ 26h59m', ' πŸ“… 2023-07-04', ' ❌ 2023-07-06', ' βœ… 2023-07-05', @@ -228,6 +229,7 @@ describe('task line rendering - layout options', () => { ' βž• 2023-07-01', ' πŸ›« 2023-07-02', ' ⏳ 2023-07-03', + ' ⏱ 26h59m', ' πŸ“… 2023-07-04', ' ❌ 2023-07-06', ' βœ… 2023-07-05', @@ -243,6 +245,10 @@ describe('task line rendering - layout options', () => { await testLayoutOptions(['Do exercises #todo #health', ' πŸ”Ό'], [TaskLayoutComponent.Priority]); }); + it('renders with duration', async () => { + await testLayoutOptions(['Do exercises #todo #health', ' ⏱ 26h59m'], [TaskLayoutComponent.Duration]); + }); + it('renders with recurrence rule', async () => { await testLayoutOptions( ['Do exercises #todo #health', ' πŸ” every day when done'], @@ -364,6 +370,10 @@ describe('task line rendering - classes and data attributes', () => { await testComponentClasses('- [ ] Minimal task β›” ya44g5,hry475', 'task-dependsOn', ''); }); + it('renders duration field with its class', async () => { + await testComponentClasses('- [ ] Minimal task ⏱️ 1h30m', 'task-duration', 'taskDuration: 1h30m'); + }); + it('should render recurrence component with its class and data attribute', async () => { await testComponentClasses( '- [ ] Full task ⏫ πŸ“… 2022-07-02 ⏳ 2022-07-03 πŸ›« 2022-07-04 πŸ” every day', diff --git a/tests/Scripting/TaskProperties.test.task_other_fields.approved.md b/tests/Scripting/TaskProperties.test.task_other_fields.approved.md index 72ffe65078..cede794544 100644 --- a/tests/Scripting/TaskProperties.test.task_other_fields.approved.md +++ b/tests/Scripting/TaskProperties.test.task_other_fields.approved.md @@ -4,6 +4,8 @@ | ----- | ----- | ----- | ----- | ----- | | `task.description` | `string` | `'Do exercises #todo #health'` | `string` | `'minimal task'` | | `task.descriptionWithoutTags` | `string` | `'Do exercises'` | `string` | `'minimal task'` | +| `task.durationHours` | `number` | `26` | `string` | `''` | +| `task.durationMinutes` | `number` | `59` | `string` | `''` | | `task.priorityNumber` | `number` | `2` | `number` | `3` | | `task.priorityName` | `string` | `'Medium'` | `string` | `'Normal'` | | `task.priorityNameGroupText` | `string` | `'%%2%%Medium priority'` [^commented] | `string` | `'%%3%%Normal priority'` [^commented] | @@ -12,7 +14,7 @@ | `task.recurrenceRule` | `string` | `'every day when done'` | `string` | `''` | | `task.onCompletion` | `string` | `'delete'` | `string` | `''` | | `task.tags` | `string[]` | `['#todo', '#health']` | `any[]` | `[]` | -| `task.originalMarkdown` | `string` | `' - [ ] Do exercises #todo #health πŸ†” abcdef β›” 123456,abc123 πŸ”Ό πŸ” every day when done 🏁 delete βž• 2023-07-01 πŸ›« 2023-07-02 ⏳ 2023-07-03 πŸ“… 2023-07-04 ❌ 2023-07-06 βœ… 2023-07-05 ^dcf64c'` | `string` | `'- [/] minimal task'` | +| `task.originalMarkdown` | `string` | `' - [ ] Do exercises #todo #health πŸ†” abcdef β›” 123456,abc123 πŸ”Ό πŸ” every day when done 🏁 delete βž• 2023-07-01 πŸ›« 2023-07-02 ⏳ 2023-07-03 ⏱ 26h59m πŸ“… 2023-07-04 ❌ 2023-07-06 βœ… 2023-07-05 ^dcf64c'` | `string` | `'- [/] minimal task'` | | `task.lineNumber` | `number` | `17` | `number` | `0` | diff --git a/tests/Scripting/TaskProperties.test.ts b/tests/Scripting/TaskProperties.test.ts index 05f90e5a2a..c70dfb1eb4 100644 --- a/tests/Scripting/TaskProperties.test.ts +++ b/tests/Scripting/TaskProperties.test.ts @@ -127,6 +127,8 @@ describe('task', () => { verifyFieldDataForReferenceDocs([ 'task.description', 'task.descriptionWithoutTags', + 'task.durationHours', + 'task.durationMinutes', 'task.priorityNumber', 'task.priorityName', 'task.priorityNameGroupText', diff --git a/tests/Suggestor/Suggestor.test.auto-complete_with__dataview__symbols_offers_basic_completion_options_for_an_empty_task.approved.json b/tests/Suggestor/Suggestor.test.auto-complete_with__dataview__symbols_offers_basic_completion_options_for_an_empty_task.approved.json index 2ea89e2059..db1380132c 100644 --- a/tests/Suggestor/Suggestor.test.auto-complete_with__dataview__symbols_offers_basic_completion_options_for_an_empty_task.approved.json +++ b/tests/Suggestor/Suggestor.test.auto-complete_with__dataview__symbols_offers_basic_completion_options_for_an_empty_task.approved.json @@ -11,6 +11,10 @@ "displayText": "scheduled:: scheduled date", "appendText": "scheduled:: " }, + { + "displayText": "duration:: duration", + "appendText": "duration:: " + }, { "displayText": "priority:: high priority", "appendText": "priority:: high] ", diff --git a/tests/Suggestor/Suggestor.test.auto-complete_with__dataview__symbols_show_all_suggested_text.approved.md b/tests/Suggestor/Suggestor.test.auto-complete_with__dataview__symbols_show_all_suggested_text.approved.md index 6610ae58f2..32e04f0abe 100644 --- a/tests/Suggestor/Suggestor.test.auto-complete_with__dataview__symbols_show_all_suggested_text.approved.md +++ b/tests/Suggestor/Suggestor.test.auto-complete_with__dataview__symbols_show_all_suggested_text.approved.md @@ -3,6 +3,7 @@ | due:: due date | due:: | | start:: start date | start:: | | scheduled:: scheduled date | scheduled:: | +| duration:: duration | duration:: | | priority:: high priority | priority:: high] | | priority:: medium priority | priority:: medium] | | priority:: low priority | priority:: low] | @@ -50,6 +51,13 @@ | next week (2022-07-18) | scheduled:: 2022-07-18] | | next month (2022-08-11) | scheduled:: 2022-08-11] | | next year (2023-07-11) | scheduled:: 2023-07-11] | +| duration:: 5m | duration:: 5m] | +| duration:: 15m | duration:: 15m] | +| duration:: 1h | duration:: 1h] | +| duration:: 30m | duration:: 30m] | +| duration:: 45m | duration:: 45m] | +| duration:: 2h | duration:: 2h] | +| duration:: 3h | duration:: 3h] | | today (2022-07-11) | start:: 2022-07-11] | | tomorrow (2022-07-12) | start:: 2022-07-12] | | Sunday (2022-07-17) | start:: 2022-07-17] | diff --git a/tests/Suggestor/Suggestor.test.auto-complete_with__emoji__symbols_offers_basic_completion_options_for_an_empty_task.approved.json b/tests/Suggestor/Suggestor.test.auto-complete_with__emoji__symbols_offers_basic_completion_options_for_an_empty_task.approved.json index ef887cd4d8..9944f6d242 100644 --- a/tests/Suggestor/Suggestor.test.auto-complete_with__emoji__symbols_offers_basic_completion_options_for_an_empty_task.approved.json +++ b/tests/Suggestor/Suggestor.test.auto-complete_with__emoji__symbols_offers_basic_completion_options_for_an_empty_task.approved.json @@ -16,6 +16,10 @@ "displayText": "⏳ scheduled date", "appendText": "⏳ " }, + { + "displayText": "⏱ duration", + "appendText": "⏱ " + }, { "displayText": "⏫ high priority", "appendText": "⏫ " diff --git a/tests/Suggestor/Suggestor.test.auto-complete_with__emoji__symbols_show_all_suggested_text.approved.md b/tests/Suggestor/Suggestor.test.auto-complete_with__emoji__symbols_show_all_suggested_text.approved.md index 1e84f27286..d185860222 100644 --- a/tests/Suggestor/Suggestor.test.auto-complete_with__emoji__symbols_show_all_suggested_text.approved.md +++ b/tests/Suggestor/Suggestor.test.auto-complete_with__emoji__symbols_show_all_suggested_text.approved.md @@ -4,6 +4,7 @@ | πŸ“… due date | πŸ“… | | πŸ›« start date | πŸ›« | | ⏳ scheduled date | ⏳ | +| ⏱ duration | ⏱ | | ⏫ high priority | ⏫ | | πŸ”Ό medium priority | πŸ”Ό | | πŸ”½ low priority | πŸ”½ | @@ -51,6 +52,13 @@ | next week (2022-07-18) | ⏳ 2022-07-18 | | next month (2022-08-11) | ⏳ 2022-08-11 | | next year (2023-07-11) | ⏳ 2023-07-11 | +| ⏱ 5m | ⏱ 5m | +| ⏱ 15m | ⏱ 15m | +| ⏱ 1h | ⏱ 1h | +| ⏱ 30m | ⏱ 30m | +| ⏱ 45m | ⏱ 45m | +| ⏱ 2h | ⏱ 2h | +| ⏱ 3h | ⏱ 3h | | today (2022-07-11) | πŸ›« 2022-07-11 | | tomorrow (2022-07-12) | πŸ›« 2022-07-12 | | Sunday (2022-07-17) | πŸ›« 2022-07-17 | diff --git a/tests/Suggestor/Suggestor.test.ts b/tests/Suggestor/Suggestor.test.ts index ba464dcf90..c9909add7d 100644 --- a/tests/Suggestor/Suggestor.test.ts +++ b/tests/Suggestor/Suggestor.test.ts @@ -216,6 +216,7 @@ ${JSON.stringify(suggestions[0], null, 4)} const { dueDateSymbol, scheduledDateSymbol, + durationSymbol, startDateSymbol, createdDateSymbol, recurrenceSymbol, @@ -252,6 +253,24 @@ ${JSON.stringify(suggestions[0], null, 4)} verifyFirstSuggestions(lines, 'How due date suggestions are affected by what the user has typed:'); }); + it('offers generic duration completions', () => { + const line = `- [ ] some task ${durationSymbol} `; //FIXME: errors on removing trailing space.. + shouldStartWithSuggestionsEqualling(line, [ + `${durationSymbol} 5m`, + `${durationSymbol} 15m`, + `${durationSymbol} 1h`, + ]); + }); + + it('offers specific duration completions', () => { + const line = `- [ ] some task ${durationSymbol} 1h`; + shouldStartWithSuggestionsEqualling(line, [ + `${durationSymbol} 1h15m`, + `${durationSymbol} 1h30m`, + `${durationSymbol} 1h45m`, + ]); + }); + it('offers generic recurrence completions', () => { const line = `- [ ] some task ${recurrenceSymbol}`; shouldStartWithSuggestionsEqualling(line, ['every', 'every day', 'every week']); @@ -488,6 +507,7 @@ ${JSON.stringify(suggestions[0], null, 4)} `- [ ] some task ${recurrenceSymbol} `, `- [ ] some task ${dueDateSymbol} `, `- [ ] some task ${scheduledDateSymbol} `, + `- [ ] some task ${durationSymbol} `, `- [ ] some task ${startDateSymbol} `, `- [ ] some task ${onCompletionSymbol} `, ]; diff --git a/tests/Task/Task.test.ts b/tests/Task/Task.test.ts index ba5364cc35..daa2aaeb50 100644 --- a/tests/Task/Task.test.ts +++ b/tests/Task/Task.test.ts @@ -16,6 +16,7 @@ import { fromLine, toMarkdown } from '../TestingTools/TestHelpers'; import { TaskBuilder } from '../TestingTools/TaskBuilder'; import { RecurrenceBuilder } from '../TestingTools/RecurrenceBuilder'; import { Priority } from '../../src/Task/Priority'; +import { Duration } from '../../src/Task/Duration'; import { SampleTasks } from '../TestingTools/SampleTasks'; import { booleanToEmoji } from '../TestingTools/FilterTestHelpers'; import type { TasksDate } from '../../src/DateTime/TasksDate'; @@ -1649,6 +1650,12 @@ describe('identicalTo', () => { expect(lhs).not.toBeIdenticalTo(new TaskBuilder().priority(Priority.None)); }); + it('should check duration', () => { + const lhs = new TaskBuilder().duration(Duration.fromText('1h30m')!); + expect(lhs).toBeIdenticalTo(new TaskBuilder().duration(new Duration({ hours: 1, minutes: 30 }))); + expect(lhs).not.toBeIdenticalTo(new TaskBuilder().duration(new Duration({ hours: 0, minutes: 90 }))); + }); + it('should check createdDate', () => { const lhs = new TaskBuilder().createdDate('2012-12-27'); expect(lhs).toBeIdenticalTo(new TaskBuilder().createdDate('2012-12-27')); diff --git a/tests/TaskSerializer/DataviewTaskSerializer.test.ts b/tests/TaskSerializer/DataviewTaskSerializer.test.ts index 67ed7a6753..fd101dba67 100644 --- a/tests/TaskSerializer/DataviewTaskSerializer.test.ts +++ b/tests/TaskSerializer/DataviewTaskSerializer.test.ts @@ -9,6 +9,7 @@ import type { Task } from '../../src/Task/Task'; import type { TaskDetails } from '../../src/TaskSerializer'; import { OnCompletion } from '../../src/Task/OnCompletion'; import { Priority } from '../../src/Task/Priority'; +import { Duration } from '../../src/Task/Duration'; jest.mock('obsidian'); window.moment = moment; @@ -46,6 +47,13 @@ describe('DataviewTaskSerializer', () => { } }); + it('should parse a duration', () => { + const taskDetails = deserialize('[duration:: 1h30m]'); + expect(taskDetails).toMatchTaskDetails({ + duration: new Duration({ hours: 1, minutes: 30 }), + }); + }); + it('should parse a recurrence', () => { const taskDetails = deserialize('[repeat:: every day]'); expect(taskDetails).toMatchTaskDetails({ @@ -288,6 +296,8 @@ describe('DataviewTaskSerializer', () => { priority: Priority.High, }); }); + + //TODO: add different duration-formulation as dataviewTask }); describe('serialize', () => { @@ -317,6 +327,15 @@ describe('DataviewTaskSerializer', () => { expect(serialized).toEqual(''); }); + it('should serialize duration', () => { + const task = new TaskBuilder() + .description('') + .duration(new Duration({ hours: 1, minutes: 30 })) + .build(); + const serialized = serialize(task); + expect(serialized).toEqual(' [duration:: 1h30m]'); + }); + it('should serialize a recurrence', () => { const task = new TaskBuilder() .recurrence(new RecurrenceBuilder().rule('every day').build()) @@ -354,7 +373,7 @@ describe('DataviewTaskSerializer', () => { const task = TaskBuilder.createFullyPopulatedTask(); const serialized = serialize(task); expect(serialized).toMatchInlineSnapshot( - '"Do exercises #todo #health [id:: abcdef] [dependsOn:: 123456,abc123] [priority:: medium] [repeat:: every day when done] [onCompletion:: delete] [created:: 2023-07-01] [start:: 2023-07-02] [scheduled:: 2023-07-03] [due:: 2023-07-04] [cancelled:: 2023-07-06] [completion:: 2023-07-05] ^dcf64c"', + '"Do exercises #todo #health [id:: abcdef] [dependsOn:: 123456,abc123] [priority:: medium] [repeat:: every day when done] [onCompletion:: delete] [created:: 2023-07-01] [start:: 2023-07-02] [scheduled:: 2023-07-03] [duration:: 26h59m] [due:: 2023-07-04] [cancelled:: 2023-07-06] [completion:: 2023-07-05] ^dcf64c"', ); }); }); diff --git a/tests/TaskSerializer/DefaultTaskSerializer.test.ts b/tests/TaskSerializer/DefaultTaskSerializer.test.ts index d6843fac78..0764fa274c 100644 --- a/tests/TaskSerializer/DefaultTaskSerializer.test.ts +++ b/tests/TaskSerializer/DefaultTaskSerializer.test.ts @@ -13,6 +13,7 @@ import { import { TaskBuilder } from '../TestingTools/TaskBuilder'; import { OnCompletion } from '../../src/Task/OnCompletion'; import { Priority } from '../../src/Task/Priority'; +import { Duration } from '../../src/Task/Duration'; import { escapeInvisibleCharacters } from '../../src/lib/StringHelpers'; jest.mock('obsidian'); @@ -69,6 +70,7 @@ describe('validate emoji regular expressions', () => { startDateRegex: /πŸ›«\\ufe0f? *(\\d{4}-\\d{2}-\\d{2})$/ createdDateRegex: /βž•\\ufe0f? *(\\d{4}-\\d{2}-\\d{2})$/ scheduledDateRegex: /(?:⏳|βŒ›)\\ufe0f? *(\\d{4}-\\d{2}-\\d{2})$/ + durationRegex: /⏱\\ufe0f? *([0-9]+h[0-9]+m?|[0-9]+h|[0-9]+m?)$/ dueDateRegex: /(?:πŸ“…|πŸ“†|πŸ—“)\\ufe0f? *(\\d{4}-\\d{2}-\\d{2})$/ doneDateRegex: /βœ…\\ufe0f? *(\\d{4}-\\d{2}-\\d{2})$/ cancelledDateRegex: /❌\\ufe0f? *(\\d{4}-\\d{2}-\\d{2})$/ @@ -93,6 +95,7 @@ describe.each(symbolMap)("DefaultTaskSerializer with '$taskFormat' symbols", ({ recurrenceSymbol, onCompletionSymbol, scheduledDateSymbol, + durationSymbol, dueDateSymbol, doneDateSymbol, idSymbol, @@ -173,6 +176,13 @@ describe.each(symbolMap)("DefaultTaskSerializer with '$taskFormat' symbols", ({ }); }); + it('should parse a duration', () => { + const taskDetails = deserialize(`${durationSymbol} 1h30m`); + expect(taskDetails).toMatchTaskDetails({ + duration: new Duration({ hours: 1, minutes: 30 }), + }); + }); + it('should parse a recurrence', () => { const taskDetails = deserialize(`${recurrenceSymbol} every day`); expect(taskDetails).toMatchTaskDetails({ @@ -302,6 +312,18 @@ describe.each(symbolMap)("DefaultTaskSerializer with '$taskFormat' symbols", ({ expect(serialized).toEqual(''); }); + it('should serialize a valid duration', () => { + const task = new TaskBuilder().duration(Duration.fromText('1h30m')!).description('').build(); + const serialized = serialize(task); + expect(serialized).toEqual(` ${durationSymbol} 1h30m`); + }); + + it('should serialize a None duration', () => { + const task = new TaskBuilder().duration(Duration.None).description('').build(); + const serialized = serialize(task); + expect(serialized).toEqual(''); + }); + it('should serialize a recurrence', () => { const task = new TaskBuilder() .recurrence(new RecurrenceBuilder().rule('every day').build()) diff --git a/tests/TaskSerializer/DocsSamplesForTaskFormats.test.Serializer_Emojis_tabulate-emojis.approved.md b/tests/TaskSerializer/DocsSamplesForTaskFormats.test.Serializer_Emojis_tabulate-emojis.approved.md index c93e88df72..01185b38e7 100644 --- a/tests/TaskSerializer/DocsSamplesForTaskFormats.test.Serializer_Emojis_tabulate-emojis.approved.md +++ b/tests/TaskSerializer/DocsSamplesForTaskFormats.test.Serializer_Emojis_tabulate-emojis.approved.md @@ -12,6 +12,7 @@ | πŸ›« | U+1F6EB | | ⏫ | U+23EB | | ⏬ | U+23EC | +| ⏱ | U+23F1 | | ⏳ | U+23F3 | | β›” | U+26D4 | | βœ… | U+2705 | diff --git a/tests/TaskSerializer/TaskSerializer.test.ts b/tests/TaskSerializer/TaskSerializer.test.ts index 1541ce9fde..122774a5ae 100644 --- a/tests/TaskSerializer/TaskSerializer.test.ts +++ b/tests/TaskSerializer/TaskSerializer.test.ts @@ -8,6 +8,7 @@ import type { TaskDetails, TaskSerializer } from '../../src/TaskSerializer'; import { TaskBuilder } from '../TestingTools/TaskBuilder'; import { OnCompletion } from '../../src/Task/OnCompletion'; import { Priority } from '../../src/Task/Priority'; +import { Duration } from '../../src/Task/Duration'; import { TaskRegularExpressions } from '../../src/Task/TaskRegularExpressions'; jest.mock('obsidian'); @@ -70,6 +71,7 @@ describe('TaskSerializer Example', () => { startDate: null, createdDate: null, scheduledDate: null, + duration: Duration.None, doneDate: null, cancelledDate: null, recurrence: null, diff --git a/tests/TestingTools/SampleTasks.ts b/tests/TestingTools/SampleTasks.ts index 1bd7aae673..0e4a00d161 100644 --- a/tests/TestingTools/SampleTasks.ts +++ b/tests/TestingTools/SampleTasks.ts @@ -5,6 +5,7 @@ import { Status } from '../../src/Statuses/Status'; import { StatusType } from '../../src/Statuses/StatusConfiguration'; import { Priority } from '../../src/Task/Priority'; import { PriorityTools } from '../../src/lib/PriorityTools'; +import { Duration } from '../../src/Task/Duration'; import { OnCompletion } from '../../src/Task/OnCompletion'; import { TaskBuilder } from './TaskBuilder'; import { fromLine, fromLines } from './TestHelpers'; @@ -147,6 +148,12 @@ export class SampleTasks { }); } + public static withAllRepresentativeDurations(): Task[] { + return ['', '5m', '90m', '1h', '3h25', '4h90m', '96h'].map((duration) => { + return new TaskBuilder().duration(Duration.fromText(duration)!).build(); + }); + } + public static withEachDateTypeAndCorrespondingStatus(): Task[] { function desc(fieldName: string) { return `#task Has a ${fieldName} date`; diff --git a/tests/TestingTools/TaskBuilder.test.ts b/tests/TestingTools/TaskBuilder.test.ts index 9062d1b568..a179fcd6b2 100644 --- a/tests/TestingTools/TaskBuilder.test.ts +++ b/tests/TestingTools/TaskBuilder.test.ts @@ -86,7 +86,7 @@ describe('TaskBuilder', () => { expect(getNullOrUnsetFields(task.taskLocation)).toEqual([]); expect(task.originalMarkdown).toEqual( - ' - [ ] Do exercises #todo #health πŸ†” abcdef β›” 123456,abc123 πŸ”Ό πŸ” every day when done 🏁 delete βž• 2023-07-01 πŸ›« 2023-07-02 ⏳ 2023-07-03 πŸ“… 2023-07-04 ❌ 2023-07-06 βœ… 2023-07-05 ^dcf64c', + ' - [ ] Do exercises #todo #health πŸ†” abcdef β›” 123456,abc123 πŸ”Ό πŸ” every day when done 🏁 delete βž• 2023-07-01 πŸ›« 2023-07-02 ⏳ 2023-07-03 ⏱ 26h59m πŸ“… 2023-07-04 ❌ 2023-07-06 βœ… 2023-07-05 ^dcf64c', ); }); }); diff --git a/tests/TestingTools/TaskBuilder.ts b/tests/TestingTools/TaskBuilder.ts index 2a41cff14e..c394127686 100644 --- a/tests/TestingTools/TaskBuilder.ts +++ b/tests/TestingTools/TaskBuilder.ts @@ -10,6 +10,7 @@ import { DateParser } from '../../src/DateTime/DateParser'; import { StatusConfiguration, StatusType } from '../../src/Statuses/StatusConfiguration'; import { TaskLocation } from '../../src/Task/TaskLocation'; import { Priority } from '../../src/Task/Priority'; +import { Duration } from '../../src/Task/Duration'; import { setCurrentCacheFile } from '../__mocks__/obsidian'; import type { ListItem } from '../../src/Task/ListItem'; import type { SimulatedFile } from '../Obsidian/SimulatedFile'; @@ -47,6 +48,7 @@ export class TaskBuilder { private _createdDate: Moment | null = null; private _startDate: Moment | null = null; private _scheduledDate: Moment | null = null; + private _duration: Duration = Duration.None; private _dueDate: Moment | null = null; private _doneDate: Moment | null = null; private _cancelledDate: Moment | null = null; @@ -99,6 +101,7 @@ export class TaskBuilder { createdDate: this._createdDate, startDate: this._startDate, scheduledDate: this._scheduledDate, + duration: this._duration, dueDate: this._dueDate, doneDate: this._doneDate, cancelledDate: this._cancelledDate, @@ -131,6 +134,7 @@ export class TaskBuilder { .createdDate('2023-07-01') .startDate('2023-07-02') .scheduledDate('2023-07-03') + .duration(new Duration({ hours: 26, minutes: 59 })) .dueDate('2023-07-04') .doneDate('2023-07-05') .cancelledDate('2023-07-06') @@ -274,6 +278,11 @@ export class TaskBuilder { return this; } + public duration(duration: Duration): this { + this._duration = duration; + return this; + } + public dueDate(dueDate: string | null): this { this._dueDate = TaskBuilder.parseDate(dueDate); return this; diff --git a/tests/ui/EditTask.test.Edit_Modal_HTML_snapshot_tests_should_match_snapshot.approved.html b/tests/ui/EditTask.test.Edit_Modal_HTML_snapshot_tests_should_match_snapshot.approved.html index 5891f9f15e..a58a1743ea 100644 --- a/tests/ui/EditTask.test.Edit_Modal_HTML_snapshot_tests_should_match_snapshot.approved.html +++ b/tests/ui/EditTask.test.Edit_Modal_HTML_snapshot_tests_should_match_snapshot.approved.html @@ -137,6 +137,15 @@

  • +
    + + + + ⏱ + no duration + +
    +

    +
    + + + + ⏱ + no duration + +
    +
    diff --git a/tests/ui/EditTask.test.ts b/tests/ui/EditTask.test.ts index 1eb16fa943..f950f2bae5 100644 --- a/tests/ui/EditTask.test.ts +++ b/tests/ui/EditTask.test.ts @@ -252,6 +252,10 @@ describe('Task rendering', () => { testElementRender(fullyPopulatedLine, 'scheduled', '2023-07-03'); }); + it('should display valid duration', () => { + testElementRender(fullyPopulatedLine, 'duration', '26h59m'); + }); + it('should display valid due date', () => { testElementRender(fullyPopulatedLine, 'due', '2023-07-04'); }); diff --git a/tests/ui/EditableTask.test.ts b/tests/ui/EditableTask.test.ts index c5fef7539d..ebc72bebda 100644 --- a/tests/ui/EditableTask.test.ts +++ b/tests/ui/EditableTask.test.ts @@ -54,6 +54,7 @@ describe('EditableTask tests', () => { "description": "Do exercises #todo #health", "doneDate": "2023-07-05", "dueDate": "2023-07-04", + "duration": "26h59m", "forwardOnly": true, "onCompletion": "delete", "originalBlocking": [], @@ -168,11 +169,15 @@ describe('EditableTask tests', () => { "description": "", "doneDate": null, "dueDate": null, + "duration": Duration { + "hours": 26, + "minutes": 59, + }, "id": "abcdef", "indentation": " ", "listMarker": "-", "onCompletion": "", - "originalMarkdown": " - [ ] Do exercises #todo #health πŸ†” abcdef β›” 123456,abc123 πŸ”Ό πŸ” every day when done 🏁 delete βž• 2023-07-01 πŸ›« 2023-07-02 ⏳ 2023-07-03 πŸ“… 2023-07-04 ❌ 2023-07-06 βœ… 2023-07-05 ^dcf64c", + "originalMarkdown": " - [ ] Do exercises #todo #health πŸ†” abcdef β›” 123456,abc123 πŸ”Ό πŸ” every day when done 🏁 delete βž• 2023-07-01 πŸ›« 2023-07-02 ⏳ 2023-07-03 ⏱ 26h59m πŸ“… 2023-07-04 ❌ 2023-07-06 βœ… 2023-07-05 ^dcf64c", "parent": null, "priority": "3", "recurrence": null,