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: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
105 changes: 49 additions & 56 deletions src/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
Expand All @@ -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);
Expand All @@ -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 });
}
Expand Down Expand Up @@ -1204,6 +1218,31 @@ function buildSetPatch(frontmatter: Record<string, unknown>): TaskPatchOperation
.map(([field, value]) => ({ op: "set", field, value }) satisfies TaskPatchOperation);
}

function buildFrontmatterPatch(
original: Record<string, unknown>,
updated: Record<string, unknown>
): 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<string, unknown>): TaskInfo {
const updatedTask = { ...task };
if (Object.prototype.hasOwnProperty.call(fields, "title")) updatedTask.title = readString(fields.title) || updatedTask.title;
Expand Down Expand Up @@ -1254,52 +1293,6 @@ function applySpecFieldsToTaskInfo(task: TaskInfo, fields: Record<string, unknow
return updatedTask;
}

function addUnsetMappedFieldDeletes(
patch: TaskPatchOperation[],
updates: TaskUpdateInput,
fieldMapping: FieldMapping
): void {
const deletable: Array<[keyof TaskUpdateInput, FieldMappingKey]> = [
["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<Record<keyof TaskInfo, FieldMappingKey>> = {
title: "title",
Expand Down
37 changes: 37 additions & 0 deletions test/model.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down