diff --git a/src/app/features/tasks/add-task-bar/add-task-bar-parser.service.spec.ts b/src/app/features/tasks/add-task-bar/add-task-bar-parser.service.spec.ts index 534a695d0e..d2400df9d9 100644 --- a/src/app/features/tasks/add-task-bar/add-task-bar-parser.service.spec.ts +++ b/src/app/features/tasks/add-task-bar/add-task-bar-parser.service.spec.ts @@ -16,6 +16,7 @@ describe('AddTaskBarParserService', () => { 'updateProjectId', 'updateTagIds', 'updateNewTagTitles', + 'updateSpent', 'updateEstimate', 'updateDate', 'isAutoDetected', @@ -88,6 +89,7 @@ describe('AddTaskBarParserService', () => { // Reset all spy calls before each test mockStateService.updateCleanText.calls.reset(); mockStateService.updateDate.calls.reset(); + mockStateService.updateSpent.calls.reset(); mockStateService.updateEstimate.calls.reset(); mockStateService.updateTagIds.calls.reset(); mockStateService.updateNewTagTitles.calls.reset(); @@ -117,6 +119,7 @@ describe('AddTaskBarParserService', () => { newTagTitles: [], date: null, time: null, + spent: null, estimate: null, cleanText: null, }; @@ -150,6 +153,7 @@ describe('AddTaskBarParserService', () => { newTagTitles: [], date: currentDate, time: currentTime, + spent: null, estimate: null, cleanText: null, }; @@ -180,6 +184,7 @@ describe('AddTaskBarParserService', () => { newTagTitles: [], date: currentDate, time: null, + spent: null, estimate: null, cleanText: null, }; @@ -276,6 +281,30 @@ describe('AddTaskBarParserService', () => { expect(mockStateService.updateEstimate).toHaveBeenCalledWith(null); }); + + it('should call updateSpent when parsing text', () => { + service.parseAndUpdateText( + 'Task with potential time spent', + mockConfig, + mockProjects, + mockTags, + mockDefaultProject, + ); + + expect(mockStateService.updateSpent).toHaveBeenCalled(); + }); + + it('should handle null time spent', () => { + service.parseAndUpdateText( + 'Simple task', + mockConfig, + mockProjects, + mockTags, + mockDefaultProject, + ); + + expect(mockStateService.updateSpent).toHaveBeenCalledWith(null); + }); }); describe('Basic Parsing Tests', () => { @@ -439,6 +468,9 @@ describe('AddTaskBarParserService', () => { { input: 'Task t1.5h', expected: 'Task' }, { input: 'Task 45d', expected: 'Task' }, { input: 'Task t30m other text', expected: 'Task other text' }, + { input: 'Task 1h/', expected: 'Task' }, + { input: 'Task 1h/ between', expected: 'Task between' }, + { input: '1h/ Task', expected: 'Task' }, ]; testCases.forEach(({ input, expected }) => { @@ -552,6 +584,7 @@ describe('AddTaskBarParserService', () => { newTagTitles: [], date: dateStr, time: timeStr, + spent: null, estimate: null, cleanText: null, }; @@ -582,6 +615,7 @@ describe('AddTaskBarParserService', () => { newTagTitles: [], date: null, time: null, + spent: null, estimate: null, cleanText: null, }; @@ -614,6 +648,7 @@ describe('AddTaskBarParserService', () => { newTagTitles: [], date: null, time: null, + spent: null, estimate: null, cleanText: null, }; @@ -644,6 +679,7 @@ describe('AddTaskBarParserService', () => { newTagTitles: [], date: null, time: null, + spent: null, estimate: null, cleanText: null, }; @@ -673,6 +709,7 @@ describe('AddTaskBarParserService', () => { newTagTitles: [], date: null, time: null, + spent: null, estimate: null, cleanText: null, }; diff --git a/src/app/features/tasks/add-task-bar/add-task-bar-parser.service.ts b/src/app/features/tasks/add-task-bar/add-task-bar-parser.service.ts index 4ce05f3fc8..4b7c09ef6c 100644 --- a/src/app/features/tasks/add-task-bar/add-task-bar-parser.service.ts +++ b/src/app/features/tasks/add-task-bar/add-task-bar-parser.service.ts @@ -2,15 +2,17 @@ import { inject, Injectable } from '@angular/core'; import { Project } from '../../project/project.model'; import { Tag } from '../../tag/tag.model'; import { AddTaskBarStateService } from './add-task-bar-state.service'; -import { shortSyntax } from '../short-syntax'; +import { SHORT_SYNTAX_TIME_REG_EX, shortSyntax } from '../short-syntax'; import { ShortSyntaxConfig } from '../../config/global-config.model'; import { getDbDateStr } from '../../../util/get-db-date-str'; +import { TimeSpentOnDay } from '../task.model'; interface PreviousParseResult { cleanText: string | null; projectId: string | null; tagIds: string[]; newTagTitles: string[]; + timeSpentOnDay: TimeSpentOnDay | null; timeEstimate: number | null; dueDate: string | null; dueTime: string | null; @@ -68,6 +70,7 @@ export class AddTaskBarParserService { : null, tagIds: currentState.tagIds, // Preserve pre-selected tags newTagTitles: [], + timeSpentOnDay: null, timeEstimate: null, // Preserve current date/time if user has selected them, otherwise use defaults dueDate: currentState.date || (defaultDate ? defaultDate : null), @@ -104,6 +107,7 @@ export class AddTaskBarParserService { projectId: parseResult.projectId || null, tagIds: tagIds, newTagTitles: newTagTitles, + timeSpentOnDay: parseResult.taskChanges.timeSpentOnDay || null, timeEstimate: parseResult.taskChanges.timeEstimate || null, dueDate: dueDate, dueTime: dueTime, @@ -151,6 +155,25 @@ export class AddTaskBarParserService { this._stateService.updateNewTagTitles(currentResult.newTagTitles); } + const prevTimeSpentOnDay = this._previousParseResult?.timeSpentOnDay || null; + const currTimeSpentOnDay = currentResult.timeSpentOnDay; + + if ( + !this._previousParseResult || + // Check for field existence change + (prevTimeSpentOnDay === null) !== (currTimeSpentOnDay === null) || + // Check for any discrepancy between all recorded time spent + (prevTimeSpentOnDay !== null && + currTimeSpentOnDay !== null && + (Object.keys(prevTimeSpentOnDay).length !== + Object.keys(currTimeSpentOnDay).length || + Object.keys(prevTimeSpentOnDay).some( + (k) => prevTimeSpentOnDay[k] !== currTimeSpentOnDay[k], + ))) + ) { + this._stateService.updateSpent(currentResult.timeSpentOnDay); + } + if ( !this._previousParseResult || this._previousParseResult.timeEstimate !== currentResult.timeEstimate @@ -203,9 +226,8 @@ export class AddTaskBarParserService { case 'estimate': // Remove estimate syntax (e.g., t30m, 1h, 30m/1h, t1.5h) - // This matches the SHORT_SYNTAX_TIME_REG_EX from short-syntax.ts cleanedInput = cleanedInput.replace( - /(?:\s|^)t?((\d+(?:\.\d+)?[mhd])(?:\s*\/\s*(\d+(?:\.\d+)?[mhd]))?)/gi, + new RegExp(SHORT_SYNTAX_TIME_REG_EX.source, 'gi'), ' ', ); break; diff --git a/src/app/features/tasks/add-task-bar/add-task-bar-state.service.ts b/src/app/features/tasks/add-task-bar/add-task-bar-state.service.ts index 34a40274d4..d780da5fbb 100644 --- a/src/app/features/tasks/add-task-bar/add-task-bar-state.service.ts +++ b/src/app/features/tasks/add-task-bar/add-task-bar-state.service.ts @@ -3,6 +3,7 @@ import { Tag } from '../../tag/tag.model'; import { AddTaskBarState, INITIAL_ADD_TASK_BAR_STATE } from './add-task-bar.const'; import { toObservable } from '@angular/core/rxjs-interop'; import { SS } from '../../../core/persistence/storage-keys.const'; +import { TimeSpentOnDay } from '../task.model'; @Injectable() export class AddTaskBarStateService { @@ -39,6 +40,10 @@ export class AddTaskBarStateService { this._taskInputState.update((state) => ({ ...state, time })); } + updateSpent(spent: TimeSpentOnDay | null): void { + this._taskInputState.update((state) => ({ ...state, spent })); + } + updateEstimate(estimate: number | null): void { this._taskInputState.update((state) => ({ ...state, estimate })); } diff --git a/src/app/features/tasks/add-task-bar/add-task-bar.component.ts b/src/app/features/tasks/add-task-bar/add-task-bar.component.ts index c169153b3f..a574096d7d 100644 --- a/src/app/features/tasks/add-task-bar/add-task-bar.component.ts +++ b/src/app/features/tasks/add-task-bar/add-task-bar.component.ts @@ -433,6 +433,10 @@ export class AddTaskBarComponent implements AfterViewInit, OnInit, OnDestroy { timeEstimate: state.estimate || 0, }; + if (state.spent) { + taskData.timeSpentOnDay = state.spent; + } + if (state.date) { // Parse date components to create date in local timezone // This avoids timezone issues when parsing date strings like "2024-01-15" diff --git a/src/app/features/tasks/add-task-bar/add-task-bar.const.ts b/src/app/features/tasks/add-task-bar/add-task-bar.const.ts index 47b5357ee6..00d072401a 100644 --- a/src/app/features/tasks/add-task-bar/add-task-bar.const.ts +++ b/src/app/features/tasks/add-task-bar/add-task-bar.const.ts @@ -1,10 +1,12 @@ import { INBOX_PROJECT } from '../../project/project.const'; +import { TimeSpentOnDay } from '../task.model'; export interface AddTaskBarState { projectId: string; tagIds: string[]; date: string | null; time: string | null; + spent: TimeSpentOnDay | null; estimate: number | null; newTagTitles: string[]; cleanText: string | null; @@ -26,6 +28,7 @@ export const INITIAL_ADD_TASK_BAR_STATE: AddTaskBarState = { tagIds: [], date: null, time: null, + spent: null, estimate: null, newTagTitles: [], cleanText: null, diff --git a/src/app/features/tasks/short-syntax.spec.ts b/src/app/features/tasks/short-syntax.spec.ts index 1d906d765f..46179581b6 100644 --- a/src/app/features/tasks/short-syntax.spec.ts +++ b/src/app/features/tasks/short-syntax.spec.ts @@ -242,6 +242,25 @@ describe('shortSyntax', () => { const r = shortSyntax(t, { ...CONFIG, isEnableDue: false }); expect(r).toEqual(undefined); }); + + it('with time spent only', () => { + const t = { + ...TASK, + title: 'Task description 30m/', + }; + const r = shortSyntax(t, CONFIG); + expect(r).toEqual({ + newTagTitles: [], + remindAt: null, + projectId: undefined, + taskChanges: { + title: 'Task description', + timeSpentOnDay: { + [getDbDateStr()]: 1800000, + }, + }, + }); + }); }); describe('should recognize short syntax for date', () => { diff --git a/src/app/features/tasks/short-syntax.ts b/src/app/features/tasks/short-syntax.ts index 5d2db2908d..84d0a8215e 100644 --- a/src/app/features/tasks/short-syntax.ts +++ b/src/app/features/tasks/short-syntax.ts @@ -18,9 +18,13 @@ type DueChanges = { dueWithTime?: number; }; -const SHORT_SYNTAX_TIME_REG_EX = - /(?:\s|^)t?((\d+(?:\.\d+)?[mhd])(?:\s*\/\s*(\d+(?:\.\d+)?[mhd]))?(?=\s|$))/i; -// NOTE: should come after the time reg ex is executed so we don't have to deal with those strings too +const CH_TSP = '/'; +export const SHORT_SYNTAX_TIME_REG_EX = new RegExp( + String.raw`(?:\s|^)t?(\d+(?:\.\d+)?[mhd])(?:\s*` + + `\\${CH_TSP}` + + String.raw`(:?\s*(\d+(?:\.\d+)?[mhd]))?)?(?=\s|$)`, + 'i', +); const CH_PRO = '+'; const CH_TAG = '#'; @@ -107,11 +111,10 @@ export const shortSyntax = ( let changesForTag: TagChanges = {}; if (config.isEnableDue) { - // NOTE: we do this twice... :-O ...it's weird, but required to make whitespaces work as separator and not as one taskChanges = parseTimeSpentChanges(task); taskChanges = { ...taskChanges, - ...parseScheduledDate(task, now), + ...parseScheduledDate({ ...task, title: taskChanges.title || task.title }, now), }; } @@ -139,15 +142,6 @@ export const shortSyntax = ( }; } - if (config.isEnableDue) { - taskChanges = { - ...taskChanges, - // NOTE: because we pass the new taskChanges here we need to assignments... - ...parseTimeSpentChanges(taskChanges), - // title: taskChanges.title?.trim(), - }; - } - // const changesForDue = parseDueChanges({...task, title: taskChanges.title || task.title}); // if (changesForDue.remindAt) { // taskChanges = { @@ -389,28 +383,24 @@ const parseTimeSpentChanges = (task: Partial): Partial => { } const matches = SHORT_SYNTAX_TIME_REG_EX.exec(task.title); - if (matches && matches.length >= 3) { - const full = matches[0]; - const timeSpent = matches[2]; // First part (before slash) - const timeEstimate = matches[3]; // Second part (after slash) - // If no slash, use the single value as timeEstimate only - const hasSlashFormat = matches[3] !== undefined; - return { - ...(hasSlashFormat && timeSpent - ? { - timeSpentOnDay: { - ...(task.timeSpentOnDay || {}), - [getDbDateStr()]: stringToMs(timeSpent), - }, - } - : {}), - ...(timeEstimate - ? { timeEstimate: stringToMs(timeEstimate) } - : timeSpent - ? { timeEstimate: stringToMs(timeSpent) } - : {}), - title: task.title.replace(full, '').trim(), - }; + if (!matches) { + return {}; } - return {}; + + const [matchSpan, preSplit, postSplit] = matches; + const timeSpent = matchSpan.includes(CH_TSP) ? preSplit : null; + const timeEstimate = timeSpent === null ? preSplit : postSplit; + + return { + ...(typeof timeSpent === 'string' && { + timeSpentOnDay: { + ...task.timeSpentOnDay, + [getDbDateStr()]: stringToMs(timeSpent), + }, + }), + ...(typeof timeEstimate === 'string' && { + timeEstimate: stringToMs(timeEstimate), + }), + title: task.title.replace(matchSpan, '').trim(), + }; };