feat(addTaskBar): add time spent short syntax for new tasks (#5269)

This commit is contained in:
Michael Huynh 2025-10-29 19:31:18 +08:00
parent 2762c60319
commit deae7b1065
No known key found for this signature in database
GPG key ID: 760127DAE4EDD351
7 changed files with 120 additions and 40 deletions

View file

@ -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,
};

View file

@ -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;

View file

@ -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 }));
}

View file

@ -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"

View file

@ -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,

View file

@ -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', () => {

View file

@ -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<TaskCopy>): Partial<Task> => {
}
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(),
};
};