diff --git a/src/components/TaskContextMenu.ts b/src/components/TaskContextMenu.ts index 4225f803..17241192 100644 --- a/src/components/TaskContextMenu.ts +++ b/src/components/TaskContextMenu.ts @@ -12,6 +12,7 @@ import { import { renameVaultFile } from "../services/VaultMutationService"; import { showConfirmationModal } from "../modals/ConfirmationModal"; import { DateContextMenu } from "./DateContextMenu"; +import { DateTimePickerModal } from "../modals/DateTimePickerModal"; import { buildWeekdaysOnlyRecurrenceRule, getPluginCalendarLocale, @@ -44,6 +45,7 @@ import { openOrCreateOccurrenceNote, } from "../ui/occurrenceNoteActions"; import { createTaskNotesLogger } from "../utils/tasknotesLogger"; +import type { UserMappedField } from "../types/settings"; const tasknotesLogger = createTaskNotesLogger({ tag: "Components/TaskContextMenu" }); @@ -266,6 +268,8 @@ export class TaskContextMenu { ); }); + this.addCustomDateFieldMenuItems(task, plugin); + if (task.recurrence) { this.addRecurringInstanceMenuItems(task, plugin); } @@ -1757,7 +1761,8 @@ export class TaskContextMenu { submenu: Menu, currentValue: string | undefined, onSelect: (value: string | null) => Promise, - onCustomDate: () => void + onCustomDate: () => void, + options: { pickDateTitle?: string } = {} ): void { const dateContextMenu = new DateContextMenu({ currentValue: currentValue, @@ -1826,7 +1831,7 @@ export class TaskContextMenu { submenu.addSeparator(); submenu.addItem((item) => { - item.setTitle(this.t("contextMenus.date.pickDateTime")); + item.setTitle(options.pickDateTitle ?? this.t("contextMenus.date.pickDateTime")); item.setIcon("calendar"); item.onClick(onCustomDate); }); @@ -1842,6 +1847,113 @@ export class TaskContextMenu { } } + private addCustomDateFieldMenuItems(task: TaskInfo, plugin: TaskNotesPlugin): void { + const dateFields = this.getCustomDateFields(plugin); + if (dateFields.length === 0) { + return; + } + + this.menu.addItem((item) => { + item.setTitle(this.t("contextMenus.task.customDates")); + item.setIcon("calendar-days"); + + const submenu = getSubmenu(item); + dateFields.forEach((field) => { + submenu.addItem((fieldItem) => { + const fieldLabel = this.getCustomFieldLabel(field); + fieldItem.setTitle(fieldLabel); + fieldItem.setIcon("calendar"); + + const fieldSubmenu = getSubmenu(fieldItem); + const currentValue = this.getCustomDateFieldValue(task, field); + this.addDateOptions( + fieldSubmenu, + currentValue, + async (value) => { + await this.updateCustomDateField(task, plugin, field, value); + }, + () => { + this.openCustomDateFieldPicker(task, plugin, field, currentValue); + }, + { + pickDateTitle: this.t("modals.task.userFields.pickDate", { + field: fieldLabel, + }), + } + ); + }); + }); + }); + } + + private getCustomDateFields(plugin: TaskNotesPlugin): UserMappedField[] { + return (plugin.settings.userFields || []).filter( + (field) => field.type === "date" && field.key.trim().length > 0 + ); + } + + private getCustomFieldLabel(field: UserMappedField): string { + return field.displayName.trim() || field.key || field.id; + } + + private getCustomDateFieldValue(task: TaskInfo, field: UserMappedField): string | undefined { + const taskRecord = task as unknown as Record; + const value = taskRecord[field.key] ?? task.customProperties?.[field.key]; + return typeof value === "string" && value.trim().length > 0 ? value : undefined; + } + + private openCustomDateFieldPicker( + task: TaskInfo, + plugin: TaskNotesPlugin, + field: UserMappedField, + currentValue: string | undefined + ): void { + this.menu.hide(); + const fieldLabel = this.getCustomFieldLabel(field); + const modal = new DateTimePickerModal(plugin.app, { + currentDate: currentValue || null, + title: this.t("modals.task.userFields.pickDate", { field: fieldLabel }), + showTime: false, + plugin, + onSelect: (date) => { + void this.updateCustomDateField(task, plugin, field, date); + }, + }); + modal.open(); + } + + private async updateCustomDateField( + task: TaskInfo, + plugin: TaskNotesPlugin, + field: UserMappedField, + value: string | null + ): Promise { + const fieldLabel = this.getCustomFieldLabel(field); + try { + const updatedTask = await plugin.updateTaskProperty( + task, + field.key as keyof TaskInfo, + value || undefined + ); + Object.assign(task, updatedTask); + this.options.onUpdate?.(); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + tasknotesLogger.error("Error updating custom date field:", { + category: "persistence", + operation: "updating-custom-date-field", + details: { taskPath: task.path, field: field.key }, + error: errorMessage, + }); + new Notice( + this.t("contextMenus.task.notices.updateCustomDateFailure", { + field: fieldLabel, + message: errorMessage, + }) + ); + } + } + private addRecurrenceOptions( submenu: Menu, currentValue: string | undefined, diff --git a/src/i18n/resources/de.ts b/src/i18n/resources/de.ts index 35e80202..46a27f84 100644 --- a/src/i18n/resources/de.ts +++ b/src/i18n/resources/de.ts @@ -2594,6 +2594,8 @@ export const de: TranslationTree = { }, metadata: { totalTrackedTime: "Gesamte erfasste Zeit:", + due: "Fällig:", + scheduled: "Geplant:", created: "Erstellt:", modified: "Geändert:", file: "Datei:" @@ -2771,6 +2773,7 @@ export const de: TranslationTree = { prioritySelected: "✓ {label}", dueDate: "Fälligkeitsdatum", scheduledDate: "Planungsdatum", + customDates: "Benutzerdefinierte Datumsfelder", reminders: "Erinnerungen", remindBeforeDue: "Vor Fälligkeit erinnern…", remindBeforeScheduled: "Vor Planung erinnern…", @@ -2878,6 +2881,7 @@ export const de: TranslationTree = { toggleSkipFailure: "Überspringen der wiederkehrenden Aufgabe konnte nicht umgeschaltet werden: {message}", updateDueDateFailure: "Aufgaben-Fälligkeitsdatum konnte nicht aktualisiert werden: {message}", updateScheduledFailure: "Aufgaben-Planungsdatum konnte nicht aktualisiert werden: {message}", + updateCustomDateFailure: "Fehler beim Aktualisieren von {field}: {message}", updateRemindersFailure: "Erinnerungen konnten nicht aktualisiert werden", clearRemindersFailure: "Erinnerungen konnten nicht gelöscht werden", addReminderFailure: "Erinnerung konnte nicht hinzugefügt werden", diff --git a/src/i18n/resources/en.ts b/src/i18n/resources/en.ts index b7d47661..0926250b 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -2753,6 +2753,8 @@ export const en: TranslationTree = { }, metadata: { totalTrackedTime: "Total tracked time:", + due: "Due:", + scheduled: "Scheduled:", created: "Created:", modified: "Modified:", file: "File:", @@ -2940,6 +2942,7 @@ export const en: TranslationTree = { clearTags: "Clear tags", dueDate: "Due date", scheduledDate: "Scheduled date", + customDates: "Custom dates", reminders: "Reminders", remindBeforeDue: "Remind before due…", remindBeforeScheduled: "Remind before scheduled…", @@ -3047,6 +3050,7 @@ export const en: TranslationTree = { toggleSkipFailure: "Failed to toggle recurring task skip: {message}", updateDueDateFailure: "Failed to update task due date: {message}", updateScheduledFailure: "Failed to update task scheduled date: {message}", + updateCustomDateFailure: "Failed to update {field}: {message}", updateRemindersFailure: "Failed to update reminders", clearRemindersFailure: "Failed to clear reminders", addReminderFailure: "Failed to add reminder", diff --git a/src/i18n/resources/es.ts b/src/i18n/resources/es.ts index b80067d4..07d2caea 100644 --- a/src/i18n/resources/es.ts +++ b/src/i18n/resources/es.ts @@ -2594,6 +2594,8 @@ export const es: TranslationTree = { }, metadata: { totalTrackedTime: "Tiempo total rastreado:", + due: "Vencimiento:", + scheduled: "Programada:", created: "Creado:", modified: "Modificado:", file: "Archivo:" @@ -2771,6 +2773,7 @@ export const es: TranslationTree = { prioritySelected: "✓ {label}", dueDate: "Fecha de vencimiento", scheduledDate: "Fecha programada", + customDates: "Fechas personalizadas", reminders: "Recordatorios", remindBeforeDue: "Recordar antes del vencimiento…", remindBeforeScheduled: "Recordar antes de programado…", @@ -2878,6 +2881,7 @@ export const es: TranslationTree = { toggleSkipFailure: "Error al alternar omisión de tarea recurrente: {message}", updateDueDateFailure: "Error al actualizar fecha de vencimiento de tarea: {message}", updateScheduledFailure: "Error al actualizar fecha programada de tarea: {message}", + updateCustomDateFailure: "No se pudo actualizar {field}: {message}", updateRemindersFailure: "Error al actualizar recordatorios", clearRemindersFailure: "Error al limpiar recordatorios", addReminderFailure: "Error al agregar recordatorio", diff --git a/src/i18n/resources/fr.ts b/src/i18n/resources/fr.ts index d699a07a..9d16e265 100644 --- a/src/i18n/resources/fr.ts +++ b/src/i18n/resources/fr.ts @@ -2594,6 +2594,8 @@ export const fr: TranslationTree = { }, metadata: { totalTrackedTime: "Temps suivi total :", + due: "Échéance :", + scheduled: "Planifiée :", created: "Créée :", modified: "Modifiée :", file: "Fichier :" @@ -2771,6 +2773,7 @@ export const fr: TranslationTree = { prioritySelected: "Priorité sélectionnée : {label}", dueDate: "Échéance", scheduledDate: "Date planifiée", + customDates: "Dates personnalisées", reminders: "Rappels", remindBeforeDue: "Rappeler avant l'échéance…", remindBeforeScheduled: "Rappeler avant la date planifiée…", @@ -2878,6 +2881,7 @@ export const fr: TranslationTree = { toggleSkipFailure: "Impossible de modifier l'omission de tâche récurrente : {message}", updateDueDateFailure: "Impossible de mettre à jour l'échéance : {message}", updateScheduledFailure: "Impossible de mettre à jour la date planifiée : {message}", + updateCustomDateFailure: "Échec de la mise à jour de {field} : {message}", updateRemindersFailure: "Impossible de mettre à jour les rappels", clearRemindersFailure: "Impossible de supprimer les rappels", addReminderFailure: "Impossible d'ajouter un rappel", diff --git a/src/i18n/resources/ja.ts b/src/i18n/resources/ja.ts index c0db19be..1e3f4df6 100644 --- a/src/i18n/resources/ja.ts +++ b/src/i18n/resources/ja.ts @@ -2594,6 +2594,8 @@ export const ja: TranslationTree = { }, metadata: { totalTrackedTime: "総追跡時間:", + due: "期限:", + scheduled: "予定:", created: "作成:", modified: "変更:", file: "ファイル:" @@ -2771,6 +2773,7 @@ export const ja: TranslationTree = { prioritySelected: "✓ {label}", dueDate: "期限日", scheduledDate: "予定日", + customDates: "カスタム日付", reminders: "リマインダー", remindBeforeDue: "期限前にリマインド…", remindBeforeScheduled: "予定前にリマインド…", @@ -2878,6 +2881,7 @@ export const ja: TranslationTree = { toggleSkipFailure: "繰り返しタスクのスキップ切り替えに失敗しました:{message}", updateDueDateFailure: "タスク期限日の更新に失敗しました:{message}", updateScheduledFailure: "タスク予定日の更新に失敗しました:{message}", + updateCustomDateFailure: "{field}の更新に失敗しました:{message}", updateRemindersFailure: "リマインダーの更新に失敗しました", clearRemindersFailure: "リマインダーのクリアに失敗しました", addReminderFailure: "リマインダーの追加に失敗しました", diff --git a/src/i18n/resources/ko.ts b/src/i18n/resources/ko.ts index c7129ff9..a4ef4f8d 100644 --- a/src/i18n/resources/ko.ts +++ b/src/i18n/resources/ko.ts @@ -2755,6 +2755,7 @@ export const ko: TranslationTree = { prioritySelected: "✓ {label}", dueDate: "마감일", scheduledDate: "예정일", + customDates: "사용자 지정 날짜", reminders: "리마인더", remindBeforeDue: "마감 전 알림...", remindBeforeScheduled: "예정 전 알림...", @@ -2862,6 +2863,7 @@ export const ko: TranslationTree = { toggleSkipFailure: "반복 작업 건너뛰기 토글 실패: {message}", updateDueDateFailure: "작업 마감일 업데이트 실패: {message}", updateScheduledFailure: "작업 예정일 업데이트 실패: {message}", + updateCustomDateFailure: "{field} 업데이트 실패: {message}", updateRemindersFailure: "리마인더 업데이트 실패", clearRemindersFailure: "리마인더 지우기 실패", addReminderFailure: "리마인더 추가 실패", diff --git a/src/i18n/resources/pt.ts b/src/i18n/resources/pt.ts index da289167..7d377b4c 100644 --- a/src/i18n/resources/pt.ts +++ b/src/i18n/resources/pt.ts @@ -2596,6 +2596,8 @@ export const pt: TranslationTree = { }, metadata: { totalTrackedTime: "Tempo total registrado:", + due: "Vencimento:", + scheduled: "Agendada:", created: "Criada:", modified: "Modificada:", file: "Arquivo:" @@ -2773,6 +2775,7 @@ export const pt: TranslationTree = { prioritySelected: "✓ {label}", dueDate: "Data de vencimento", scheduledDate: "Data agendada", + customDates: "Datas personalizadas", reminders: "Lembretes", remindBeforeDue: "Lembrar antes do vencimento…", remindBeforeScheduled: "Lembrar antes da data agendada…", @@ -2880,6 +2883,7 @@ export const pt: TranslationTree = { toggleSkipFailure: "Falha ao alternar pulo de tarefa recorrente: {message}", updateDueDateFailure: "Falha ao atualizar data de vencimento da tarefa: {message}", updateScheduledFailure: "Falha ao atualizar data agendada da tarefa: {message}", + updateCustomDateFailure: "Falha ao atualizar {field}: {message}", updateRemindersFailure: "Falha ao atualizar lembretes", clearRemindersFailure: "Falha ao limpar lembretes", addReminderFailure: "Falha ao adicionar lembrete", diff --git a/src/i18n/resources/ru.ts b/src/i18n/resources/ru.ts index 41611077..d0f75346 100644 --- a/src/i18n/resources/ru.ts +++ b/src/i18n/resources/ru.ts @@ -2594,6 +2594,8 @@ export const ru: TranslationTree = { }, metadata: { totalTrackedTime: "Общее отслеженное время:", + due: "Срок:", + scheduled: "Запланировано:", created: "Создано:", modified: "Изменено:", file: "Файл:" @@ -2771,6 +2773,7 @@ export const ru: TranslationTree = { prioritySelected: "✓ {label}", dueDate: "Срок выполнения", scheduledDate: "Запланированная дата", + customDates: "Пользовательские даты", reminders: "Напоминания", remindBeforeDue: "Напомнить до срока выполнения…", remindBeforeScheduled: "Напомнить до запланированной даты…", @@ -2878,6 +2881,7 @@ export const ru: TranslationTree = { toggleSkipFailure: "Не удалось переключить пропуск повторяющейся задачи: {message}", updateDueDateFailure: "Не удалось обновить срок выполнения задачи: {message}", updateScheduledFailure: "Не удалось обновить запланированную дату задачи: {message}", + updateCustomDateFailure: "Не удалось обновить {field}: {message}", updateRemindersFailure: "Не удалось обновить напоминания", clearRemindersFailure: "Не удалось очистить напоминания", addReminderFailure: "Не удалось добавить напоминание", diff --git a/src/i18n/resources/zh.ts b/src/i18n/resources/zh.ts index a12b34f1..0665e8e0 100644 --- a/src/i18n/resources/zh.ts +++ b/src/i18n/resources/zh.ts @@ -2594,6 +2594,8 @@ export const zh: TranslationTree = { }, metadata: { totalTrackedTime: "总跟踪时间:", + due: "截止:", + scheduled: "计划:", created: "创建:", modified: "修改:", file: "文件:" @@ -2770,6 +2772,7 @@ export const zh: TranslationTree = { prioritySelected: "✓ {label}", dueDate: "到期日期", scheduledDate: "安排日期", + customDates: "自定义日期", reminders: "提醒", remindBeforeDue: "到期前提醒...", remindBeforeScheduled: "安排前提醒...", @@ -2877,6 +2880,7 @@ export const zh: TranslationTree = { toggleSkipFailure: "切换重复任务跳过失败:{message}", updateDueDateFailure: "更新任务到期日期失败:{message}", updateScheduledFailure: "更新任务安排日期失败:{message}", + updateCustomDateFailure: "更新 {field} 失败:{message}", updateRemindersFailure: "更新提醒失败", clearRemindersFailure: "清除提醒失败", addReminderFailure: "添加提醒失败", diff --git a/src/modals/DateTimePickerModal.ts b/src/modals/DateTimePickerModal.ts index 3a45ca5f..cea524b8 100644 --- a/src/modals/DateTimePickerModal.ts +++ b/src/modals/DateTimePickerModal.ts @@ -11,6 +11,7 @@ export interface DateTimePickerOptions { currentTime?: string | null; title?: string; dateRole?: "due" | "scheduled"; + showTime?: boolean; plugin?: TaskNotesPlugin; naturalLanguageParser?: NaturalLanguageDateParser; onSelect: (date: string | null, time: string | null) => void; @@ -114,7 +115,9 @@ export class DateTimePickerModal extends Modal { this.renderQuickActions(contentEl); this.renderNaturalLanguageInput(contentEl); this.renderDateInput(contentEl); - this.renderTimeInput(contentEl); + if (this.shouldShowTime()) { + this.renderTimeInput(contentEl); + } this.renderActions(contentEl); this.updateSelectButtonState(); @@ -300,7 +303,10 @@ export class DateTimePickerModal extends Modal { private confirmSelectedDate(): void { if (!this.selectedDate) return; - this.options.onSelect(this.selectedDate, this.timeInput?.value || null); + this.options.onSelect( + this.selectedDate, + this.shouldShowTime() ? this.timeInput?.value || null : null + ); this.close(); } @@ -314,6 +320,10 @@ export class DateTimePickerModal extends Modal { return Boolean(this.options.plugin?.settings.enableNaturalLanguageInput); } + private shouldShowTime(): boolean { + return this.options.showTime !== false; + } + private getNaturalLanguageParser(): NaturalLanguageDateParser | null { if (this.options.naturalLanguageParser) return this.options.naturalLanguageParser; if (!this.options.plugin?.settings.enableNaturalLanguageInput) return null; @@ -332,7 +342,7 @@ export class DateTimePickerModal extends Modal { return; } - this.options.onSelect(selection.date, selection.time); + this.options.onSelect(selection.date, this.shouldShowTime() ? selection.time : null); this.close(); } } diff --git a/src/modals/taskModalUserFieldControls.ts b/src/modals/taskModalUserFieldControls.ts index f5fce2f8..bd6a0c91 100644 --- a/src/modals/taskModalUserFieldControls.ts +++ b/src/modals/taskModalUserFieldControls.ts @@ -1,6 +1,6 @@ import { Setting, setIcon, type App, type TextComponent } from "obsidian"; -import { DateContextMenu } from "../components/DateContextMenu"; import type TaskNotesPlugin from "../main"; +import { DateTimePickerModal } from "./DateTimePickerModal"; import type { UserMappedField } from "../types/settings"; import { attachDateInputBehavior } from "../ui/dateInputBehavior"; import { stringifyUnknown } from "../utils/stringUtils"; @@ -266,16 +266,19 @@ function createTaskModalDateUserField( setIcon(button, "calendar"); button.addEventListener("click", (event) => { event.preventDefault(); - const menu = new DateContextMenu({ - currentValue: getTextValue(text) || undefined, + const picker = new DateTimePickerModal(context.app, { + currentDate: getTextValue(text) || null, + title: context.translate("modals.task.userFields.pickDate", { + field: field.displayName, + }), + showTime: false, + plugin: context.plugin, onSelect: (value) => { text.setValue(value || ""); options.onValueChange(field.key, parseNullableTextUserFieldInput(value)); }, - plugin: context.plugin, - app: context.app, }); - menu.showAtElement(button); + picker.open(); }); } }); diff --git a/src/services/NaturalLanguageParser.ts b/src/services/NaturalLanguageParser.ts index f6be5da3..59bd9669 100644 --- a/src/services/NaturalLanguageParser.ts +++ b/src/services/NaturalLanguageParser.ts @@ -230,22 +230,32 @@ export class NaturalLanguageParser extends NaturalLanguageParserCore { const userFields = parsed.userFields as Record; for (const userField of this.taskNotesUserFields) { - if (userField.type !== "boolean") continue; - const value = userFields[userField.id]; if (typeof value !== "string") continue; - const normalized = value.trim().toLowerCase(); - if (normalized === "true") { - userFields[userField.id] = true; - } else if (normalized === "false") { - userFields[userField.id] = false; + if (userField.type === "boolean") { + const normalized = value.trim().toLowerCase(); + if (normalized === "true") { + userFields[userField.id] = true; + } else if (normalized === "false") { + userFields[userField.id] = false; + } + } else if (userField.type === "date") { + userFields[userField.id] = this.normalizeDateUserFieldValue(value) ?? value; } } return parsed; } + private normalizeDateUserFieldValue(value: string): string | null { + const trimmed = value.trim(); + if (!trimmed) return null; + + const parsed = super.parseInput(trimmed); + return parsed.scheduledDate || parsed.dueDate || null; + } + private extractLinkedUserFields(input: string, parsed: ParsedTaskData): ParsedTaskData { const triggers = this.taskNotesNlpTriggers?.triggers || []; if (triggers.length === 0 || this.taskNotesUserFields.length === 0) { diff --git a/tests/unit/issues/issue-1462-date-picker-nlp.test.ts b/tests/unit/issues/issue-1462-date-picker-nlp.test.ts index 46cb1fee..da5986ae 100644 --- a/tests/unit/issues/issue-1462-date-picker-nlp.test.ts +++ b/tests/unit/issues/issue-1462-date-picker-nlp.test.ts @@ -100,4 +100,35 @@ describe("Issue #1462: NLP date edits", () => { expect(onSelect).toHaveBeenCalledWith("2026-01-23", "08:00"); }); + + it("can run as a date-only picker for custom date fields", () => { + const onSelect = jest.fn(); + const parser = { + parseInput: jest.fn(() => + parsedTaskData({ + scheduledDate: "2026-01-24", + scheduledTime: "09:30", + }) + ), + }; + const modal = new DateTimePickerModal({} as any, { + showTime: false, + naturalLanguageParser: parser, + onSelect, + }); + + modal.open(); + + expect(modal.contentEl.querySelector(".date-time-picker-modal__time-field")).toBeNull(); + + const input = modal.contentEl.querySelector( + ".date-time-picker-modal__nlp-input" + ); + expect(input).toBeTruthy(); + + input!.value = "next Saturday at 9:30am"; + input!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + + expect(onSelect).toHaveBeenCalledWith("2026-01-24", null); + }); }); diff --git a/tests/unit/issues/issue-2058-nlp-date-user-fields.test.ts b/tests/unit/issues/issue-2058-nlp-date-user-fields.test.ts new file mode 100644 index 00000000..c0e8466b --- /dev/null +++ b/tests/unit/issues/issue-2058-nlp-date-user-fields.test.ts @@ -0,0 +1,62 @@ +import type TaskNotesPlugin from "../../../src/main"; +import { NaturalLanguageParser } from "../../../src/services/NaturalLanguageParser"; +import { buildTaskCreationDataFromParsed } from "../../../src/services/buildTaskCreationDataFromParsed"; +import { DEFAULT_SETTINGS } from "../../../src/settings/defaults"; +import type { NLPTriggersConfig, UserMappedField } from "../../../src/types/settings"; + +const DATE_FIELD: UserMappedField = { + id: "snoozed", + displayName: "Snoozed", + key: "snoozed", + type: "date", +}; + +const NLP_TRIGGERS: NLPTriggersConfig = { + triggers: [{ propertyId: "snoozed", trigger: "s:", enabled: true }], +}; + +function formatDate(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +function tomorrowDate(): string { + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + return formatDate(tomorrow); +} + +function createParser(): NaturalLanguageParser { + return new NaturalLanguageParser([], [], true, "en", NLP_TRIGGERS, [DATE_FIELD]); +} + +function createPlugin(): TaskNotesPlugin { + return { + settings: { + ...DEFAULT_SETTINGS, + userFields: [DATE_FIELD], + }, + } as unknown as TaskNotesPlugin; +} + +describe("Issue #2058: NLP date user fields", () => { + it("parses natural-language values for date-type custom fields", () => { + const parsed = createParser().parseInput("Review invoices s:tomorrow"); + + expect(parsed.title).toBe("Review invoices"); + expect((parsed.userFields as Record).snoozed).toBe(tomorrowDate()); + + const taskData = buildTaskCreationDataFromParsed(createPlugin(), parsed); + + expect(taskData.customFrontmatter).toEqual({ snoozed: tomorrowDate() }); + }); + + it("leaves unrecognized date custom field values untouched", () => { + const parsed = createParser().parseInput("Review invoices s:later"); + + expect(parsed.title).toBe("Review invoices"); + expect(parsed.userFields).toEqual({ snoozed: "later" }); + }); +}); diff --git a/tests/unit/issues/issue-2060-custom-date-context-menu.test.ts b/tests/unit/issues/issue-2060-custom-date-context-menu.test.ts new file mode 100644 index 00000000..1ed7bd0f --- /dev/null +++ b/tests/unit/issues/issue-2060-custom-date-context-menu.test.ts @@ -0,0 +1,195 @@ +import { App, Menu } from "obsidian"; +import { TaskContextMenu } from "../../../src/components/TaskContextMenu"; +import { DateTimePickerModal } from "../../../src/modals/DateTimePickerModal"; +import { createI18nService } from "../../../src/i18n"; +import type TaskNotesPlugin from "../../../src/main"; +import type { TaskInfo } from "../../../src/types"; + +jest.mock("../../../src/modals/DateTimePickerModal", () => ({ + DateTimePickerModal: jest.fn().mockImplementation(() => ({ + open: jest.fn(), + })), +})); + +type MockMenuItem = { + setTitle?: jest.Mock; + setIcon?: jest.Mock; + onClick?: jest.Mock; + submenu?: MockMenu; + type?: string; +}; + +type MockMenu = { + items: MockMenuItem[]; + hide: jest.Mock; +}; + +const menuMock = Menu as unknown as jest.Mock; +const dateTimePickerMock = DateTimePickerModal as unknown as jest.Mock; + +function createTask(overrides: Record = {}): TaskInfo { + return { + id: "Tasks/custom-date-menu.md", + path: "Tasks/custom-date-menu.md", + title: "Custom date menu", + status: "open", + priority: "normal", + archived: false, + tags: [], + contexts: [], + projects: [], + ...overrides, + } as unknown as TaskInfo; +} + +function createPlugin(): TaskNotesPlugin { + const app = new App(); + return { + app, + i18n: createI18nService(), + settings: { + customStatuses: [], + customPriorities: [], + userFields: [ + { + id: "snoozed", + key: "snoozed", + displayName: "Snoozed", + type: "date", + }, + { + id: "energy", + key: "energy", + displayName: "Energy", + type: "number", + }, + ], + calendarViewSettings: { + enableTimeblocking: false, + }, + useFrontmatterMarkdownLinks: true, + }, + statusManager: { + getAllStatuses: jest.fn(() => []), + getNonCompletionStatuses: jest.fn(() => []), + isCompletedStatus: jest.fn(() => false), + }, + priorityManager: { + getAllPriorities: jest.fn(() => []), + getPrioritiesByWeight: jest.fn(() => []), + }, + taskService: { + toggleRecurringTaskSkipped: jest.fn(), + updateBlockingRelationships: jest.fn(), + deleteTask: jest.fn(), + }, + cacheManager: { + getAllContexts: jest.fn(() => []), + getAllTasks: jest.fn(() => []), + getTaskInfo: jest.fn(), + }, + updateTaskProperty: jest.fn(async (task, property, value) => ({ + ...task, + [property]: value, + })), + toggleRecurringTaskComplete: jest.fn(), + getActiveTimeSession: jest.fn(() => null), + stopTimeTracking: jest.fn(), + startTimeTracking: jest.fn(), + openDueDateModal: jest.fn(), + openScheduledDateModal: jest.fn(), + openTimeEntryEditor: jest.fn(), + toggleTaskArchive: jest.fn(), + openTaskEditModal: jest.fn(), + openTaskCreationModal: jest.fn(), + } as unknown as TaskNotesPlugin; +} + +function getTopLevelMenu(): MockMenu { + return menuMock.mock.results[0].value as MockMenu; +} + +function findMenuItemByTitle(menu: MockMenu | undefined, title: string): MockMenuItem | undefined { + return menu?.items.find((item) => + item.setTitle?.mock.calls.some(([value]) => value === title) + ); +} + +function createMenuContext(task = createTask({ snoozed: "2026-06-24" })) { + const plugin = createPlugin(); + const onUpdate = jest.fn(); + + new TaskContextMenu({ + task, + plugin, + targetDate: new Date("2026-06-21T12:00:00"), + onUpdate, + }); + + const customDatesItem = findMenuItemByTitle(getTopLevelMenu(), "Custom dates"); + const snoozedItem = findMenuItemByTitle(customDatesItem?.submenu, "Snoozed"); + + return { + plugin, + onUpdate, + topLevelMenu: getTopLevelMenu(), + customDatesItem, + snoozedItem, + snoozedMenu: snoozedItem?.submenu, + }; +} + +describe("Issue #2060: custom date fields in the task context menu", () => { + beforeEach(() => { + menuMock.mockClear(); + dateTimePickerMock.mockClear(); + }); + + afterEach(() => { + menuMock.mockClear(); + dateTimePickerMock.mockClear(); + }); + + it("adds date-type user fields to a Custom dates submenu", () => { + const { customDatesItem, snoozedItem, snoozedMenu } = createMenuContext(); + + expect(customDatesItem).toBeDefined(); + expect(customDatesItem?.setIcon).toHaveBeenCalledWith("calendar-days"); + expect(snoozedItem).toBeDefined(); + expect(findMenuItemByTitle(customDatesItem?.submenu, "Energy")).toBeUndefined(); + expect(findMenuItemByTitle(snoozedMenu, "Pick Snoozed date")).toBeDefined(); + expect(findMenuItemByTitle(snoozedMenu, "Clear date")).toBeDefined(); + }); + + it("clears custom dates through the configured field key", async () => { + const task = createTask({ snoozed: "2026-06-24" }); + const { plugin, onUpdate, snoozedMenu } = createMenuContext(task); + const clearItem = findMenuItemByTitle(snoozedMenu, "Clear date"); + + clearItem?.onClick?.mock.calls[0][0](); + await Promise.resolve(); + await Promise.resolve(); + + expect(plugin.updateTaskProperty).toHaveBeenCalledWith(task, "snoozed", undefined); + expect(onUpdate).toHaveBeenCalledTimes(1); + }); + + it("opens the date-only picker for custom date fields", () => { + const { plugin, topLevelMenu, snoozedMenu } = createMenuContext(); + const pickItem = findMenuItemByTitle(snoozedMenu, "Pick Snoozed date"); + + pickItem?.onClick?.mock.calls[0][0](); + + expect(topLevelMenu.hide).toHaveBeenCalledTimes(1); + expect(dateTimePickerMock).toHaveBeenCalledWith( + plugin.app, + expect.objectContaining({ + currentDate: "2026-06-24", + title: "Pick Snoozed date", + showTime: false, + plugin, + }) + ); + expect(dateTimePickerMock.mock.results[0].value.open).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/unit/modals/taskModalUserFieldControls.test.ts b/tests/unit/modals/taskModalUserFieldControls.test.ts index bd6895b6..80ff9d0d 100644 --- a/tests/unit/modals/taskModalUserFieldControls.test.ts +++ b/tests/unit/modals/taskModalUserFieldControls.test.ts @@ -12,12 +12,14 @@ const mockToggleControls: Array<{ setValue: (value: boolean) => unknown; }> = []; -const mockDateMenus: Array<{ +const mockDateTimePickers: Array<{ options: { currentValue?: string | null; - onSelect: (value: string | null) => void; + currentDate?: string | null; + showTime?: boolean; + onSelect: (value: string | null, time: string | null) => void; }; - showAtElement: jest.Mock; + open: jest.Mock; }> = []; const mockSetIcon = jest.fn(); @@ -102,14 +104,14 @@ jest.mock("obsidian", () => ({ setIcon: mockSetIcon, })); -jest.mock("../../../src/components/DateContextMenu", () => ({ - DateContextMenu: jest.fn().mockImplementation((options) => { - const menu = { +jest.mock("../../../src/modals/DateTimePickerModal", () => ({ + DateTimePickerModal: jest.fn().mockImplementation((_app, options) => { + const picker = { options, - showAtElement: jest.fn(), + open: jest.fn(), }; - mockDateMenus.push(menu); - return menu; + mockDateTimePickers.push(picker); + return picker; }), })); @@ -154,7 +156,7 @@ describe("taskModalUserFieldControls", () => { document.body.innerHTML = ""; mockTextControls.length = 0; mockToggleControls.length = 0; - mockDateMenus.length = 0; + mockDateTimePickers.length = 0; mockSetIcon.mockClear(); mockUserFieldSuggest.mockClear(); mockAttachDateInputBehavior.mockClear(); @@ -306,7 +308,7 @@ describe("taskModalUserFieldControls", () => { mockTextControls[3].onChangeCallback?.(""); mockToggleControls[0].onChangeCallback?.(false); dateButton?.click(); - mockDateMenus[0].options.onSelect("2026-06-03"); + mockDateTimePickers[0].options.onSelect("2026-06-03", null); expect(onValueChange).toHaveBeenCalledWith("assignee", null); expect(onValueChange).toHaveBeenCalledWith("labels", ["one", "two"]); @@ -314,7 +316,9 @@ describe("taskModalUserFieldControls", () => { expect(onValueChange).toHaveBeenCalledWith("custom_date", null); expect(onValueChange).toHaveBeenCalledWith("custom_date", "2026-06-03"); expect(onValueChange).toHaveBeenCalledWith("flagged", false); - expect(mockDateMenus[0].showAtElement).toHaveBeenCalledWith(dateButton); + expect(mockDateTimePickers[0].options.currentDate).toBe("2026-05-19"); + expect(mockDateTimePickers[0].options.showTime).toBe(false); + expect(mockDateTimePickers[0].open).toHaveBeenCalled(); }); it("updates existing user-field input and toggle controls from modal state", () => {