From d7a18401d68ea052f97df0375201fd2cdd599166 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Mon, 1 Jun 2026 04:50:49 +1000 Subject: [PATCH] fix same-day calendar due duplicates --- docs/releases/unreleased.md | 1 + src/bases/calendar-core.ts | 25 ++++- ...77-calendar-same-day-scheduled-due.test.ts | 106 ++++++++++++++++++ 3 files changed, 129 insertions(+), 3 deletions(-) create mode 100644 tests/unit/issues/issue-1977-calendar-same-day-scheduled-due.test.ts diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index 39f3a1d0..13440e23 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -48,6 +48,7 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l ## Fixed - (#1976) Fixed embedded Calendar and Agenda Bases so "Navigate to date from property" can use the containing note's date property when the Base rows do not have that property. Thanks to @matesvecenik for reporting this. +- (#1977) Fixed Calendar and Agenda views showing duplicate all-day entries when a task's scheduled date and due date are the same day. Timed due dates on the same day still show as separate deadline markers. Thanks to @pdgBC for reporting this. - (#216) Improved inline conversion for Tasks plugin task lines, including Dataview-style fields, priority markers, recurrence text, date aliases, block links, and safer trailing-field parsing. Thanks to @ksdavidc for the request, @natleahh for the Dataview example, and @hangryscribe3 and @nayatiuh for the discussion. - (#1974) Stopped Calendar and Agenda property-based events from logging date parse errors for entries that do not have the selected date property. Thanks to @Jomo94 for suggesting completed-date Agenda events. - (#1973) Reduced unnecessary Calendar view recreations when external calendar providers are reported in a different order, and preserved Calendar scroll position when a config-driven refresh has to recreate the view. Thanks to @e-zz for reporting this. diff --git a/src/bases/calendar-core.ts b/src/bases/calendar-core.ts index e5d70c00..63b20a7a 100644 --- a/src/bases/calendar-core.ts +++ b/src/bases/calendar-core.ts @@ -840,6 +840,15 @@ function replaceDatePartPreservingTime(value: string, datePart: string): string return timePart ? `${datePart}T${timePart}` : datePart; } +function hasDateOnlyDueOnScheduledDay(task: TaskInfo): boolean { + return Boolean( + task.scheduled && + task.due && + !hasTimeComponent(task.due) && + getDatePart(task.scheduled) === getDatePart(task.due) + ); +} + function createRecurringScheduledToDueSpanEvents( task: TaskInfo, instanceDate: string, @@ -1505,7 +1514,8 @@ export async function generateCalendarEvents( task: TaskInfo, includeScheduled: boolean, allowScheduledToDueSpan: boolean, - includeDue = showDue + includeDue = showDue, + hasGeneratedScheduledLayer = false ): void => { let showedSpan = false; if (allowScheduledToDueSpan && showScheduledToDueSpan && task.scheduled && task.due) { @@ -1534,7 +1544,13 @@ export async function generateCalendarEvents( } } - if (includeDue && task.due) { + const shouldSuppressDateOnlyDue = + includeDue && + (includeScheduled || hasGeneratedScheduledLayer) && + hasDateOnlyDueOnScheduledDay(task); + const shouldShowDue = includeDue && !shouldSuppressDateOnlyDue; + + if (shouldShowDue && task.due) { if (isDateInVisibleRange(task.due, visibleStart, visibleEnd)) { const dueEvent = createDueEvent(task, plugin); if (dueEvent) { @@ -1551,6 +1567,7 @@ export async function generateCalendarEvents( let includeStandaloneScheduled = showScheduled; let includeStandaloneDue = showDue; let allowScheduledToDueSpan = true; + let hasGeneratedScheduledLayer = false; if ( (showRecurring || @@ -1578,6 +1595,7 @@ export async function generateCalendarEvents( ); events.push(...recurringEvents); if (showRecurring) { + hasGeneratedScheduledLayer = recurringEvents.length > 0; includeStandaloneScheduled = false; allowScheduledToDueSpan = false; if ( @@ -1596,7 +1614,8 @@ export async function generateCalendarEvents( task, includeStandaloneScheduled, allowScheduledToDueSpan, - includeStandaloneDue + includeStandaloneDue, + hasGeneratedScheduledLayer ); } else { // Handle non-recurring tasks with date range filtering diff --git a/tests/unit/issues/issue-1977-calendar-same-day-scheduled-due.test.ts b/tests/unit/issues/issue-1977-calendar-same-day-scheduled-due.test.ts new file mode 100644 index 00000000..6330113b --- /dev/null +++ b/tests/unit/issues/issue-1977-calendar-same-day-scheduled-due.test.ts @@ -0,0 +1,106 @@ +/** + * Issue #1977: Calendar and Agenda should not duplicate date-only tasks when + * the scheduled and due dates are the same day. + * + * @see https://github.com/callumalpass/tasknotes/issues/1977 + */ + +import { generateCalendarEvents, type CalendarEvent } from "../../../src/bases/calendar-core"; +import type TaskNotesPlugin from "../../../src/main"; +import { TaskFactory } from "../../helpers/mock-factories"; + +function createPlugin(): TaskNotesPlugin { + return { + priorityManager: { + getPriorityConfig: jest.fn().mockReturnValue({ color: "#6366f1" }), + }, + statusManager: { + isCompletedStatus: jest.fn().mockReturnValue(false), + }, + } as unknown as TaskNotesPlugin; +} + +function eventTypes(events: CalendarEvent[]): string[] { + return events.map((event) => event.extendedProps.eventType).sort(); +} + +describe("Issue #1977: same-day scheduled and due Calendar duplicates", () => { + const plugin = createPlugin(); + + it("renders one task event when date-only scheduled and due values are the same day", async () => { + const task = TaskFactory.createTask({ + title: "Same day task", + path: "Tasks/same-day.md", + scheduled: "2026-05-31", + due: "2026-05-31", + }); + + const events = await generateCalendarEvents([task], plugin, { + showScheduled: true, + showDue: true, + showRecurring: false, + showTimeEntries: false, + }); + + expect(eventTypes(events)).toEqual(["scheduled"]); + expect(events[0]).toMatchObject({ + id: "scheduled-Tasks/same-day.md", + start: "2026-05-31", + allDay: true, + }); + }); + + it("keeps separate due events when the due value adds a distinct time", async () => { + const task = TaskFactory.createTask({ + title: "Timed deadline", + path: "Tasks/timed-deadline.md", + scheduled: "2026-05-31", + due: "2026-05-31T17:00", + }); + + const events = await generateCalendarEvents([task], plugin, { + showScheduled: true, + showDue: true, + showRecurring: false, + showTimeEntries: false, + }); + + expect(eventTypes(events)).toEqual(["due", "scheduled"]); + }); + + it("keeps the due event when scheduled events are hidden", async () => { + const task = TaskFactory.createTask({ + title: "Due only display", + path: "Tasks/due-only-display.md", + scheduled: "2026-05-31", + due: "2026-05-31", + }); + + const events = await generateCalendarEvents([task], plugin, { + showScheduled: false, + showDue: true, + showRecurring: false, + showTimeEntries: false, + }); + + expect(eventTypes(events)).toEqual(["due"]); + }); + + it("keeps separate due events when scheduled and due dates differ", async () => { + const task = TaskFactory.createTask({ + title: "Different day task", + path: "Tasks/different-day.md", + scheduled: "2026-05-31", + due: "2026-06-01", + }); + + const events = await generateCalendarEvents([task], plugin, { + showScheduled: true, + showDue: true, + showRecurring: false, + showTimeEntries: false, + }); + + expect(eventTypes(events)).toEqual(["due", "scheduled"]); + }); +});