Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/releases/unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ Example:
```
## Fixed

- Avoid showing a recurring task twice when its original occurrence date is recorded by a calendar move. Keep requested completion and skip history visible.

- (#768) Fixed calendar view appearing empty in week and day views due to invalid time configuration values
- Added time validation in settings UI with proper error messages and debouncing
- Prevents "Cannot read properties of null (reading 'years')" error from FullCalendar
Expand Down
9 changes: 8 additions & 1 deletion src/bases/calendar-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1141,6 +1141,13 @@ export function generateRecurringTaskInstances(
const hasOriginalTime = hasTimeComponent(task.scheduled);
const templateTime = getRecurringTime(task);
const nextScheduledDate = getDatePart(task.scheduled);
// A moved occurrence is represented at its current scheduled placement.
// Its original rule date must not also become a projected task. Recorded
// completions/skips are handled separately below and remain available.
const movedOriginalDates = new Set(task.googleCalendarMovedOriginalDates || []);
if (task.googleCalendarExceptionOriginalScheduled) {
movedOriginalDates.add(getDatePart(task.googleCalendarExceptionOriginalScheduled));
}
const spanDayOffset = showScheduledToDueSpan ? getScheduledToDueSpanDayOffset(task) : null;
const shouldCreateRecurringSpan = spanDayOffset !== null;
const recurringSearchStartDate = shouldCreateRecurringSpan
Expand Down Expand Up @@ -1227,7 +1234,7 @@ export function generateRecurringTaskInstances(
}

// Skip if conflicts with next scheduled occurrence
if (instanceDate === nextScheduledDate) {
if (instanceDate === nextScheduledDate || movedOriginalDates.has(instanceDate)) {
continue;
}

Expand Down
2 changes: 2 additions & 0 deletions src/bases/calendarDataSignature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ const CALENDAR_DATA_SIGNATURE_FIELDS: FieldMappingKey[] = [
"blockedBy",
"icsEventId",
"googleCalendarEventId",
"googleCalendarExceptionOriginalScheduled",
"googleCalendarMovedOriginalDates",
"reminders",
"sortOrder",
];
Expand Down
6 changes: 6 additions & 0 deletions src/bases/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,8 @@ function createTaskInfoFromProperties(
"icsEventId",
"complete_instances",
"skipped_instances",
"googleCalendarExceptionOriginalScheduled",
"googleCalendarMovedOriginalDates",
"blockedBy",
"blocking",
"sortOrder",
Expand Down Expand Up @@ -267,6 +269,10 @@ function createTaskInfoFromProperties(
icsEventId: toStringArray(props.icsEventId),
complete_instances: toStringArray(props.complete_instances),
skipped_instances: toStringArray(props.skipped_instances),
googleCalendarExceptionOriginalScheduled: toOptionalString(
props.googleCalendarExceptionOriginalScheduled
),
googleCalendarMovedOriginalDates: toStringArray(props.googleCalendarMovedOriginalDates),
blockedBy: toDependencies(props.blockedBy),
blocking: blockingTasks.length > 0 ? blockingTasks : undefined,
isBlocked: isBlocked,
Expand Down
10 changes: 10 additions & 0 deletions tests/unit/bases/calendarDataSignature.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ import {
} from "../../../src/bases/calendarDataSignature";

describe("calendarDataSignature", () => {
it("invalidates the calendar when mapped occurrence-move markers change", () => {
const properties = buildCalendarDataSignaturePropertyIds({
mapField: (field) => field === "googleCalendarExceptionOriginalScheduled" ? "originalOccurrence" : undefined,
showPropertyBasedEvents: false,
});
const before = [{path: "task.md", properties: {originalOccurrence: "2026-09-12"}}];
const after = [{path: "task.md", properties: {originalOccurrence: "2026-09-19"}}];
expect(buildCalendarDataSignature(before, properties)).not.toEqual(buildCalendarDataSignature(after, properties));
expect(properties).toContain("googleCalendarMovedOriginalDates");
});
it("selects mapped core fields, visible properties, and property-event fields", () => {
const propertyIds = buildCalendarDataSignaturePropertyIds({
mapField: (field) => (field === "scheduled" ? "planned" : undefined),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
} from "../../../src/bases/calendar-core";
import type TaskNotesPlugin from "../../../src/main";
import { TaskFactory } from "../../helpers/mock-factories";
import { createTaskInfoFromBasesData } from "../../../src/bases/helpers";

function createPlugin(): TaskNotesPlugin {
return {
Expand All @@ -35,6 +36,59 @@ describe("Issue #1603: recurring calendar instance visibility", () => {
const start = new Date("2026-02-01T00:00:00.000Z");
const end = new Date("2026-02-06T00:00:00.000Z");

it("renders a moved occurrence once and keeps later weekly occurrences", () => {
const task = TaskFactory.createRecurringTask("DTSTART:20260905;FREQ=WEEKLY;BYDAY=SA", {
scheduled: "2026-09-11",
googleCalendarExceptionOriginalScheduled: "2026-09-12",
googleCalendarMovedOriginalDates: ["2026-09-05"],
});
const before = JSON.stringify(task);
const events = generateRecurringTaskInstances(
task,
new Date("2026-09-01T00:00:00Z"),
new Date("2026-09-21T00:00:00Z"),
plugin
);
expect(getInstanceDates(events)).toEqual(["2026-09-11", "2026-09-19"]);
expect(JSON.stringify(task)).toBe(before);
});

it("preserves move markers through Bases conversion before the task cache is warm", () => {
const task = createTaskInfoFromBasesData({
path: "tasks/moved.md",
properties: {
recurrence: "DTSTART:20260905;FREQ=WEEKLY;BYDAY=SA",
scheduled: "2026-09-11",
googleCalendarExceptionOriginalScheduled: "2026-09-12",
googleCalendarMovedOriginalDates: ["2026-09-05"],
},
});
expect(task).not.toBeNull();
expect(getInstanceDates(generateRecurringTaskInstances(
task!, new Date("2026-09-01T00:00:00Z"), new Date("2026-09-21T00:00:00Z"), plugin
))).toEqual(["2026-09-11", "2026-09-19"]);
});

it("keeps recorded history for moved dates when history is requested", () => {
const task = TaskFactory.createRecurringTask("DTSTART:20260905;FREQ=WEEKLY;BYDAY=SA", {
scheduled: "2026-09-19",
googleCalendarMovedOriginalDates: ["2026-09-12"],
complete_instances: ["2026-09-12"],
});
const range = [new Date("2026-09-11T00:00:00Z"), new Date("2026-09-21T00:00:00Z")] as const;
expect(getInstanceDates(generateRecurringTaskInstances(task, ...range, plugin))).toEqual([
"2026-09-12",
"2026-09-19",
]);
expect(
getInstanceDates(
generateRecurringTaskInstances(task, ...range, plugin, {
showCompletedRecurringInstances: false,
})
)
).toEqual(["2026-09-19"]);
});

it("keeps completed and skipped recurring instances visible by default", () => {
const task = TaskFactory.createRecurringTask("FREQ=DAILY;INTERVAL=1", {
path: "tasks/recur.md",
Expand Down
Loading