diff --git a/CHANGELOG.md b/CHANGELOG.md index c5ca944..8f45919 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 0.3.0-rc.18 + +### Fixed + +- TaskNotes #2396: moved native recurring calendar occurrences retain their original rule-date identity for IDs, completion/skip lookup, spans and materialized-child suppression. Their original date is not projected again; moves onto another rule date keep the two occurrences distinct. Recorded history and Google moved-date behavior are preserved. +- Calendar edit plans capture and preserve the mapped `recurrence_original_date`, delete it on return to the original day or a series/DTSTART edit, and anchor DTSTART-less rules to the old scheduled date and clock rather than the moved placement. Legacy unmarked off-pattern and completion-anchored tasks do not acquire guessed identities. + +### Added + +- Optional `recurrenceOriginalDate` field mapping (default `recurrence_original_date`), task/schema support and strict date normalization, with fallback for older saved mappings. +- Packaged moved-occurrence regression fixtures covering projection, mapped edits, history, materialization and timed/all-day spans. + ## 0.3.0-rc.17 ### Added diff --git a/README.md b/README.md index 64b8eee..fc84dde 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,21 @@ const plan = planCalendarEdit({ than being projected on each recurrence date. Materialized children suppress virtual parent occurrences; supply `materializedOccurrences` for children outside the visible task set, keyed by resolved parent task ID. +- Native scheduled-anchor moves use `recurrence_original_date` as the pending + occurrence's identity, while `scheduled` remains its placement. Forward both + fields and `recurrence_anchor` in host projection adapters, alongside completion/ + skip history and Google moved-date fields. IDs, completion/skip lookup, spans and + materialized-child suppression use the original identity; a move onto another + rule date leaves that real occurrence distinct. Unmarked legacy off-pattern + dates are not assigned guessed identities. Completion-anchor tasks ignore this + marker. Recorded history remains available when its moved placement is outside + the range. +- `FieldMapping.recurrenceOriginalDate` defaults to `recurrence_original_date`, + including for older mappings that omit the key. Mapping accepts only real, + strict `YYYY-MM-DD` identities. Calendar moves preserve this identity across + repeated moves, remove it on return to its original day, and pin missing DTSTART + to the old scheduled date/clock. A series/DTSTART clock edit clears the marker. + This is a model extension; it does not change the pinned spec contract version. - Edit plans mirror plugin gestures: next-occurrence drops reschedule `scheduled`; pattern drops change only DTSTART's clock (preserving its date); recorded drops change the scheduled clock without moving its date. No gesture materializes a @@ -117,6 +132,9 @@ const plan = planCalendarEdit({ spans are read-only. Resizes change only the estimate, not `scheduled`/`due`, except time-entry resizes, which replace their range. `clearedInstances` reports completion/skip dates removed by a next-occurrence reschedule, for confirmation. +- Shared fixtures include `fixtures/calendar/moved-occurrences.json`. Raw JSON + edits use `expectedUnsetProperties` to name updates whose value is `undefined`; + the typed `calendar/fixtures` export expands these deletions automatically. - The index caches eight ranges per task by default. Supply immutable task objects or call `upsert` with a changed revision after mutation. `setTasks` preserves cached unchanged objects and updates order/membership; `remove` and materialized diff --git a/fixtures/calendar/moved-occurrences.json b/fixtures/calendar/moved-occurrences.json new file mode 100644 index 0000000..21c16f4 --- /dev/null +++ b/fixtures/calendar/moved-occurrences.json @@ -0,0 +1,766 @@ +[ + { + "name": "native-first-move-captures-rule-identity", + "input": { + "tasks": [ + { + "id": "a", + "path": "a.md", + "title": "Annual", + "status": "open", + "priority": "normal", + "archived": false, + "scheduled": "2026-10-18", + "recurrence": "DTSTART:20261018;FREQ=YEARLY;COUNT=2" + } + ], + "range": { + "start": "2026-10-01", + "end": "2026-11-01" + }, + "timezone": "America/New_York", + "now": "2026-10-12T08:00:00Z" + }, + "expectedEntries": [ + { + "id": "next-scheduled-a-2026-10-18", + "taskId": "a", + "title": "Annual", + "type": "scheduled", + "start": "2026-10-18", + "allDay": true, + "durationMinutes": 1440, + "isCompleted": false, + "isSkipped": false, + "startEditable": true, + "durationEditable": true, + "editReason": "next-occurrence", + "recurringKind": "next-scheduled", + "instanceDate": "2026-10-18", + "recurringTemplateTime": "09:00" + } + ], + "edits": [ + { + "entryId": "next-scheduled-a-2026-10-18", + "change": { + "kind": "drop", + "start": "2026-10-12", + "allDay": true + }, + "expectedUpdates": { + "scheduled": "2026-10-12", + "recurrence_original_date": "2026-10-18", + "dateModified": "2026-10-12T08:00:00Z" + }, + "expectedFrontmatterPatch": [ + { + "op": "set", + "field": "scheduled", + "value": "2026-10-12" + }, + { + "op": "set", + "field": "recurrence_original_date", + "value": "2026-10-18" + }, + { + "op": "set", + "field": "dateModified", + "value": "2026-10-12T08:00:00Z" + } + ], + "expectedClearedInstances": { + "complete": [], + "skipped": [] + } + } + ] + }, + { + "name": "native-moved-annual-preserves-and-clears-identity", + "input": { + "tasks": [ + { + "id": "a", + "path": "a.md", + "title": "Annual", + "status": "open", + "priority": "normal", + "archived": false, + "scheduled": "2026-10-12", + "recurrence": "DTSTART:20261018;FREQ=YEARLY;COUNT=2", + "recurrence_original_date": "2026-10-18" + } + ], + "range": { + "start": "2026-10-01", + "end": "2027-11-01" + }, + "timezone": "America/New_York", + "now": "2026-10-12T08:00:00Z" + }, + "expectedEntries": [ + { + "id": "next-scheduled-a-2026-10-18", + "taskId": "a", + "title": "Annual", + "type": "scheduled", + "start": "2026-10-12", + "allDay": true, + "durationMinutes": 1440, + "isCompleted": false, + "isSkipped": false, + "startEditable": true, + "durationEditable": true, + "editReason": "next-occurrence", + "recurringKind": "next-scheduled", + "instanceDate": "2026-10-18", + "recurringTemplateTime": "09:00" + }, + { + "id": "recurring-a-2027-10-18", + "taskId": "a", + "title": "Annual", + "type": "recurring", + "start": "2027-10-18", + "allDay": true, + "durationMinutes": 1440, + "isCompleted": false, + "isSkipped": false, + "startEditable": true, + "durationEditable": true, + "editReason": "series-time", + "recurringKind": "pattern", + "instanceDate": "2027-10-18", + "recurringTemplateTime": "09:00" + } + ], + "edits": [ + { + "entryId": "next-scheduled-a-2026-10-18", + "change": { + "kind": "drop", + "start": "2027-10-20", + "allDay": true + }, + "expectedUpdates": { + "scheduled": "2027-10-20", + "recurrence_original_date": "2026-10-18", + "dateModified": "2026-10-12T08:00:00Z" + }, + "expectedFrontmatterPatch": [ + { + "op": "set", + "field": "scheduled", + "value": "2027-10-20" + }, + { + "op": "set", + "field": "recurrence_original_date", + "value": "2026-10-18" + }, + { + "op": "set", + "field": "dateModified", + "value": "2026-10-12T08:00:00Z" + } + ], + "expectedClearedInstances": { + "complete": [], + "skipped": [] + } + }, + { + "entryId": "next-scheduled-a-2026-10-18", + "change": { + "kind": "drop", + "start": "2026-10-18", + "allDay": true + }, + "expectedUpdates": { + "scheduled": "2026-10-18", + "dateModified": "2026-10-12T08:00:00Z" + }, + "expectedFrontmatterPatch": [ + { + "op": "set", + "field": "scheduled", + "value": "2026-10-18" + }, + { + "op": "delete", + "field": "recurrence_original_date" + }, + { + "op": "set", + "field": "dateModified", + "value": "2026-10-12T08:00:00Z" + } + ], + "expectedClearedInstances": { + "complete": [], + "skipped": [] + }, + "expectedUnsetProperties": [ + "recurrence_original_date" + ] + }, + { + "entryId": "recurring-a-2027-10-18", + "change": { + "kind": "drop", + "start": "2027-10-18T14:30", + "allDay": false + }, + "expectedUpdates": { + "recurrence": "DTSTART:20261018T143000Z;FREQ=YEARLY;COUNT=2", + "dateModified": "2026-10-12T08:00:00Z" + }, + "expectedFrontmatterPatch": [ + { + "op": "set", + "field": "recurrence", + "value": "DTSTART:20261018T143000Z;FREQ=YEARLY;COUNT=2" + }, + { + "op": "delete", + "field": "recurrence_original_date" + }, + { + "op": "set", + "field": "dateModified", + "value": "2026-10-12T08:00:00Z" + } + ], + "expectedClearedInstances": { + "complete": [], + "skipped": [] + }, + "expectedUnsetProperties": [ + "recurrence_original_date" + ] + } + ] + }, + { + "name": "native-landing-on-rule-date-keeps-two-identities", + "input": { + "tasks": [ + { + "id": "a", + "path": "a.md", + "title": "Annual", + "status": "open", + "priority": "normal", + "archived": false, + "scheduled": "2026-10-19", + "recurrence": "DTSTART:20261012;FREQ=WEEKLY;COUNT=3", + "recurrence_original_date": "2026-10-12", + "complete_instances": [ + "2026-10-12" + ], + "skipped_instances": [ + "2026-10-19" + ], + "googleCalendarMovedOriginalDates": [ + "2026-10-26" + ] + } + ], + "range": { + "start": "2026-10-01", + "end": "2026-11-01" + }, + "timezone": "America/New_York", + "now": "2026-10-12T08:00:00Z" + }, + "expectedEntries": [ + { + "id": "next-scheduled-a-2026-10-12", + "taskId": "a", + "title": "Annual", + "type": "scheduled", + "start": "2026-10-19", + "allDay": true, + "durationMinutes": 1440, + "isCompleted": true, + "isSkipped": false, + "startEditable": true, + "durationEditable": true, + "editReason": "next-occurrence", + "recurringKind": "next-scheduled", + "instanceDate": "2026-10-12", + "recurringTemplateTime": "09:00" + }, + { + "id": "recurring-a-2026-10-19", + "taskId": "a", + "title": "Annual", + "type": "recurring", + "start": "2026-10-19", + "allDay": true, + "durationMinutes": 1440, + "isCompleted": false, + "isSkipped": true, + "startEditable": true, + "durationEditable": true, + "editReason": "series-time", + "recurringKind": "pattern", + "instanceDate": "2026-10-19", + "recurringTemplateTime": "09:00" + } + ], + "edits": [] + }, + { + "name": "native-completed-identity-hidden", + "input": { + "tasks": [ + { + "id": "a", + "path": "a.md", + "title": "Annual", + "status": "open", + "priority": "normal", + "archived": false, + "scheduled": "2026-10-12", + "recurrence": "DTSTART:20261018;FREQ=YEARLY;COUNT=2", + "recurrence_original_date": "2026-10-18", + "complete_instances": [ + "2026-10-18" + ] + } + ], + "range": { + "start": "2026-10-01", + "end": "2026-11-01" + }, + "timezone": "America/New_York", + "now": "2026-10-12T08:00:00Z", + "settings": { + "showCompletedRecurringInstances": false + } + }, + "expectedEntries": [], + "edits": [] + }, + { + "name": "native-skipped-identity-hidden", + "input": { + "tasks": [ + { + "id": "a", + "path": "a.md", + "title": "Annual", + "status": "open", + "priority": "normal", + "archived": false, + "scheduled": "2026-10-12", + "recurrence": "DTSTART:20261018;FREQ=YEARLY;COUNT=2", + "recurrence_original_date": "2026-10-18", + "skipped_instances": [ + "2026-10-18" + ] + } + ], + "range": { + "start": "2026-10-01", + "end": "2026-11-01" + }, + "timezone": "America/New_York", + "now": "2026-10-12T08:00:00Z", + "settings": { + "showSkippedRecurringInstances": false + } + }, + "expectedEntries": [], + "edits": [] + }, + { + "name": "native-materialized-original-suppresses-moved-parent", + "input": { + "tasks": [ + { + "id": "a", + "path": "a.md", + "title": "Annual", + "status": "open", + "priority": "normal", + "archived": false, + "scheduled": "2026-10-12", + "recurrence": "DTSTART:20261018;FREQ=YEARLY;COUNT=2", + "recurrence_original_date": "2026-10-18" + } + ], + "range": { + "start": "2026-10-01", + "end": "2026-11-01" + }, + "timezone": "America/New_York", + "now": "2026-10-12T08:00:00Z", + "materializedOccurrences": { + "a": [ + "2026-10-18" + ] + } + }, + "expectedEntries": [], + "edits": [] + }, + { + "name": "native-moved-all-day-span-uses-placement-offset", + "input": { + "tasks": [ + { + "id": "a", + "path": "a.md", + "title": "Annual", + "status": "open", + "priority": "normal", + "archived": false, + "scheduled": "2026-10-12", + "recurrence": "DTSTART:20261018;FREQ=YEARLY;COUNT=2", + "recurrence_original_date": "2026-10-18", + "due": "2026-10-14" + } + ], + "range": { + "start": "2026-10-01", + "end": "2026-11-01" + }, + "timezone": "America/New_York", + "now": "2026-10-12T08:00:00Z", + "settings": { + "showScheduledToDueSpan": true + } + }, + "expectedEntries": [ + { + "id": "span-next-scheduled-a-2026-10-18-2026-10-12", + "taskId": "a", + "title": "Annual", + "type": "scheduledToDueSpan", + "start": "2026-10-12", + "allDay": true, + "durationMinutes": 4320, + "isCompleted": false, + "isSkipped": false, + "startEditable": false, + "durationEditable": false, + "editReason": "recurring-span-readonly", + "recurringKind": "next-scheduled", + "instanceDate": "2026-10-18", + "recurringTemplateTime": "09:00", + "end": "2026-10-15" + } + ], + "edits": [] + }, + { + "name": "native-moved-timed-span-segment-identities", + "input": { + "tasks": [ + { + "id": "a", + "path": "a.md", + "title": "Annual", + "status": "open", + "priority": "normal", + "archived": false, + "scheduled": "2026-10-12T09:00", + "recurrence": "DTSTART:20261018T100000Z;FREQ=YEARLY;COUNT=2", + "recurrence_original_date": "2026-10-18", + "due": "2026-10-14T17:00", + "timeEstimate": 60 + } + ], + "range": { + "start": "2026-10-01", + "end": "2026-11-01" + }, + "timezone": "America/New_York", + "now": "2026-10-12T08:00:00Z", + "settings": { + "showScheduledToDueSpan": true + } + }, + "expectedEntries": [ + { + "id": "span-next-scheduled-a-2026-10-18-2026-10-12", + "taskId": "a", + "title": "Annual", + "type": "scheduledToDueSpan", + "start": "2026-10-12T09:00", + "allDay": false, + "durationMinutes": 60, + "isCompleted": false, + "isSkipped": false, + "startEditable": false, + "durationEditable": false, + "editReason": "recurring-span-readonly", + "recurringKind": "next-scheduled", + "instanceDate": "2026-10-18", + "recurringTemplateTime": "09:00", + "end": "2026-10-12T10:00" + }, + { + "id": "span-next-scheduled-a-2026-10-18-2026-10-13", + "taskId": "a", + "title": "Annual", + "type": "scheduledToDueSpan", + "start": "2026-10-13T09:00", + "allDay": false, + "durationMinutes": 60, + "isCompleted": false, + "isSkipped": false, + "startEditable": false, + "durationEditable": false, + "editReason": "recurring-span-readonly", + "recurringKind": "next-scheduled", + "instanceDate": "2026-10-18", + "recurringTemplateTime": "09:00", + "end": "2026-10-13T10:00" + }, + { + "id": "span-next-scheduled-a-2026-10-18-2026-10-14", + "taskId": "a", + "title": "Annual", + "type": "scheduledToDueSpan", + "start": "2026-10-14T09:00", + "allDay": false, + "durationMinutes": 60, + "isCompleted": false, + "isSkipped": false, + "startEditable": false, + "durationEditable": false, + "editReason": "recurring-span-readonly", + "recurringKind": "next-scheduled", + "instanceDate": "2026-10-18", + "recurringTemplateTime": "09:00", + "end": "2026-10-14T10:00" + } + ], + "edits": [] + }, + { + "name": "native-recorded-original-visible-when-placement-outside-range", + "input": { + "tasks": [ + { + "id": "a", + "path": "a.md", + "title": "Annual", + "status": "open", + "priority": "normal", + "archived": false, + "scheduled": "2027-10-20", + "recurrence": "DTSTART:20261018;FREQ=YEARLY;COUNT=2", + "recurrence_original_date": "2026-10-18", + "complete_instances": [ + "2026-10-18" + ] + } + ], + "range": { + "start": "2026-10-01", + "end": "2026-11-01" + }, + "timezone": "America/New_York", + "now": "2026-10-12T08:00:00Z" + }, + "expectedEntries": [ + { + "id": "recurring-recorded-a-2026-10-18", + "taskId": "a", + "title": "Annual", + "type": "recurring", + "start": "2026-10-18", + "allDay": true, + "durationMinutes": 1440, + "isCompleted": true, + "isSkipped": false, + "startEditable": true, + "durationEditable": true, + "editReason": "recorded-time", + "recurringKind": "recorded", + "instanceDate": "2026-10-18", + "recurringTemplateTime": "09:00" + } + ], + "edits": [] + }, + { + "name": "native-dtstartless-move-pins-old-clock", + "input": { + "tasks": [ + { + "id": "a", + "path": "a.md", + "title": "Annual", + "status": "open", + "priority": "normal", + "archived": false, + "scheduled": "2026-10-18T09:15", + "recurrence": "FREQ=YEARLY;COUNT=2" + } + ], + "range": { + "start": "2026-10-01", + "end": "2026-11-01" + }, + "timezone": "America/New_York", + "now": "2026-10-12T08:00:00Z" + }, + "expectedEntries": [ + { + "id": "next-scheduled-a-2026-10-18", + "taskId": "a", + "title": "Annual", + "type": "scheduled", + "start": "2026-10-18T09:15", + "allDay": false, + "durationMinutes": 60, + "isCompleted": false, + "isSkipped": false, + "startEditable": true, + "durationEditable": true, + "editReason": "next-occurrence", + "recurringKind": "next-scheduled", + "instanceDate": "2026-10-18", + "recurringTemplateTime": "09:15" + } + ], + "edits": [ + { + "entryId": "next-scheduled-a-2026-10-18", + "change": { + "kind": "drop", + "start": "2026-10-12T11:30", + "allDay": false + }, + "expectedUpdates": { + "scheduled": "2026-10-12T11:30", + "recurrence": "DTSTART:20261018T091500Z;FREQ=YEARLY;COUNT=2", + "recurrence_original_date": "2026-10-18", + "dateModified": "2026-10-12T08:00:00Z" + }, + "expectedFrontmatterPatch": [ + { + "op": "set", + "field": "scheduled", + "value": "2026-10-12T11:30" + }, + { + "op": "set", + "field": "recurrence", + "value": "DTSTART:20261018T091500Z;FREQ=YEARLY;COUNT=2" + }, + { + "op": "set", + "field": "recurrence_original_date", + "value": "2026-10-18" + }, + { + "op": "set", + "field": "dateModified", + "value": "2026-10-12T08:00:00Z" + } + ], + "expectedClearedInstances": { + "complete": [], + "skipped": [] + } + } + ] + }, + { + "name": "native-legacy-off-pattern-does-not-guess-identity", + "input": { + "tasks": [ + { + "id": "a", + "path": "a.md", + "title": "Annual", + "status": "open", + "priority": "normal", + "archived": false, + "scheduled": "2026-10-12", + "recurrence": "DTSTART:20261018;FREQ=YEARLY;COUNT=2" + } + ], + "range": { + "start": "2026-10-01", + "end": "2026-11-01" + }, + "timezone": "America/New_York", + "now": "2026-10-12T08:00:00Z" + }, + "expectedEntries": [ + { + "id": "next-scheduled-a-2026-10-12", + "taskId": "a", + "title": "Annual", + "type": "scheduled", + "start": "2026-10-12", + "allDay": true, + "durationMinutes": 1440, + "isCompleted": false, + "isSkipped": false, + "startEditable": true, + "durationEditable": true, + "editReason": "next-occurrence", + "recurringKind": "next-scheduled", + "instanceDate": "2026-10-12", + "recurringTemplateTime": "09:00" + }, + { + "id": "recurring-a-2026-10-18", + "taskId": "a", + "title": "Annual", + "type": "recurring", + "start": "2026-10-18", + "allDay": true, + "durationMinutes": 1440, + "isCompleted": false, + "isSkipped": false, + "startEditable": true, + "durationEditable": true, + "editReason": "series-time", + "recurringKind": "pattern", + "instanceDate": "2026-10-18", + "recurringTemplateTime": "09:00" + } + ], + "edits": [ + { + "entryId": "next-scheduled-a-2026-10-12", + "change": { + "kind": "drop", + "start": "2026-10-13", + "allDay": true + }, + "expectedUpdates": { + "scheduled": "2026-10-13", + "dateModified": "2026-10-12T08:00:00Z" + }, + "expectedFrontmatterPatch": [ + { + "op": "set", + "field": "scheduled", + "value": "2026-10-13" + }, + { + "op": "set", + "field": "dateModified", + "value": "2026-10-12T08:00:00Z" + } + ], + "expectedClearedInstances": { + "complete": [], + "skipped": [] + } + } + ] + } +] diff --git a/fixtures/calendar/scenarios.json b/fixtures/calendar/scenarios.json index 3284aa4..999e172 100644 --- a/fixtures/calendar/scenarios.json +++ b/fixtures/calendar/scenarios.json @@ -753,7 +753,8 @@ "scheduled": "2026-03-07T11:00", "complete_instances": [], "skipped_instances": [], - "dateModified": "2026-03-08T16:00:00Z" + "dateModified": "2026-03-08T16:00:00Z", + "recurrence_original_date": "2026-03-08" }, "expectedFrontmatterPatch": [ { @@ -769,6 +770,11 @@ "op": "delete", "field": "skipped_instances" }, + { + "op": "set", + "field": "recurrence_original_date", + "value": "2026-03-08" + }, { "op": "set", "field": "dateModified", @@ -1078,7 +1084,8 @@ }, "expectedUpdates": { "scheduled": "2026-03-02T12:00", - "dateModified": "2026-03-08T16:00:00Z" + "dateModified": "2026-03-08T16:00:00Z", + "recurrence": "DTSTART:20260302;FREQ=WEEKLY;COUNT=3;BYDAY=MO" }, "expectedFrontmatterPatch": [ { @@ -1086,6 +1093,11 @@ "field": "scheduled", "value": "2026-03-02T12:00" }, + { + "op": "set", + "field": "recurrence", + "value": "DTSTART:20260302;FREQ=WEEKLY;COUNT=3;BYDAY=MO" + }, { "op": "set", "field": "dateModified", @@ -1329,7 +1341,8 @@ }, "expectedUpdates": { "scheduled": "2026-03-09T12:00", - "dateModified": "2026-03-08T16:00:00Z" + "dateModified": "2026-03-08T16:00:00Z", + "recurrence_original_date": "2026-03-07" }, "expectedFrontmatterPatch": [ { @@ -1337,6 +1350,11 @@ "field": "scheduled", "value": "2026-03-09T12:00" }, + { + "op": "set", + "field": "recurrence_original_date", + "value": "2026-03-07" + }, { "op": "set", "field": "dateModified", diff --git a/package-lock.json b/package-lock.json index e6dab91..ad43bc5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@tasknotes/model", - "version": "0.3.0-rc.17", + "version": "0.3.0-rc.18", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tasknotes/model", - "version": "0.3.0-rc.17", + "version": "0.3.0-rc.18", "license": "MIT", "dependencies": { "rrule": "^2.8.1", diff --git a/package.json b/package.json index feaee21..6666344 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tasknotes/model", - "version": "0.3.0-rc.17", + "version": "0.3.0-rc.18", "description": "TaskNotes model, mapping, validation, recurrence, and operation-planning reference implementation.", "license": "MIT", "type": "module", diff --git a/scripts/smoke-package.mjs b/scripts/smoke-package.mjs index e1bcd23..0063788 100644 --- a/scripts/smoke-package.mjs +++ b/scripts/smoke-package.mjs @@ -47,7 +47,7 @@ try { for (const [filename, content] of [ ["calendar.mjs", 'import { buildCalendarEntries, createCalendarEntryIndex, planCalendarEdit } from "@tasknotes/model/calendar";\nimport { calendarFixtures } from "@tasknotes/model/calendar/fixtures";\nif (!planCalendarEdit || !createCalendarEntryIndex || buildCalendarEntries(calendarFixtures[0].input).length !== calendarFixtures[0].expectedEntries.length) throw new Error("ESM calendar exports failed");\n'], - ["calendar.cjs", 'const { buildCalendarEntries } = require("@tasknotes/model/calendar");\nconst { calendarFixtures } = require("@tasknotes/model/calendar/fixtures");\nconst raw = require("@tasknotes/model/fixtures/calendar/scenarios.json");\nif (raw.length !== calendarFixtures.length || buildCalendarEntries(raw[0].input).length !== raw[0].expectedEntries.length) throw new Error("CJS calendar exports failed");\n'], + ["calendar.cjs", 'const { buildCalendarEntries } = require("@tasknotes/model/calendar");\nconst { calendarFixtures } = require("@tasknotes/model/calendar/fixtures");\nconst raw = [...require("@tasknotes/model/fixtures/calendar/scenarios.json"), ...require("@tasknotes/model/fixtures/calendar/moved-occurrences.json")];\nif (raw.length !== calendarFixtures.length || buildCalendarEntries(raw[0].input).length !== raw[0].expectedEntries.length) throw new Error("CJS calendar exports failed");\n'], ["calendar.ts", 'import { buildCalendarEntries, type CalendarEntry } from "@tasknotes/model/calendar";\nimport { calendarFixtures, type CalendarFixture } from "@tasknotes/model/calendar/fixtures";\nconst fixture: CalendarFixture = calendarFixtures[0];\nconst entries: CalendarEntry[] = buildCalendarEntries(fixture.input);\nvoid entries;\n'], ]) writeFileSync(join(tempRoot, filename), content); diff --git a/src/calendar-fixtures.ts b/src/calendar-fixtures.ts index 31fae6d..7567eb2 100644 --- a/src/calendar-fixtures.ts +++ b/src/calendar-fixtures.ts @@ -1,4 +1,5 @@ import scenarios from "../fixtures/calendar/scenarios.json"; +import movedOccurrences from "../fixtures/calendar/moved-occurrences.json"; import type { BuildCalendarEntriesInput, CalendarChange, CalendarEntry } from "./calendar.js"; import type { TaskInfo, TaskPatchOperation, TaskUpdateInput } from "./types.js"; @@ -15,5 +16,19 @@ export interface CalendarFixture { }>; } -/** Static cross-host regression contract; no runtime projection used to produce expectations. */ -export const calendarFixtures = scenarios as CalendarFixture[]; +/** Static cross-host regression contract; no runtime projection used to produce expectations. + * JSON cannot encode undefined: expectedUnsetProperties expands explicit update deletions. + */ +export const calendarFixtures: CalendarFixture[] = [...scenarios, ...movedOccurrences].map((fixture) => ({ + ...fixture, + edits: fixture.edits.map((raw) => { + const edit = raw as CalendarFixture["edits"][number] & { expectedUnsetProperties?: Array }; + return { + ...edit, + expectedUpdates: { + ...edit.expectedUpdates, + ...Object.fromEntries((edit.expectedUnsetProperties || []).map((key) => [key, undefined])), + }, + }; + }), +})) as CalendarFixture[]; diff --git a/src/calendar.ts b/src/calendar.ts index f8a78a9..a9f4bb9 100644 --- a/src/calendar.ts +++ b/src/calendar.ts @@ -1,4 +1,5 @@ -import { generateRecurringInstances } from "./recurrence.js"; +import { addDTSTARTToRecurrenceRule, generateRecurringInstances } from "./recurrence.js"; +import { normalizeRecurrenceOriginalDate } from "./mapping.js"; import { normalizeTaskReference, taskInfoUpdatesToFrontmatterPatch } from "./operations.js"; import type { FieldMapping, TaskInfo, TaskOperationPlan, TaskUpdateInput } from "./types.js"; import { @@ -278,6 +279,10 @@ function buildTaskEntries( (s.showRecurring || s.showCompletedRecurringInstances || s.showSkippedRecurringInstances) ) { const nextDate = dayOf(task.scheduled); + const nextIdentity = + (task.recurrence_anchor || "scheduled") === "scheduled" + ? normalizeRecurrenceOriginalDate(task.recurrence_original_date) || nextDate + : nextDate; const dtstartTime = /DTSTART(?:;[^:\r\n]+)?:\d{8}T(\d{2})(\d{2})/.exec(task.recurrence); const templateTime = dtstartTime ? `${dtstartTime[1]}:${dtstartTime[2]}` @@ -291,11 +296,12 @@ function buildTaskEntries( -Math.max(spanOffset || 0, Math.ceil((task.timeEstimate || 0) / 1440)), ); const moved = new Set(task.googleCalendarMovedOriginalDates || []); + if (nextIdentity !== nextDate) moved.add(nextIdentity); if (task.googleCalendarExceptionOriginalScheduled) moved.add(calendarDay(task.googleCalendarExceptionOriginalScheduled)); - const dates = new Map(); + const dates: Array<[string, CalendarRecurringKind]> = []; if (s.showRecurring) { - dates.set(nextDate, "next-scheduled"); + dates.push([nextIdentity, "next-scheduled"]); // Date-only bounds avoid every process-local date parser in recurrence.ts. // RRULE dates are civil anchors, like scheduled/complete_instances. rrule's // TZID conversion depends on the process zone and may shift the anchor day. @@ -311,20 +317,22 @@ function buildTaskEntries( new Date(`${endDay}T00:00:00Z`), )) { const day = date.toISOString().slice(0, 10); - if (day >= searchStart && day < endDay && day !== nextDate && !moved.has(day)) - dates.set(day, "pattern"); + if (day >= searchStart && day < endDay && day !== nextIdentity && !moved.has(day)) + dates.push([day, "pattern"]); } } for (const day of [ ...(s.showCompletedRecurringInstances ? task.complete_instances || [] : []), ...(s.showSkippedRecurringInstances ? task.skipped_instances || [] : []), ].sort()) { - if (day >= startDay && day < endDay && !dates.has(day)) dates.set(day, "recorded"); + if (day >= searchStart && day < endDay) dates.push([day, "recorded"]); } + const emitted = new Set(); for (const [day, kind] of dates) { const completed = !!task.complete_instances?.includes(day); const skipped = !!task.skipped_instances?.includes(day); if ( + emitted.has(day) || materialized.has(day) || (kind !== "recorded" && ((completed && !s.showCompletedRecurringInstances) || @@ -339,11 +347,12 @@ function buildTaskEntries( : timed(task.scheduled) ? `${day}T${clock}` : day; + const placementDay = kind === "next-scheduled" ? nextDate : day; const recurringEntries = spanOffset !== null && task.due ? spans( start, - `${addCalendarDays(day, spanOffset)}${timed(task.due) ? `T${civil(task.due).slice(11, 16)}` : ""}`, + `${addCalendarDays(placementDay, spanOffset)}${timed(task.due) ? `T${civil(task.due).slice(11, 16)}` : ""}`, ) : [scheduled(start)]; for (const entry of recurringEntries) { @@ -368,7 +377,10 @@ function buildTaskEntries( ? "series-time" : "recorded-time", }); - if (visible(entry)) generated.push(entry); + if (visible(entry)) { + generated.push(entry); + emitted.add(day); + } } } entries.push(...generated); @@ -608,6 +620,56 @@ export function planCalendarEdit({ updates.googleCalendarExceptionOriginalScheduled = calendarDay(updates.scheduled) !== original ? original : undefined; } + // A calendar move changes the pending placement, not the native rule identity. + // Freeze DTSTART against the old cursor before it can become the new seed. + if (updates.scheduled && updates.scheduled !== task.scheduled && task.recurrence) { + const originalCivilTask = { + ...task, + scheduled: task.scheduled + ? calendarInstantToDate( + calendarDateToInstant(task.scheduled, timezone), + timezone, + !task.scheduled.includes("T"), + ) + : undefined, + }; + if (!/DTSTART(?:;[^:\r\n]+)?:/.test(task.recurrence)) { + const pinned = addDTSTARTToRecurrenceRule(originalCivilTask); + if (pinned) updates.recurrence = pinned; + } + if ((task.recurrence_anchor || "scheduled") === "scheduled") { + const previous = task.scheduled ? dayOf(task.scheduled) : ""; + const next = dayOf(updates.scheduled); + if (previous !== next) { + let original = normalizeRecurrenceOriginalDate(task.recurrence_original_date); + if (!original && !tracksException && previous) { + const target = new Date(`${previous}T00:00:00Z`); + const occurrences = generateRecurringInstances( + { + ...originalCivilTask, + recurrence: task.recurrence.replace(/;TZID=[^:;\r\n]+/g, ""), + }, + target, + target, + ); + if (occurrences.some((date) => date.toISOString().slice(0, 10) === previous)) + original = previous; + } + if (task.recurrence_original_date || original) { + updates.recurrence_original_date = original && next !== original ? original : undefined; + } + } + } else if (task.recurrence_original_date) { + updates.recurrence_original_date = undefined; + } + } else if ( + updates.recurrence !== undefined && + updates.recurrence !== task.recurrence && + task.recurrence_original_date + ) { + // Pattern/DTSTART edits establish a new series contract. + updates.recurrence_original_date = undefined; + } updates.dateModified = now; return { kind: "calendar.edit", diff --git a/src/defaults.ts b/src/defaults.ts index 9571ffc..aaa2a0e 100644 --- a/src/defaults.ts +++ b/src/defaults.ts @@ -21,6 +21,7 @@ export const DEFAULT_FIELD_MAPPING: FieldMapping = { dateModified: "dateModified", recurrence: "recurrence", recurrenceAnchor: "recurrence_anchor", + recurrenceOriginalDate: "recurrence_original_date", recurrenceParent: "recurrence_parent", occurrenceDate: "occurrence_date", occurrenceMaterialization: "occurrence_materialization", diff --git a/src/mapping.ts b/src/mapping.ts index 05f0365..d76624a 100644 --- a/src/mapping.ts +++ b/src/mapping.ts @@ -1,6 +1,6 @@ import { DEFAULT_FIELD_MAPPING, DEFAULT_PRIORITIES, DEFAULT_STATUSES } from "./defaults"; import { normalizeAttachmentList } from "./attachments"; -import { validateCompleteInstances } from "./date"; +import { validateCompleteInstances, validateDateString } from "./date"; import type { FieldMapping, FieldMappingKey, @@ -23,7 +23,33 @@ export const VALID_DEPENDENCY_RELTYPES: TaskDependencyRelType[] = [ "STARTTOFINISH", ]; +export const DEFAULT_RECURRENCE_ORIGINAL_DATE_PROPERTY = "recurrence_original_date"; + +function withResolvedMapping(mapping: FieldMapping): FieldMapping & { recurrenceOriginalDate: string } { + return { + ...mapping, + recurrenceOriginalDate: toUserField(mapping, "recurrenceOriginalDate"), + }; +} + +/** Only real, strict calendar dates may become occurrence identities. */ +export function normalizeRecurrenceOriginalDate(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + if (!/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) return undefined; + try { + return validateDateString(trimmed); + } catch { + return undefined; + } +} + export function toUserField(mapping: FieldMapping, internalName: FieldMappingKey): string { + if (internalName === "recurrenceOriginalDate") { + return typeof mapping.recurrenceOriginalDate === "string" + ? mapping.recurrenceOriginalDate + : DEFAULT_RECURRENCE_ORIGINAL_DATE_PROPERTY; + } return mapping[internalName]; } @@ -141,6 +167,8 @@ export function mapTaskFromFrontmatter( if (frontmatter[mapping.recurrence] !== undefined) { mapped.recurrence = normalizeStringValue(frontmatter[mapping.recurrence]); } + const originalDate = normalizeRecurrenceOriginalDate(frontmatter[toUserField(mapping, "recurrenceOriginalDate")]); + if (originalDate) mapped.recurrence_original_date = originalDate; if (frontmatter[mapping.recurrenceAnchor] !== undefined) { const anchorValue = frontmatter[mapping.recurrenceAnchor]; if (anchorValue === "scheduled" || anchorValue === "completion") { @@ -279,6 +307,8 @@ export function mapTaskToFrontmatter( if (taskData.timeEstimate !== undefined) frontmatter[mapping.timeEstimate] = taskData.timeEstimate; if (taskData.completedDate !== undefined) frontmatter[mapping.completedDate] = taskData.completedDate; if (taskData.recurrence !== undefined) frontmatter[mapping.recurrence] = taskData.recurrence; + const originalDate = normalizeRecurrenceOriginalDate(taskData.recurrence_original_date); + if (originalDate) frontmatter[toUserField(mapping, "recurrenceOriginalDate")] = originalDate; if (taskData.recurrence_anchor !== undefined) frontmatter[mapping.recurrenceAnchor] = taskData.recurrence_anchor; if (taskData.recurrence_parent !== undefined) frontmatter[mapping.recurrenceParent] = taskData.recurrence_parent; if (taskData.occurrence_date !== undefined) frontmatter[mapping.occurrenceDate] = taskData.occurrence_date; @@ -370,7 +400,7 @@ export function lookupMappingKey( mapping: FieldMapping, frontmatterPropertyName: string ): FieldMappingKey | null { - for (const [mappingKey, propertyName] of Object.entries(mapping)) { + for (const [mappingKey, propertyName] of Object.entries(withResolvedMapping(mapping))) { if (propertyName === frontmatterPropertyName) { return mappingKey as FieldMappingKey; } @@ -387,22 +417,23 @@ export function isPropertyForField( propertyName: string, internalField: FieldMappingKey ): boolean { - return mapping[internalField] === propertyName; + return toUserField(mapping, internalField) === propertyName; } export function toUserFields(mapping: FieldMapping, internalFields: FieldMappingKey[]): string[] { - return internalFields.map((field) => mapping[field]); + return internalFields.map((field) => toUserField(mapping, field)); } export function validateFieldMapping(mapping: FieldMapping): { valid: boolean; errors: string[] } { const errors: string[] = []; - const fields = Object.keys(mapping) as FieldMappingKey[]; + const resolved = withResolvedMapping(mapping); + const fields = Object.keys(resolved) as FieldMappingKey[]; for (const field of fields) { - if (!mapping[field] || mapping[field].trim() === "") { + if (!resolved[field] || resolved[field].trim() === "") { errors.push(`Field "${field}" cannot be empty`); } } - const values = Object.values(mapping); + const values = Object.values(resolved); if (values.length !== new Set(values).size) { errors.push("Field mappings must have unique property names"); } diff --git a/src/operations.ts b/src/operations.ts index 5af9d6e..728dd61 100644 --- a/src/operations.ts +++ b/src/operations.ts @@ -22,6 +22,7 @@ import { parseLinkToPath, readAssigneeIds, serializeDependencies, + toUserField, } from "./mapping"; import type { FieldMapping, @@ -1318,6 +1319,7 @@ function fieldNameForTaskProperty(fieldMapping: FieldMapping, property: keyof Ta googleCalendarMovedOriginalDates: "googleCalendarMovedOriginalDates", recurrence: "recurrence", recurrence_anchor: "recurrenceAnchor", + recurrence_original_date: "recurrenceOriginalDate", complete_instances: "completeInstances", skipped_instances: "skippedInstances", recurrence_parent: "recurrenceParent", @@ -1333,7 +1335,7 @@ function fieldNameForTaskProperty(fieldMapping: FieldMapping, property: keyof Ta sortOrder: "sortOrder", }; const mappingKey = explicit[property]; - return mappingKey ? fieldMapping[mappingKey] : undefined; + return mappingKey ? toUserField(fieldMapping, mappingKey) : undefined; } function isOccurrenceMaterializationMode(value: unknown): value is OccurrenceMaterializationMode { diff --git a/src/schema.ts b/src/schema.ts index 500979e..564f3e2 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -69,6 +69,7 @@ export const taskInfoSchema = z.object({ assignees: z.array(z.string().regex(/\S/)).optional(), recurrence: z.string().optional(), recurrence_anchor: z.enum(["scheduled", "completion"]).optional(), + recurrence_original_date: z.string().optional(), complete_instances: z.array(z.string()).optional(), skipped_instances: z.array(z.string()).optional(), recurrence_parent: z.string().optional(), diff --git a/src/types.ts b/src/types.ts index 6b69149..a6e63b2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -56,6 +56,8 @@ export interface TaskInfo { assignees?: string[]; recurrence?: string; recurrence_anchor?: RecurrenceAnchor; + /** Original rule date of a pending moved native recurring occurrence. */ + recurrence_original_date?: string; complete_instances?: string[]; skipped_instances?: string[]; recurrence_parent?: string; @@ -122,6 +124,8 @@ export interface FieldMapping { dateModified: string; recurrence: string; recurrenceAnchor: string; + /** Optional for mappings saved before this field existed. */ + recurrenceOriginalDate?: string; recurrenceParent: string; occurrenceDate: string; occurrenceMaterialization: string; diff --git a/test/calendar-moved-identity.test.mjs b/test/calendar-moved-identity.test.mjs new file mode 100644 index 0000000..9148197 --- /dev/null +++ b/test/calendar-moved-identity.test.mjs @@ -0,0 +1,96 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { buildCalendarEntries, createCalendarEntryIndex, planCalendarEdit } from "../dist/esm/calendar.js"; +import { calendarFixtures } from "../dist/esm/calendar-fixtures.js"; +import { DEFAULT_FIELD_MAPPING } from "../dist/esm/defaults.js"; +import { + isRecognizedProperty, + mapTaskFromFrontmatter, + mapTaskToFrontmatter, + normalizeRecurrenceOriginalDate, + toUserField, + validateFieldMapping, +} from "../dist/esm/mapping.js"; +import { applyFrontmatterPatch, taskInfoUpdatesToFrontmatterPatch } from "../dist/esm/operations.js"; +import { taskInfoSchema } from "../dist/esm/schema.js"; + +const moved = () => structuredClone(calendarFixtures.find((f) => f.name === "native-moved-annual-preserves-and-clears-identity").input); + +test("original identity mapping is strict, configurable and compatible with old settings", () => { + const mapping = { ...DEFAULT_FIELD_MAPPING, recurrenceOriginalDate: "rule_day" }; + const input = moved(); + const task = taskInfoSchema.parse(input.tasks[0]); + assert.equal(task.recurrence_original_date, "2026-10-18"); + const frontmatter = mapTaskToFrontmatter(mapping, task); + assert.equal(frontmatter.rule_day, "2026-10-18"); + assert.equal(frontmatter.recurrence_original_date, undefined); + assert.equal(mapTaskFromFrontmatter(mapping, frontmatter, task.path).recurrence_original_date, "2026-10-18"); + assert.deepEqual(taskInfoUpdatesToFrontmatterPatch({ recurrence_original_date: undefined }, mapping), [{ op: "delete", field: "rule_day" }]); + for (const invalid of ["2026-02-30", "2026-13-01", "2026-10-18T09:00", "2026-1-2", "", null, 20261018, ["2026-10-18"]]) { + assert.equal(normalizeRecurrenceOriginalDate(invalid), undefined); + assert.equal(mapTaskFromFrontmatter(mapping, { rule_day: invalid }, task.path).recurrence_original_date, undefined); + assert.equal(mapTaskToFrontmatter(mapping, { recurrence_original_date: invalid }).rule_day, undefined); + } + assert.equal(normalizeRecurrenceOriginalDate(" 2028-02-29 "), "2028-02-29"); + const legacy = { ...DEFAULT_FIELD_MAPPING }; + delete legacy.recurrenceOriginalDate; + assert.equal(toUserField(legacy, "recurrenceOriginalDate"), "recurrence_original_date"); + assert.equal(isRecognizedProperty(legacy, "recurrence_original_date"), true); + assert.equal(validateFieldMapping({ ...legacy, due: "recurrence_original_date" }).valid, false); + assert.equal(mapTaskFromFrontmatter(legacy, { recurrence_original_date: "2026-10-18" }, task.path).recurrence_original_date, "2026-10-18"); +}); + +test("persisted calendar moves preserve identity through clock/estimate edits, then delete it on return or series edit", () => { + const context = moved(); + const mapping = { ...DEFAULT_FIELD_MAPPING, recurrenceOriginalDate: "rule_day" }; + let task = context.tasks[0]; + let frontmatter = mapTaskToFrontmatter(mapping, task); + const index = createCalendarEntryIndex(context); + const edit = (start, kind = "drop", end) => { + index.setTasks([task]); + const entry = index.entriesInRange(context.range).find((e) => e.recurringKind === "next-scheduled"); + const plan = planCalendarEdit({ task, entry, change: { kind, start, end, allDay: !start.includes("T") }, timezone: context.timezone, now: context.now, fieldMapping: mapping }); + frontmatter = applyFrontmatterPatch(frontmatter, plan.frontmatterPatch); + task = { ...task, ...plan.updates }; + assert.equal(mapTaskFromFrontmatter(mapping, frontmatter, task.path).recurrence_original_date, task.recurrence_original_date); + return plan; + }; + edit("2027-10-18"); + assert.equal(frontmatter.rule_day, "2026-10-18"); + assert.equal(buildCalendarEntries({ ...context, tasks: [task] }).filter((e) => e.start === "2027-10-18").length, 2); + edit("2027-10-18T11:30"); + edit("2027-10-18T11:30", "resize", "2027-10-18T12:15"); + assert.equal(frontmatter.rule_day, "2026-10-18"); + const back = edit("2026-10-18T11:30"); + assert.ok(back.frontmatterPatch.some((p) => p.op === "delete" && p.field === "rule_day")); + assert.equal(Object.hasOwn(frontmatter, "rule_day"), false); + index.setTasks([task]); + assert.deepEqual(index.entriesInRange(context.range), buildCalendarEntries({ ...context, tasks: [task] })); + assert.equal(index.entriesInRange(context.range).filter((e) => e.instanceDate === "2026-10-18").length, 1); + + // A series clock edit is not another move of the pending occurrence. + task = context.tasks[0]; + const pattern = buildCalendarEntries(context).find((e) => e.recurringKind === "pattern"); + const plan = planCalendarEdit({ task, entry: pattern, change: { kind: "drop", start: "2027-10-18T14:00", allDay: false }, timezone: context.timezone, now: context.now, fieldMapping: mapping }); + assert.equal(plan.updates.recurrence_original_date, undefined); + assert.ok(plan.frontmatterPatch.some((p) => p.op === "delete" && p.field === "rule_day")); +}); + +test("native identity never aliases completion anchors, malformed markers or a different materialized occurrence", () => { + const input = moved(); + input.range.end = "2026-11-01"; + for (const task of [ + { ...input.tasks[0], recurrence_original_date: "2026-02-30" }, + { ...input.tasks[0], recurrence_anchor: "completion" }, + ]) { + const entries = buildCalendarEntries({ ...input, tasks: [task] }); + assert.equal(entries.find((e) => e.recurringKind === "next-scheduled").instanceDate, "2026-10-12"); + assert.ok(entries.some((e) => e.start === "2026-10-18")); + } + const child = { ...input.tasks[0], id: "child", path: "child.md", recurrence: undefined, recurrence_parent: "[[a]]", occurrence_date: "2026-10-18", scheduled: "2026-10-13" }; + assert.equal(buildCalendarEntries({ ...input, tasks: [input.tasks[0], child] }).filter((e) => e.taskId === "a").length, 0); + child.occurrence_date = "2026-10-12"; + assert.equal(buildCalendarEntries({ ...input, tasks: [input.tasks[0], child] }).find((e) => e.taskId === "a").instanceDate, "2026-10-18"); + const history = buildCalendarEntries({ ...input, tasks: [{ ...input.tasks[0], googleCalendarMovedOriginalDates: ["2027-10-18"], complete_instances: ["2027-10-18"] }], range: { start: "2027-10-01", end: "2027-11-01" } }); + assert.deepEqual(history.map((e) => [e.recurringKind, e.instanceDate, e.isCompleted]), [["recorded", "2027-10-18", true]]); +});