From df9bcd8286bb96bf58ac72d570fd2225c1ba7bf9 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Fri, 5 Jun 2026 10:30:50 +1000 Subject: [PATCH] fix materialized occurrences in bases calendar --- docs/releases/unreleased.md | 4 ++ src/bases/helpers.ts | 64 ++++++++++++++++++++-- tests/unit/bases/basesTaskInfo.test.ts | 73 +++++++++++++++++++++++++- 3 files changed, 137 insertions(+), 4 deletions(-) diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index 121110a0..a4b4a3f0 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -31,3 +31,7 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l ``` --> + +## Fixed + +- (#1995) Fixed Bases Calendar views showing both a materialized occurrence note and its matching virtual recurring task instance. Thanks to @rdefaccio for reporting this. diff --git a/src/bases/helpers.ts b/src/bases/helpers.ts index e6ab9a8c..23a37aa7 100644 --- a/src/bases/helpers.ts +++ b/src/bases/helpers.ts @@ -1,5 +1,6 @@ import TaskNotesPlugin from "../main"; import { Reminder, TaskDependency, TaskInfo, TimeEntry } from "../types"; +import type { OccurrenceMaterializationMode, OccurrenceNextTrigger } from "@tasknotes/model"; import { setIcon } from "obsidian"; import { calculateTotalTimeSpent } from "../utils/helpers"; import { format } from "date-fns"; @@ -86,6 +87,20 @@ function toOptionalNumber(value: unknown): number | undefined { return undefined; } +function toOccurrenceMaterializationMode( + value: unknown +): OccurrenceMaterializationMode | undefined { + const mode = toOptionalString(value); + return mode === "manual" || mode === "on_completion" || mode === "rolling" + ? mode + : undefined; +} + +function toOccurrenceNextTrigger(value: unknown): OccurrenceNextTrigger | undefined { + const trigger = toOptionalString(value); + return trigger === "completion" || trigger === "completion_or_skip" ? trigger : undefined; +} + function toTimeEntries(value: unknown): TimeEntry[] | undefined { return Array.isArray(value) ? (value as TimeEntry[]) : undefined; } @@ -166,6 +181,13 @@ function createTaskInfoFromProperties( "timeEstimate", "completedDate", "recurrence", + "recurrence_parent", + "occurrence_date", + "occurrence_materialization", + "occurrence_next_trigger", + "occurrence_template", + "occurrence_past_horizon", + "occurrence_future_horizon", "dateCreated", "dateModified", "timeEntries", @@ -224,6 +246,15 @@ function createTaskInfoFromProperties( timeEstimate: toOptionalNumber(props.timeEstimate), completedDate: toOptionalString(props.completedDate), recurrence: toOptionalString(props.recurrence), + recurrence_parent: toOptionalString(props.recurrence_parent), + occurrence_date: toOptionalString(props.occurrence_date), + occurrence_materialization: toOccurrenceMaterializationMode( + props.occurrence_materialization + ), + occurrence_next_trigger: toOccurrenceNextTrigger(props.occurrence_next_trigger), + occurrence_template: toOptionalString(props.occurrence_template), + occurrence_past_horizon: toOptionalString(props.occurrence_past_horizon), + occurrence_future_horizon: toOptionalString(props.occurrence_future_horizon), dateCreated: toOptionalString(props.dateCreated), dateModified: toOptionalString(props.dateModified), timeEntries, @@ -242,6 +273,33 @@ function createTaskInfoFromProperties( }; } +function mergeCustomProperties( + first: Record | undefined, + second: Record | undefined +): Record | undefined { + const merged = { + ...(first ?? {}), + ...(second ?? {}), + }; + return Object.keys(merged).length > 0 ? merged : undefined; +} + +function enrichTaskInfoFromCache(taskInfo: TaskInfo, plugin?: TaskNotesPlugin): TaskInfo { + const cachedTask = plugin?.cacheManager?.getCachedTaskInfoSync?.(taskInfo.path); + if (!cachedTask) { + return taskInfo; + } + + return { + ...cachedTask, + basesData: taskInfo.basesData, + customProperties: mergeCustomProperties( + cachedTask.customProperties, + taskInfo.customProperties + ), + }; +} + export function createTaskInfoFromBasesData( basesItem: BasesDataItem, plugin?: TaskNotesPlugin @@ -267,16 +325,16 @@ export function createTaskInfoFromBasesData( }); // Merge file properties with existing custom properties - return { + return enrichTaskInfoFromCache({ ...taskInfo, customProperties: { ...mappedTaskInfo.customProperties, ...taskInfo.customProperties, ...fileProperties, }, - }; + }, plugin); } else { - return createTaskInfoFromProperties(props, basesItem, plugin); + return enrichTaskInfoFromCache(createTaskInfoFromProperties(props, basesItem, plugin), plugin); } } diff --git a/tests/unit/bases/basesTaskInfo.test.ts b/tests/unit/bases/basesTaskInfo.test.ts index 89d960a9..9cb66fef 100644 --- a/tests/unit/bases/basesTaskInfo.test.ts +++ b/tests/unit/bases/basesTaskInfo.test.ts @@ -1,4 +1,7 @@ -import { createTaskInfoFromBasesData } from "../../../src/bases/helpers"; +import { + createTaskInfoFromBasesData, + identifyTaskNotesFromBasesData, +} from "../../../src/bases/helpers"; import type TaskNotesPlugin from "../../../src/main"; import { FieldMapper } from "../../../src/services/FieldMapper"; import { DEFAULT_FIELD_MAPPING } from "../../../src/settings/defaults"; @@ -16,6 +19,9 @@ function createPlugin(sortOrderField = "sort_order") { storeTitleInFilename: false, }, dependencyCache: undefined, + cacheManager: { + getCachedTaskInfoSync: jest.fn(), + }, }; } @@ -37,4 +43,69 @@ describe("Bases TaskInfo assembly", () => { expect(task?.sortOrder).toBe("0|hzzzzz:"); expect(task?.customProperties?.sortOrder).toBeUndefined(); }); + + it("preserves materialized occurrence fields on TaskInfo", () => { + const task = createTaskInfoFromBasesData( + { + path: "Tasks/Occurrence.md", + name: "Occurrence", + properties: { + title: "Occurrence", + status: "open", + scheduled: "2026-06-05T10:30", + recurrence_parent: "[[Tasks/Parent]]", + occurrence_date: "2026-06-05", + }, + }, + createPlugin() as unknown as TaskNotesPlugin + ); + + expect(task?.recurrence_parent).toBe("[[Tasks/Parent]]"); + expect(task?.occurrence_date).toBe("2026-06-05"); + expect(task?.customProperties?.recurrence_parent).toBeUndefined(); + expect(task?.customProperties?.occurrence_date).toBeUndefined(); + }); + + it("enriches hidden materialized occurrence fields from the TaskNotes cache", async () => { + const plugin = createPlugin() as unknown as TaskNotesPlugin; + (plugin.cacheManager.getCachedTaskInfoSync as jest.Mock).mockReturnValue({ + title: "Occurrence", + status: "open", + priority: "normal", + path: "Tasks/Occurrence.md", + archived: false, + scheduled: "2026-06-05T10:30", + recurrence_parent: "[[Tasks/Parent]]", + occurrence_date: "2026-06-05", + customProperties: { + cachedOnly: true, + }, + }); + + const tasks = await identifyTaskNotesFromBasesData( + [ + { + path: "Tasks/Occurrence.md", + name: "Occurrence", + properties: { + title: "Occurrence", + status: "open", + scheduled: "2026-06-05T10:30", + "file.path": "Tasks/Occurrence.md", + }, + basesData: { source: "bases-entry" }, + }, + ], + plugin + ); + + expect(tasks).toHaveLength(1); + expect(tasks[0].recurrence_parent).toBe("[[Tasks/Parent]]"); + expect(tasks[0].occurrence_date).toBe("2026-06-05"); + expect(tasks[0].basesData).toEqual({ source: "bases-entry" }); + expect(tasks[0].customProperties).toMatchObject({ + cachedOnly: true, + "file.path": "Tasks/Occurrence.md", + }); + }); });