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
4 changes: 4 additions & 0 deletions docs/releases/unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,7 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l
```

-->

## Fixed

- (#2328) Fixed direct status edits on occurrence notes not updating the recurring parent’s completion history or creating the next occurrence when configured. Thanks to @mudnug for reporting this.
21 changes: 12 additions & 9 deletions src/services/TaskService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -721,14 +721,6 @@ export class TaskService {
updatePlan.normalizedValue
);

await this.reconcileMaterializedOccurrenceStatusChange(
freshTask,
updatePlan.updatedTask,
property,
freshTask[property],
updatePlan.normalizedValue
);

// Step 4: Return authoritative data
return updatePlan.updatedTask;
} catch (error) {
Expand All @@ -753,7 +745,8 @@ export class TaskService {
/**
* Run all post-write side effects for a property change WITHOUT performing a
* frontmatter write. This includes: cache update, EVENT_TASK_UPDATED,
* dependent-task UI refresh, webhooks, Google Calendar sync, and auto-archive.
* dependent-task UI refresh, webhooks, Google Calendar sync, auto-archive,
* and materialized occurrence parent reconciliation.
*
* Callers are responsible for having already persisted the change to frontmatter.
*/
Expand Down Expand Up @@ -784,6 +777,16 @@ export class TaskService {
newValue,
}
);

// Direct file edits and bulk property writes must reconcile occurrence
// parents just like updateProperty, without repeating the occurrence write.
await this.reconcileMaterializedOccurrenceStatusChange(
originalTask,
updatedTask,
property,
oldValue,
newValue
);
}

async materializeOccurrence(
Expand Down
49 changes: 49 additions & 0 deletions tests/unit/services/task-occurrence-materialization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import { TFile } from "../../helpers/obsidian-runtime";
import { PluginFactory, TaskFactory } from "../../helpers/mock-factories";
import { TaskService } from "../../../src/services/TaskService";
import type { TaskInfo } from "../../../src/types";
import { TaskFileLifecycleReconciliationService } from "../../../src/services/TaskFileLifecycleReconciliationService";

jest.mock("../../../src/utils/dateUtils", () => {
const actual = jest.requireActual("../../../src/utils/dateUtils");
Expand Down Expand Up @@ -459,6 +460,54 @@ describe("TaskService materialized occurrences", () => {
expect(frontmatterByPath.get(parent.path)?.skipped_instances).toBeUndefined();
});

it.each([
[false, "open", "done"],
[true, "open", "done"],
[false, "false", "true"],
[true, "false", "true"],
] as const)("reconciles direct occurrence status edits (automatic next: %s, %s → %s) (#2328)", async (automaticNext, activeStatus, completedStatus) => {
const parent = TaskFactory.createTask({
path: "Tasks/Parent.md",
recurrence: "DTSTART:20260601;FREQ=DAILY",
scheduled: "2026-06-01",
complete_instances: [],
skipped_instances: ["2026-06-01"],
occurrence_materialization: automaticNext ? "on_completion" : "manual",
});
const occurrence = TaskFactory.createTask({
path: "Tasks/Occurrence.md",
status: activeStatus,
recurrence_parent: "[[Tasks/Parent]]",
occurrence_date: "2026-06-01",
scheduled: "2026-06-01",
});
const { plugin, taskService, frontmatterByPath } = createService({
[parent.path]: parent,
[occurrence.path]: occurrence,
});
plugin.taskService = taskService;
plugin.statusManager.isCompletedStatus = jest.fn((status) => status === completedStatus);
const materialize = jest.spyOn(taskService, "materializeOccurrence").mockResolvedValue(occurrence);
const lifecycle = new TaskFileLifecycleReconciliationService(plugin);
await lifecycle.initialize();
try {
const completed = { ...occurrence, status: completedStatus };
await lifecycle.handleTaskUpdatedEvent({ task: completed });
expect(frontmatterByPath.get(parent.path)).toMatchObject({
complete_instances: ["2026-06-01"],
scheduled: "2026-06-02",
});
expect(frontmatterByPath.get(parent.path)?.skipped_instances).toBeUndefined();
expect(materialize).toHaveBeenCalledTimes(automaticNext ? 1 : 0);
await lifecycle.handleTaskUpdatedEvent({ task: completed });
expect(materialize).toHaveBeenCalledTimes(automaticNext ? 1 : 0);
await lifecycle.handleTaskUpdatedEvent({ task: occurrence });
expect(frontmatterByPath.get(parent.path)?.complete_instances).toBeUndefined();
} finally {
lifecycle.destroy();
}
});

it("advances completion-anchored parents from the actual completion date", async () => {
const dateUtils = jest.requireMock("../../../src/utils/dateUtils");
dateUtils.getCurrentDateString.mockReturnValue("2026-07-30");
Expand Down
Loading