diff --git a/src/app/features/tasks/short-syntax.spec.ts b/src/app/features/tasks/short-syntax.spec.ts index 8023f9c6e73..9b223cac7c4 100644 --- a/src/app/features/tasks/short-syntax.spec.ts +++ b/src/app/features/tasks/short-syntax.spec.ts @@ -1,6 +1,10 @@ import { ShowSubTasksMode, TaskCopy } from './task.model'; import { shortSyntax } from './short-syntax'; import { getWorklogStr } from '../../util/get-work-log-str'; +import { + MONTH_SHORT_NAMES, + oneDayInMilliseconds, +} from '../../util/month-time-conversion'; import { Tag } from '../tag/tag.model'; import { DEFAULT_TAG } from '../tag/tag.const'; import { Project } from '../project/project.model'; @@ -111,6 +115,28 @@ const checkIfCorrectDateAndTime = ( return isDayCorrect && isHourCorrect && isMinuteCorrect; }; +const checkIfCorrectDateMonthAndYear = ( + timestamp: number, + givenDate: number, + givenMonth: number, + givenYear: number, + hour?: number, + minute?: number, +): boolean => { + const date = new Date(timestamp); + const correctDateMonthYear = + date.getDate() === givenDate && + date.getMonth() + 1 === givenMonth && + date.getFullYear() === givenYear; + if (!hour) { + return correctDateMonthYear; + } + if (!minute) { + return correctDateMonthYear && date.getHours() === hour; + } + return correctDateMonthYear && date.getHours() === hour && date.getMinutes() === minute; +}; + describe('shortSyntax', () => { it('should ignore for no short syntax', () => { const r = shortSyntax(TASK, CONFIG); @@ -955,4 +981,61 @@ describe('shortSyntax', () => { expect(r).toEqual(undefined); }); }); + + // This group of tests address Chrono's parsing the format " " as year + // This will cause unintended parsing result when the date syntax is used together with the time estimate syntax + // https://github.com/johannesjo/super-productivity/issues/4194 + // The focus of this test group will be the ability of the parser to get the correct year and time estimate + describe('should not parse time estimate syntax as year', () => { + const today = new Date(); + const minuteEstimate = 90; + + it('should correctly parse year and time estimate when the input date only has month and day of the month', () => { + const tomorrow = new Date(today.getTime() + oneDayInMilliseconds); + const inputMonth = tomorrow.getMonth() + 1; + const inputMonthName = MONTH_SHORT_NAMES[tomorrow.getMonth()]; + const inputDayOfTheMonth = tomorrow.getDate(); + const t = { + ...TASK, + title: `Test @${inputMonthName} ${inputDayOfTheMonth} ${minuteEstimate}m`, + }; + const parsedTaskInfo = shortSyntax(t, CONFIG, []); + const taskChanges = parsedTaskInfo?.taskChanges; + const plannedAt = taskChanges?.plannedAt as number; + expect( + checkIfCorrectDateMonthAndYear( + plannedAt, + inputDayOfTheMonth, + inputMonth, + tomorrow.getFullYear(), + ), + ).toBeTrue(); + expect(taskChanges?.timeEstimate).toEqual(minuteEstimate * 60 * 1000); + }); + + it('should correctly parse year and time estimate when the input date contains month, day of the month and time', () => { + const time = '4pm'; + const tomorrow = new Date(today.getTime() + oneDayInMilliseconds); + const inputMonth = tomorrow.getMonth() + 1; + const inputMonthName = MONTH_SHORT_NAMES[tomorrow.getMonth()]; + const inputDayOfTheMonth = tomorrow.getDate(); + const t = { + ...TASK, + title: `Test @${inputMonthName} ${inputDayOfTheMonth} ${time} ${minuteEstimate}m`, + }; + const parsedTaskInfo = shortSyntax(t, CONFIG, []); + const taskChanges = parsedTaskInfo?.taskChanges; + const plannedAt = taskChanges?.plannedAt as number; + expect( + checkIfCorrectDateMonthAndYear( + plannedAt, + inputDayOfTheMonth, + inputMonth, + tomorrow.getFullYear(), + 16, + ), + ).toBeTrue(); + expect(taskChanges?.timeEstimate).toEqual(minuteEstimate * 60 * 1000); + }); + }); }); diff --git a/src/app/features/tasks/short-syntax.ts b/src/app/features/tasks/short-syntax.ts index 0a25f665497..25ad0c74da9 100644 --- a/src/app/features/tasks/short-syntax.ts +++ b/src/app/features/tasks/short-syntax.ts @@ -28,6 +28,43 @@ const CH_DUE = '@'; const ALL_SPECIAL = `(\\${CH_PRO}|\\${CH_TAG}|\\${CH_DUE})`; const customDateParser = casual.clone(); +customDateParser.refiners.push({ + refine: (context, results) => { + results.forEach((result) => { + const { refDate, text, start } = result; + const regex = / [5-9][0-9]$/; + const yearIndex = text.search(regex); + // The year pattern in Chrono's source code is (?:[1-9][0-9]{0,3}\\s{0,2}(?:BE|AD|BC|BCE|CE)|[1-2][0-9]{3}|[5-9][0-9]|2[0-5]). + // This means any two-digit numeric value from 50 to 99 will be considered a year. + // Link: https://github.com/wanasit/chrono/blob/54e7ff12f9185e735ee860c25922b2ab2367d40b/src/locales/en/constants.ts#L234C30-L234C108 + // When someone creates a task like "Test @25/4 90m", Chrono will return the year as 1990, which is an undesirable behaviour in most cases. + if (yearIndex !== -1) { + result.text = text.slice(0, yearIndex); + const current = new Date(); + let year = current.getFullYear(); + // If the parsed month is smaller than the current month, + // it means the time is for next year. For example, parsed month is March + // and it is currently April + const impliedDate = start.get('day'); + const impliedMonth = start.get('month'); + // Due to the future-forward nature of the date parser, there are two scenarios that the implied year is next year: + // - Implied month is smaller than current month i.e. 20/3 vs 2/4 + // - Same month but the implied date is before the current date i.e. 14/4 vs 20/4 + if ( + (impliedMonth && impliedMonth < refDate.getMonth() + 1) || + (impliedMonth === refDate.getMonth() + 1 && + impliedDate && + impliedDate < refDate.getDate()) + ) { + // || (impliedMonth === refDate.getMonth() + 1 && impliedDay && impliedDay < refDate.getDay()) + year += 1; + } + result.start.assign('year', year); + } + }); + return results; + }, +}); const SHORT_SYNTAX_PROJECT_REG_EX = new RegExp(`\\${CH_PRO}[^${ALL_SPECIAL}]+`, 'gi'); const SHORT_SYNTAX_TAGS_REG_EX = new RegExp(`\\${CH_TAG}[^${ALL_SPECIAL}|\\s]+`, 'gi'); @@ -285,7 +322,13 @@ const parseScheduledDate = (task: Partial, now: Date): DueChanges => { if (!start.isCertain('hour')) { hasPlannedTime = false; } - const inputDate = parsedDateResult.text; + let inputDate = parsedDateResult.text; + // Hacky way to strip short syntax for time estimate that was + // accidentally included in the date parser + // For example: the task is "Task @14/4 90m" and we don't want "90m" + if (inputDate.match(/ [0-9]{1,}m/g)) { + inputDate += 'm'; + } return { plannedAt, // Strip out the short syntax for scheduled date and given date diff --git a/src/app/util/month-time-conversion.ts b/src/app/util/month-time-conversion.ts new file mode 100644 index 00000000000..8395d4ed739 --- /dev/null +++ b/src/app/util/month-time-conversion.ts @@ -0,0 +1,16 @@ +export const MONTH_SHORT_NAMES = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', +]; + +export const oneDayInMilliseconds = 24 * 60 * 60 * 1000;