From 66f5e2de1842293991fb34e123e471815d5306a4 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Mon, 10 Aug 2026 08:32:57 +1000 Subject: [PATCH 1/2] fix: persist task status transition invariants --- src/operations.ts | 105 +++++++++++++++++++++----------------------- test/model.test.mjs | 37 ++++++++++++++++ 2 files changed, 86 insertions(+), 56 deletions(-) diff --git a/src/operations.ts b/src/operations.ts index d7de2ef..9007a58 100644 --- a/src/operations.ts +++ b/src/operations.ts @@ -237,8 +237,14 @@ export function buildTaskUpdatePlan({ storeTitleInFilename, userFields ); - const frontmatterPatch = buildSetPatch(mapped); - addUnsetMappedFieldDeletes(frontmatterPatch, { ...normalizedUpdates, ...recurrenceUpdates }, fieldMapping); + const originalMapped = mapTaskToFrontmatter( + fieldMapping, + originalTask, + taskTag, + storeTitleInFilename, + userFields + ); + const frontmatterPatch = buildFrontmatterPatch(originalMapped, mapped); return { kind: "task.update", updatedTask, @@ -357,11 +363,13 @@ export function buildUpdatedTaskFromPlan({ if (finalTags) updatedTask.tags = finalTags; if (normalizedDetails !== null) updatedTask.details = normalizedDetails; if (updates.status !== undefined && !originalTask.recurrence) { - if (isCompletedStatusFn(updates.status)) { - if (!originalTask.completedDate) updatedTask.completedDate = currentDateString; - } else { - updatedTask.completedDate = undefined; - } + applyStatusCompletionInvariant( + updatedTask, + originalTask, + updates.status, + currentDateString, + isCompletedStatusFn + ); } return updatedTask; } @@ -380,7 +388,13 @@ export function buildTaskPropertyUpdatePlan({ if (property === "status" && !freshTask.recurrence) { const status = String(normalizedValue ?? ""); - updatedTask.completedDate = isCompletedStatus(status, statuses) ? currentDateString : undefined; + applyStatusCompletionInvariant( + updatedTask, + freshTask, + status, + currentDateString, + (candidate) => isCompletedStatus(candidate, statuses) + ); } const fieldName = fieldNameForTaskProperty(fieldMapping, property); @@ -391,8 +405,8 @@ export function buildTaskPropertyUpdatePlan({ } else if (property === "status") { const status = String(normalizedValue ?? ""); frontmatterPatch.push({ op: "set", field: fieldName, value: coerceStatusFrontmatterValue(status) }); - if (!freshTask.recurrence && isCompletedStatus(status, statuses)) { - frontmatterPatch.push({ op: "set", field: fieldMapping.completedDate, value: currentDateString }); + if (!freshTask.recurrence && updatedTask.completedDate) { + frontmatterPatch.push({ op: "set", field: fieldMapping.completedDate, value: updatedTask.completedDate }); } else if (!freshTask.recurrence) { frontmatterPatch.push({ op: "delete", field: fieldMapping.completedDate }); } @@ -1204,6 +1218,31 @@ function buildSetPatch(frontmatter: Record): TaskPatchOperation .map(([field, value]) => ({ op: "set", field, value }) satisfies TaskPatchOperation); } +function buildFrontmatterPatch( + original: Record, + updated: Record +): TaskPatchOperation[] { + const patch = buildSetPatch(updated); + for (const field of Object.keys(original)) { + if (!Object.prototype.hasOwnProperty.call(updated, field) || updated[field] === undefined) { + patch.push({ op: "delete", field }); + } + } + return patch; +} + +function applyStatusCompletionInvariant( + updatedTask: TaskInfo, + originalTask: TaskInfo, + status: string, + currentDateString: string, + isCompleted: (status: string) => boolean +): void { + updatedTask.completedDate = isCompleted(status) + ? originalTask.completedDate ?? currentDateString + : undefined; +} + function applySpecFieldsToTaskInfo(task: TaskInfo, fields: Record): TaskInfo { const updatedTask = { ...task }; if (Object.prototype.hasOwnProperty.call(fields, "title")) updatedTask.title = readString(fields.title) || updatedTask.title; @@ -1254,52 +1293,6 @@ function applySpecFieldsToTaskInfo(task: TaskInfo, fields: Record = [ - ["due", "due"], - ["scheduled", "scheduled"], - ["contexts", "contexts"], - ["timeEstimate", "timeEstimate"], - ["completedDate", "completedDate"], - ["recurrence", "recurrence"], - ["recurrence_parent", "recurrenceParent"], - ["occurrence_date", "occurrenceDate"], - ["occurrence_materialization", "occurrenceMaterialization"], - ["occurrence_next_trigger", "occurrenceNextTrigger"], - ["occurrence_template", "occurrenceTemplate"], - ["occurrence_past_horizon", "occurrencePastHorizon"], - ["occurrence_future_horizon", "occurrenceFutureHorizon"], - ["blockedBy", "blockedBy"], - ["googleCalendarExceptionOriginalScheduled", "googleCalendarExceptionOriginalScheduled"], - ]; - for (const [updateKey, mappingKey] of deletable) { - if (Object.prototype.hasOwnProperty.call(updates, updateKey) && updates[updateKey] === undefined) { - patch.push({ op: "delete", field: fieldMapping[mappingKey] }); - } - } - if (Object.prototype.hasOwnProperty.call(updates, "projects")) { - if (!Array.isArray(updates.projects) || updates.projects.length === 0) { - patch.push({ op: "delete", field: fieldMapping.projects }); - } - } - if (Object.prototype.hasOwnProperty.call(updates, "attachments")) { - if (!Array.isArray(updates.attachments) || updates.attachments.length === 0) { - patch.push({ op: "delete", field: fieldMapping.attachments }); - } - } - if ( - Object.prototype.hasOwnProperty.call(updates, "googleCalendarMovedOriginalDates") && - (!Array.isArray(updates.googleCalendarMovedOriginalDates) || - updates.googleCalendarMovedOriginalDates.length === 0) - ) { - patch.push({ op: "delete", field: fieldMapping.googleCalendarMovedOriginalDates }); - } -} - function fieldNameForTaskProperty(fieldMapping: FieldMapping, property: keyof TaskInfo): string | undefined { const explicit: Partial> = { title: "title", diff --git a/test/model.test.mjs b/test/model.test.mjs index e3aa78f..a7ce3f8 100644 --- a/test/model.test.mjs +++ b/test/model.test.mjs @@ -163,6 +163,43 @@ test("keeps modification timestamps valid when a device clock moves backwards", assert.equal(plan.dateModified, "2026-07-28T10:00:00.001Z"); }); +test("status transitions delete the mapped completion date when a task is reopened", () => { + const fieldMapping = { + ...DEFAULT_FIELD_MAPPING, + status: "state", + completedDate: "finished_on", + }; + const completed = buildTaskUpdatePlan({ + originalTask: { + title: "Transition task", + status: "open", + priority: "normal", + path: "Tasks/transition.md", + archived: false, + }, + updates: { status: "done" }, + fieldMapping, + statuses: [{ label: "Done", value: "done", isCompleted: true }], + now: "2026-08-10T01:00:00.000Z", + currentDateString: "2026-08-10", + }); + const reopened = buildTaskUpdatePlan({ + originalTask: completed.updatedTask, + updates: { status: "open" }, + fieldMapping, + statuses: [{ label: "Done", value: "done", isCompleted: true }], + now: "2026-08-10T01:01:00.000Z", + currentDateString: "2026-08-10", + }); + + assert.equal(completed.updatedTask.completedDate, "2026-08-10"); + assert.equal(reopened.updatedTask.completedDate, undefined); + assert.deepEqual( + reopened.frontmatterPatch.find((operation) => operation.field === "finished_on"), + { op: "delete", field: "finished_on" } + ); +}); + test("recalculates recurring schedules with DTSTART", () => { const result = recalculateRecurringSchedule({ recurrence: "FREQ=DAILY;COUNT=3", From 6894d8282a47a12aedb90907da0753e0043f0fb0 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Mon, 10 Aug 2026 12:13:57 +1000 Subject: [PATCH 2/2] Prepare TaskNotes model rc.11 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 57fc662..81e6db5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@tasknotes/model", - "version": "0.3.0-rc.10", + "version": "0.3.0-rc.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tasknotes/model", - "version": "0.3.0-rc.10", + "version": "0.3.0-rc.11", "license": "MIT", "dependencies": { "rrule": "^2.8.1", diff --git a/package.json b/package.json index b7a5cac..da06fac 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tasknotes/model", - "version": "0.3.0-rc.10", + "version": "0.3.0-rc.11", "description": "TaskNotes model, mapping, validation, recurrence, and operation-planning reference implementation.", "license": "MIT", "type": "module",