diff --git a/CHANGELOG.md b/CHANGELOG.md index ef7f698..c5ca944 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 0.3.0-rc.17 + +### Added + +- Pure `@tasknotes/model/calendar` projection for scheduled tasks, deadlines, scheduled-to-due spans, recurring occurrences, materialized children, tracked sessions, overdue-on-today display, duration rules and gesture permissions. Timezone and clock inputs are explicit. +- Calendar edit plans using mapped frontmatter patches, including independent next-occurrence and DTSTART clock edits, reschedule completion/skip clearing, estimate resizes and time-entry changes. +- A bounded incremental per-task/range index and packaged shared fixtures at `@tasknotes/model/calendar/fixtures`, verified through uncached and cached projection, edits, ESM/CJS consumption and multiple runtime timezones. + +### Fixed + +- Calendar index membership/order changes retain explicit host revisions and cached expansions for unchanged task objects. +- Calendar recurrence day expansion no longer depends on the JavaScript process timezone for RFC TZID rules. Pattern edits preserve multiline RRULE/EXDATE data; duration ends in the second DST overlap clock retain their instant. + ## 0.3.0-rc.16 ### Fixed diff --git a/README.md b/README.md index 76de7e1..64b8eee 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ It intentionally contains no Obsidian API usage, no vault IO, no process exits, - recurrence evaluation and schedule advancement - materialized occurrence identity, creation, completion, skip, and parent reconciliation plans - time tracking entry planning and duration calculation +- calendar entry projection, calendar edit plans, and incremental range caching - validation helpers - host-independent operation plans for common task mutations - spec-normalized adapter helpers for CLI and mdbase-style consumers @@ -50,6 +51,8 @@ The package exports both the root module and focused subpath modules: | `@tasknotes/model/schema` | Zod schemas for model validation | | `@tasknotes/model/recurrence` | Recurrence evaluation, DTSTART handling, and schedule recalculation | | `@tasknotes/model/time` | Time-entry sanitizing, timer plans, and duration totals | +| `@tasknotes/model/calendar` | Neutral calendar entries, explicit-zone dates, calendar edit plans, and incremental index | +| `@tasknotes/model/calendar/fixtures` | Typed shared calendar building/editing fixtures (not in the root barrel) | | `@tasknotes/model/validation` | Task and time-entry validation | | `@tasknotes/model/operations` | Host-independent task mutation planning, including materialized occurrence plans | | `@tasknotes/model/frontmatter` | Markdown task document parse/serialize helpers | @@ -57,6 +60,75 @@ The package exports both the root module and focused subpath modules: | `@tasknotes/model/starter` | The published TaskNotes starter type (`tasknotes.task` pack seed), pinned to mdbase-contracts by the test suite. Use it, not the plugin defaults in `defaults`, to build starter types | | `@tasknotes/model/conformance` | tasknotes-spec conformance operation dispatcher | +## Calendar contract + +```ts +import { buildCalendarEntries, createCalendarEntryIndex, planCalendarEdit } from "@tasknotes/model/calendar"; + +const context = { + timezone: "America/New_York", + now: "2026-03-08T16:00:00Z", + settings: { showScheduled: true, showDue: true, showRecurring: true }, +}; +const range = { start: "2026-03-01", end: "2026-04-01" }; // exclusive end +const entries = buildCalendarEntries({ tasks, range, ...context }); +const index = createCalendarEntryIndex(context); +index.setTasks(tasks); +index.upsert(task.id || task.path, task, revision); // revision optional; defaults to dateModified +const sameEntries = index.entriesInRange(range); +const plan = planCalendarEdit({ + task, entry: entries[0], + change: { kind: "drop", start: "2026-03-09T11:00", allDay: false }, + timezone: context.timezone, now: context.now, fieldMapping, +}); +// After any confirmation: apply plan.frontmatterPatch, or persist plan.updates exactly. +// Do NOT pass through automatic recurrence-schedule recalculation. +``` + +- Inputs are validated task dates, an IANA timezone, and explicit `now`. Date-only and + floating datetimes are civil values in that zone; offset values are instants. + DST overlaps choose the earlier clock, gaps move forward. Ambiguous duration ends + retain an ISO instant. Timed durations are elapsed minutes; all-day durations are + nominal calendar days × 1440. Invalid dates/zones/ranges throw. +- Entries contain no task object, renderer types or colours. Join `taskId` back to + the host task/row. Stable IDs prefer task `id`, falling back to `path`. IDs must + uniquely identify tasks. `end` is exclusive; absent `end` uses the explicit + `durationMinutes` renderer default (60 minutes or one day unless configured). +- Task visibility settings use the Bases names. Defaults match the plugin: + scheduled/due/recurring/time entries and completed/skipped history on, spans and + overdue-on-today off. Pass configured completed status values, not status IDs. + `calendarView` plus `showAllDaySlot` covers list-specific all-day filtering. + Layout/weekends and external/property/timeblock visibility stay with hosts. +- Scheduled→due all-day spans include the due day. Timed spans are work blocks on + each day at the scheduled clock, **not** a single continuous timed interval. + Due entries are 30-minute deadline markers clipped to midnight, independent of + estimates. Only finished time entries are projected. +- Recurrence uses the model RRULE implementation with civil occurrence dates. + Stored RFC TZID/EXDATE properties are retained; TZID is removed only from parser + input to avoid process-zone conversion of occurrence-day anchors. The selected + calendar zone determines display clocks. Due stays a standalone deadline rather + 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. +- 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 + note. Pattern all-day/timed changes leave `scheduled` independent. Recurring + 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. +- 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 + child changes update parent suppression. `setContext` invalidates on settings, + timezone or materialization changes; `now` changes invalidate only when overdue + display uses it. Returned entries can be annotated without corrupting the cache. +- Shared expectations are available as `calendarFixtures` from + `@tasknotes/model/calendar/fixtures` and raw JSON at + `@tasknotes/model/fixtures/calendar/scenarios.json`. Every scenario includes full + inputs and expected entries; edits include exact model updates and frontmatter + patches. Both ESM and CJS consumers can use them without a JSON loader. + ## Examples Map TaskNotes frontmatter into normalized task data: diff --git a/fixtures/calendar/scenarios.json b/fixtures/calendar/scenarios.json new file mode 100644 index 0000000..3284aa4 --- /dev/null +++ b/fixtures/calendar/scenarios.json @@ -0,0 +1,2573 @@ +[ + { + "name": "all-day-estimate-and-same-day-due", + "input": { + "tasks": [ + { + "id": "a", + "path": "a.md", + "title": "A", + "status": "open", + "priority": "normal", + "archived": false, + "scheduled": "2026-03-07", + "timeEstimate": 1500, + "due": "2026-03-07" + } + ], + "settings": {}, + "range": { + "start": "2026-03-01", + "end": "2026-04-01" + }, + "timezone": "UTC", + "now": "2026-03-08T16:00:00Z" + }, + "expectedEntries": [ + { + "id": "scheduled-a", + "taskId": "a", + "title": "A", + "type": "scheduled", + "start": "2026-03-07", + "allDay": true, + "durationMinutes": 2880, + "isCompleted": false, + "isSkipped": false, + "startEditable": true, + "durationEditable": true, + "editReason": "date", + "end": "2026-03-09" + } + ], + "edits": [ + { + "entryId": "scheduled-a", + "change": { + "kind": "resize", + "start": "2026-03-07", + "allDay": true, + "end": "2026-03-10" + }, + "expectedUpdates": { + "timeEstimate": 4320, + "dateModified": "2026-03-08T16:00:00Z" + }, + "expectedFrontmatterPatch": [ + { + "op": "set", + "field": "timeEstimate", + "value": 4320 + }, + { + "op": "set", + "field": "dateModified", + "value": "2026-03-08T16:00:00Z" + } + ], + "expectedClearedInstances": { + "complete": [], + "skipped": [] + } + }, + { + "entryId": "scheduled-a", + "change": { + "kind": "drop", + "start": "2026-03-08T11:15", + "allDay": false + }, + "expectedUpdates": { + "scheduled": "2026-03-08T11:15", + "dateModified": "2026-03-08T16:00:00Z" + }, + "expectedFrontmatterPatch": [ + { + "op": "set", + "field": "scheduled", + "value": "2026-03-08T11:15" + }, + { + "op": "set", + "field": "dateModified", + "value": "2026-03-08T16:00:00Z" + } + ], + "expectedClearedInstances": { + "complete": [], + "skipped": [] + } + } + ] + }, + { + "name": "overnight-and-deadline", + "input": { + "tasks": [ + { + "id": "a", + "path": "a.md", + "title": "A", + "status": "open", + "priority": "normal", + "archived": false, + "scheduled": "2026-03-07T23:30", + "timeEstimate": 120, + "due": "2026-03-08T23:50" + } + ], + "settings": {}, + "range": { + "start": "2026-03-01", + "end": "2026-04-01" + }, + "timezone": "UTC", + "now": "2026-03-08T16:00:00Z" + }, + "expectedEntries": [ + { + "id": "scheduled-a", + "taskId": "a", + "title": "A", + "type": "scheduled", + "start": "2026-03-07T23:30", + "allDay": false, + "durationMinutes": 120, + "isCompleted": false, + "isSkipped": false, + "startEditable": true, + "durationEditable": true, + "editReason": "date", + "end": "2026-03-08T01:30" + }, + { + "id": "due-a", + "taskId": "a", + "title": "A", + "type": "due", + "start": "2026-03-08T23:50", + "allDay": false, + "durationMinutes": 10, + "isCompleted": false, + "isSkipped": false, + "startEditable": true, + "durationEditable": false, + "editReason": "deadline-marker", + "end": "2026-03-09T00:00" + } + ], + "edits": [ + { + "entryId": "due-a", + "change": { + "kind": "drop-on-day", + "start": "2026-03-12", + "allDay": true + }, + "expectedUpdates": { + "due": "2026-03-12", + "dateModified": "2026-03-08T16:00:00Z" + }, + "expectedFrontmatterPatch": [ + { + "op": "set", + "field": "due", + "value": "2026-03-12" + }, + { + "op": "set", + "field": "dateModified", + "value": "2026-03-08T16:00:00Z" + } + ], + "expectedClearedInstances": { + "complete": [], + "skipped": [] + } + }, + { + "entryId": "scheduled-a", + "change": { + "kind": "resize", + "start": "2026-03-07T23:45", + "allDay": false, + "end": "2026-03-08T01:30" + }, + "expectedUpdates": { + "timeEstimate": 105, + "dateModified": "2026-03-08T16:00:00Z" + }, + "expectedFrontmatterPatch": [ + { + "op": "set", + "field": "timeEstimate", + "value": 105 + }, + { + "op": "set", + "field": "dateModified", + "value": "2026-03-08T16:00:00Z" + } + ], + "expectedClearedInstances": { + "complete": [], + "skipped": [] + } + } + ] + }, + { + "name": "due-only", + "input": { + "tasks": [ + { + "id": "a", + "path": "a.md", + "title": "A", + "status": "open", + "priority": "normal", + "archived": false, + "due": "2026-03-11" + } + ], + "settings": { + "showScheduled": false + }, + "range": { + "start": "2026-03-01", + "end": "2026-04-01" + }, + "timezone": "UTC", + "now": "2026-03-08T16:00:00Z" + }, + "expectedEntries": [ + { + "id": "due-a", + "taskId": "a", + "title": "A", + "type": "due", + "start": "2026-03-11", + "allDay": true, + "durationMinutes": 1440, + "isCompleted": false, + "isSkipped": false, + "startEditable": true, + "durationEditable": false, + "editReason": "deadline-marker" + } + ], + "edits": [] + }, + { + "name": "implicit-timed-duration", + "input": { + "tasks": [ + { + "id": "a", + "path": "a.md", + "title": "A", + "status": "open", + "priority": "normal", + "archived": false, + "scheduled": "2026-03-11T10:00" + } + ], + "settings": {}, + "range": { + "start": "2026-03-01", + "end": "2026-04-01" + }, + "timezone": "UTC", + "now": "2026-03-08T16:00:00Z" + }, + "expectedEntries": [ + { + "id": "scheduled-a", + "taskId": "a", + "title": "A", + "type": "scheduled", + "start": "2026-03-11T10:00", + "allDay": false, + "durationMinutes": 60, + "isCompleted": false, + "isSkipped": false, + "startEditable": true, + "durationEditable": true, + "editReason": "date" + } + ], + "edits": [ + { + "entryId": "scheduled-a", + "change": { + "kind": "drop", + "start": "2026-03-15", + "allDay": true + }, + "expectedUpdates": { + "scheduled": "2026-03-15", + "dateModified": "2026-03-08T16:00:00Z" + }, + "expectedFrontmatterPatch": [ + { + "op": "set", + "field": "scheduled", + "value": "2026-03-15" + }, + { + "op": "set", + "field": "dateModified", + "value": "2026-03-08T16:00:00Z" + } + ], + "expectedClearedInstances": { + "complete": [], + "skipped": [] + } + } + ] + }, + { + "name": "inclusive-all-day-span", + "input": { + "tasks": [ + { + "id": "a", + "path": "a.md", + "title": "A", + "status": "open", + "priority": "normal", + "archived": false, + "scheduled": "2026-03-07", + "due": "2026-03-10" + } + ], + "settings": { + "showScheduledToDueSpan": true + }, + "range": { + "start": "2026-03-01", + "end": "2026-04-01" + }, + "timezone": "UTC", + "now": "2026-03-08T16:00:00Z" + }, + "expectedEntries": [ + { + "id": "span-a", + "taskId": "a", + "title": "A", + "type": "scheduledToDueSpan", + "start": "2026-03-07", + "allDay": true, + "durationMinutes": 5760, + "isCompleted": false, + "isSkipped": false, + "startEditable": true, + "durationEditable": false, + "editReason": "span-shift", + "end": "2026-03-11" + } + ], + "edits": [ + { + "entryId": "span-a", + "change": { + "kind": "drop", + "start": "2026-03-09", + "allDay": true + }, + "expectedUpdates": { + "scheduled": "2026-03-09", + "due": "2026-03-12", + "dateModified": "2026-03-08T16:00:00Z" + }, + "expectedFrontmatterPatch": [ + { + "op": "set", + "field": "scheduled", + "value": "2026-03-09" + }, + { + "op": "set", + "field": "due", + "value": "2026-03-12" + }, + { + "op": "set", + "field": "dateModified", + "value": "2026-03-08T16:00:00Z" + } + ], + "expectedClearedInstances": { + "complete": [], + "skipped": [] + } + } + ] + }, + { + "name": "timed-span-is-daily-work-not-continuous", + "input": { + "tasks": [ + { + "id": "a", + "path": "a.md", + "title": "A", + "status": "open", + "priority": "normal", + "archived": false, + "scheduled": "2026-03-07T09:00", + "due": "2026-03-09T17:00", + "timeEstimate": 45 + } + ], + "settings": { + "showScheduledToDueSpan": true + }, + "range": { + "start": "2026-03-01", + "end": "2026-04-01" + }, + "timezone": "UTC", + "now": "2026-03-08T16:00:00Z" + }, + "expectedEntries": [ + { + "id": "span-a-2026-03-07", + "taskId": "a", + "title": "A", + "type": "scheduledToDueSpan", + "start": "2026-03-07T09:00", + "allDay": false, + "durationMinutes": 45, + "isCompleted": false, + "isSkipped": false, + "startEditable": true, + "durationEditable": false, + "editReason": "span-shift", + "end": "2026-03-07T09:45" + }, + { + "id": "span-a-2026-03-08", + "taskId": "a", + "title": "A", + "type": "scheduledToDueSpan", + "start": "2026-03-08T09:00", + "allDay": false, + "durationMinutes": 45, + "isCompleted": false, + "isSkipped": false, + "startEditable": true, + "durationEditable": false, + "editReason": "span-shift", + "end": "2026-03-08T09:45" + }, + { + "id": "span-a-2026-03-09", + "taskId": "a", + "title": "A", + "type": "scheduledToDueSpan", + "start": "2026-03-09T09:00", + "allDay": false, + "durationMinutes": 45, + "isCompleted": false, + "isSkipped": false, + "startEditable": true, + "durationEditable": false, + "editReason": "span-shift", + "end": "2026-03-09T09:45" + } + ], + "edits": [] + }, + { + "name": "spring-forward-elapsed-duration", + "input": { + "tasks": [ + { + "id": "a", + "path": "a.md", + "title": "A", + "status": "open", + "priority": "normal", + "archived": false, + "scheduled": "2026-03-08T01:30", + "timeEstimate": 120 + } + ], + "settings": {}, + "range": { + "start": "2026-03-08", + "end": "2026-03-09" + }, + "timezone": "America/New_York", + "now": "2026-03-08T16:00:00Z" + }, + "expectedEntries": [ + { + "id": "scheduled-a", + "taskId": "a", + "title": "A", + "type": "scheduled", + "start": "2026-03-08T01:30", + "allDay": false, + "durationMinutes": 120, + "isCompleted": false, + "isSkipped": false, + "startEditable": true, + "durationEditable": true, + "editReason": "date", + "end": "2026-03-08T04:30" + } + ], + "edits": [ + { + "entryId": "scheduled-a", + "change": { + "kind": "resize", + "start": "2026-03-08T01:30", + "allDay": false, + "end": "2026-03-08T04:30" + }, + "expectedUpdates": { + "timeEstimate": 120, + "dateModified": "2026-03-08T16:00:00Z" + }, + "expectedFrontmatterPatch": [ + { + "op": "set", + "field": "timeEstimate", + "value": 120 + }, + { + "op": "set", + "field": "dateModified", + "value": "2026-03-08T16:00:00Z" + } + ], + "expectedClearedInstances": { + "complete": [], + "skipped": [] + } + } + ] + }, + { + "name": "fall-back-elapsed-duration", + "input": { + "tasks": [ + { + "id": "a", + "path": "a.md", + "title": "A", + "status": "open", + "priority": "normal", + "archived": false, + "scheduled": "2026-11-01T00:30", + "timeEstimate": 180 + } + ], + "settings": {}, + "range": { + "start": "2026-11-01", + "end": "2026-11-02" + }, + "timezone": "America/New_York", + "now": "2026-03-08T16:00:00Z" + }, + "expectedEntries": [ + { + "id": "scheduled-a", + "taskId": "a", + "title": "A", + "type": "scheduled", + "start": "2026-11-01T00:30", + "allDay": false, + "durationMinutes": 180, + "isCompleted": false, + "isSkipped": false, + "startEditable": true, + "durationEditable": true, + "editReason": "date", + "end": "2026-11-01T02:30" + } + ], + "edits": [] + }, + { + "name": "positive-timezone-visible-range", + "input": { + "tasks": [ + { + "id": "a", + "path": "a.md", + "title": "A", + "status": "open", + "priority": "normal", + "archived": false, + "scheduled": "2026-03-07T23:30", + "timeEstimate": 120 + } + ], + "settings": {}, + "range": { + "start": "2026-03-07T11:00:00Z", + "end": "2026-03-08T11:00:00Z" + }, + "timezone": "Pacific/Auckland", + "now": "2026-03-08T16:00:00Z" + }, + "expectedEntries": [ + { + "id": "scheduled-a", + "taskId": "a", + "title": "A", + "type": "scheduled", + "start": "2026-03-07T23:30", + "allDay": false, + "durationMinutes": 120, + "isCompleted": false, + "isSkipped": false, + "startEditable": true, + "durationEditable": true, + "editReason": "date", + "end": "2026-03-08T01:30" + } + ], + "edits": [] + }, + { + "name": "daily-next-pattern-and-instance-state", + "input": { + "tasks": [ + { + "id": "a", + "path": "a.md", + "title": "A", + "status": "open", + "priority": "normal", + "archived": false, + "scheduled": "2026-03-08T09:00", + "recurrence": "DTSTART:20260307T100000Z;FREQ=DAILY;COUNT=4", + "complete_instances": [ + "2026-03-07" + ], + "skipped_instances": [ + "2026-03-09" + ], + "timeEstimate": 30 + } + ], + "settings": {}, + "range": { + "start": "2026-03-01", + "end": "2026-04-01" + }, + "timezone": "Europe/London", + "now": "2026-03-08T16:00:00Z" + }, + "expectedEntries": [ + { + "id": "next-scheduled-a-2026-03-08", + "taskId": "a", + "title": "A", + "type": "scheduled", + "start": "2026-03-08T09:00", + "allDay": false, + "durationMinutes": 30, + "isCompleted": false, + "isSkipped": false, + "startEditable": true, + "durationEditable": true, + "editReason": "next-occurrence", + "end": "2026-03-08T09:30", + "recurringKind": "next-scheduled", + "instanceDate": "2026-03-08", + "recurringTemplateTime": "09:00" + }, + { + "id": "recurring-a-2026-03-07", + "taskId": "a", + "title": "A", + "type": "recurring", + "start": "2026-03-07T10:00", + "allDay": false, + "durationMinutes": 30, + "isCompleted": true, + "isSkipped": false, + "startEditable": true, + "durationEditable": true, + "editReason": "series-time", + "end": "2026-03-07T10:30", + "recurringKind": "pattern", + "instanceDate": "2026-03-07", + "recurringTemplateTime": "10:00" + }, + { + "id": "recurring-a-2026-03-09", + "taskId": "a", + "title": "A", + "type": "recurring", + "start": "2026-03-09T10:00", + "allDay": false, + "durationMinutes": 30, + "isCompleted": false, + "isSkipped": true, + "startEditable": true, + "durationEditable": true, + "editReason": "series-time", + "end": "2026-03-09T10:30", + "recurringKind": "pattern", + "instanceDate": "2026-03-09", + "recurringTemplateTime": "10:00" + }, + { + "id": "recurring-a-2026-03-10", + "taskId": "a", + "title": "A", + "type": "recurring", + "start": "2026-03-10T10:00", + "allDay": false, + "durationMinutes": 30, + "isCompleted": false, + "isSkipped": false, + "startEditable": true, + "durationEditable": true, + "editReason": "series-time", + "end": "2026-03-10T10:30", + "recurringKind": "pattern", + "instanceDate": "2026-03-10", + "recurringTemplateTime": "10:00" + } + ], + "edits": [ + { + "entryId": "next-scheduled-a-2026-03-08", + "change": { + "kind": "drop", + "start": "2026-03-07T11:00", + "allDay": false + }, + "expectedUpdates": { + "scheduled": "2026-03-07T11:00", + "complete_instances": [], + "skipped_instances": [], + "dateModified": "2026-03-08T16:00:00Z" + }, + "expectedFrontmatterPatch": [ + { + "op": "set", + "field": "scheduled", + "value": "2026-03-07T11:00" + }, + { + "op": "delete", + "field": "complete_instances" + }, + { + "op": "delete", + "field": "skipped_instances" + }, + { + "op": "set", + "field": "dateModified", + "value": "2026-03-08T16:00:00Z" + } + ], + "expectedClearedInstances": { + "complete": [ + "2026-03-07" + ], + "skipped": [ + "2026-03-09" + ] + } + }, + { + "entryId": "recurring-a-2026-03-09", + "change": { + "kind": "drop", + "start": "2026-03-15T14:15", + "allDay": false + }, + "expectedUpdates": { + "recurrence": "DTSTART:20260307T141500Z;FREQ=DAILY;COUNT=4", + "dateModified": "2026-03-08T16:00:00Z" + }, + "expectedFrontmatterPatch": [ + { + "op": "set", + "field": "recurrence", + "value": "DTSTART:20260307T141500Z;FREQ=DAILY;COUNT=4" + }, + { + "op": "set", + "field": "dateModified", + "value": "2026-03-08T16:00:00Z" + } + ], + "expectedClearedInstances": { + "complete": [], + "skipped": [] + } + }, + { + "entryId": "recurring-a-2026-03-07", + "change": { + "kind": "resize", + "start": "2026-03-07T10:00", + "allDay": false, + "end": "2026-03-07T11:30" + }, + "expectedUpdates": { + "timeEstimate": 90, + "dateModified": "2026-03-08T16:00:00Z" + }, + "expectedFrontmatterPatch": [ + { + "op": "set", + "field": "timeEstimate", + "value": 90 + }, + { + "op": "set", + "field": "dateModified", + "value": "2026-03-08T16:00:00Z" + } + ], + "expectedClearedInstances": { + "complete": [], + "skipped": [] + } + }, + { + "entryId": "recurring-a-2026-03-09", + "change": { + "kind": "drop-on-day", + "start": "2026-03-22", + "allDay": true + }, + "expectedUpdates": { + "recurrence": "DTSTART:20260307;FREQ=DAILY;COUNT=4", + "dateModified": "2026-03-08T16:00:00Z" + }, + "expectedFrontmatterPatch": [ + { + "op": "set", + "field": "recurrence", + "value": "DTSTART:20260307;FREQ=DAILY;COUNT=4" + }, + { + "op": "set", + "field": "dateModified", + "value": "2026-03-08T16:00:00Z" + } + ], + "expectedClearedInstances": { + "complete": [], + "skipped": [] + } + } + ] + }, + { + "name": "hide-processed-daily-instances", + "input": { + "tasks": [ + { + "id": "a", + "path": "a.md", + "title": "A", + "status": "open", + "priority": "normal", + "archived": false, + "scheduled": "2026-03-08T09:00", + "recurrence": "DTSTART:20260307T100000Z;FREQ=DAILY;COUNT=4", + "complete_instances": [ + "2026-03-07" + ], + "skipped_instances": [ + "2026-03-09" + ], + "timeEstimate": 30 + } + ], + "settings": { + "showCompletedRecurringInstances": false, + "showSkippedRecurringInstances": false + }, + "range": { + "start": "2026-03-01", + "end": "2026-04-01" + }, + "timezone": "UTC", + "now": "2026-03-08T16:00:00Z" + }, + "expectedEntries": [ + { + "id": "next-scheduled-a-2026-03-08", + "taskId": "a", + "title": "A", + "type": "scheduled", + "start": "2026-03-08T09:00", + "allDay": false, + "durationMinutes": 30, + "isCompleted": false, + "isSkipped": false, + "startEditable": true, + "durationEditable": true, + "editReason": "next-occurrence", + "end": "2026-03-08T09:30", + "recurringKind": "next-scheduled", + "instanceDate": "2026-03-08", + "recurringTemplateTime": "09:00" + }, + { + "id": "recurring-a-2026-03-10", + "taskId": "a", + "title": "A", + "type": "recurring", + "start": "2026-03-10T10:00", + "allDay": false, + "durationMinutes": 30, + "isCompleted": false, + "isSkipped": false, + "startEditable": true, + "durationEditable": true, + "editReason": "series-time", + "end": "2026-03-10T10:30", + "recurringKind": "pattern", + "instanceDate": "2026-03-10", + "recurringTemplateTime": "10:00" + } + ], + "edits": [] + }, + { + "name": "weekly-recorded-off-pattern-and-add-dtstart", + "input": { + "tasks": [ + { + "id": "a", + "path": "a.md", + "title": "A", + "status": "open", + "priority": "normal", + "archived": false, + "scheduled": "2026-03-02", + "recurrence": "FREQ=WEEKLY;COUNT=3;BYDAY=MO", + "complete_instances": [ + "2026-03-05" + ] + } + ], + "settings": {}, + "range": { + "start": "2026-03-01", + "end": "2026-04-01" + }, + "timezone": "UTC", + "now": "2026-03-08T16:00:00Z" + }, + "expectedEntries": [ + { + "id": "next-scheduled-a-2026-03-02", + "taskId": "a", + "title": "A", + "type": "scheduled", + "start": "2026-03-02", + "allDay": true, + "durationMinutes": 1440, + "isCompleted": false, + "isSkipped": false, + "startEditable": true, + "durationEditable": true, + "editReason": "next-occurrence", + "recurringKind": "next-scheduled", + "instanceDate": "2026-03-02", + "recurringTemplateTime": "09:00" + }, + { + "id": "recurring-a-2026-03-09", + "taskId": "a", + "title": "A", + "type": "recurring", + "start": "2026-03-09", + "allDay": true, + "durationMinutes": 1440, + "isCompleted": false, + "isSkipped": false, + "startEditable": true, + "durationEditable": true, + "editReason": "series-time", + "recurringKind": "pattern", + "instanceDate": "2026-03-09", + "recurringTemplateTime": "09:00" + }, + { + "id": "recurring-a-2026-03-16", + "taskId": "a", + "title": "A", + "type": "recurring", + "start": "2026-03-16", + "allDay": true, + "durationMinutes": 1440, + "isCompleted": false, + "isSkipped": false, + "startEditable": true, + "durationEditable": true, + "editReason": "series-time", + "recurringKind": "pattern", + "instanceDate": "2026-03-16", + "recurringTemplateTime": "09:00" + }, + { + "id": "recurring-recorded-a-2026-03-05", + "taskId": "a", + "title": "A", + "type": "recurring", + "start": "2026-03-05", + "allDay": true, + "durationMinutes": 1440, + "isCompleted": true, + "isSkipped": false, + "startEditable": true, + "durationEditable": true, + "editReason": "recorded-time", + "recurringKind": "recorded", + "instanceDate": "2026-03-05", + "recurringTemplateTime": "09:00" + } + ], + "edits": [ + { + "entryId": "recurring-a-2026-03-09", + "change": { + "kind": "drop", + "start": "2026-03-13T16:45", + "allDay": false + }, + "expectedUpdates": { + "recurrence": "DTSTART:20260302T164500Z;FREQ=WEEKLY;COUNT=3;BYDAY=MO", + "dateModified": "2026-03-08T16:00:00Z" + }, + "expectedFrontmatterPatch": [ + { + "op": "set", + "field": "recurrence", + "value": "DTSTART:20260302T164500Z;FREQ=WEEKLY;COUNT=3;BYDAY=MO" + }, + { + "op": "set", + "field": "dateModified", + "value": "2026-03-08T16:00:00Z" + } + ], + "expectedClearedInstances": { + "complete": [], + "skipped": [] + } + }, + { + "entryId": "recurring-recorded-a-2026-03-05", + "change": { + "kind": "drop", + "start": "2026-03-23T12:00", + "allDay": false + }, + "expectedUpdates": { + "scheduled": "2026-03-02T12:00", + "dateModified": "2026-03-08T16:00:00Z" + }, + "expectedFrontmatterPatch": [ + { + "op": "set", + "field": "scheduled", + "value": "2026-03-02T12:00" + }, + { + "op": "set", + "field": "dateModified", + "value": "2026-03-08T16:00:00Z" + } + ], + "expectedClearedInstances": { + "complete": [], + "skipped": [] + } + } + ] + }, + { + "name": "recorded-only-when-projections-disabled", + "input": { + "tasks": [ + { + "id": "a", + "path": "a.md", + "title": "A", + "status": "open", + "priority": "normal", + "archived": false, + "scheduled": "2026-03-02", + "recurrence": "FREQ=WEEKLY;COUNT=3;BYDAY=MO", + "complete_instances": [ + "2026-03-05" + ] + } + ], + "settings": { + "showRecurring": false, + "showScheduled": false, + "showDue": false + }, + "range": { + "start": "2026-03-01", + "end": "2026-04-01" + }, + "timezone": "UTC", + "now": "2026-03-08T16:00:00Z" + }, + "expectedEntries": [ + { + "id": "recurring-recorded-a-2026-03-05", + "taskId": "a", + "title": "A", + "type": "recurring", + "start": "2026-03-05", + "allDay": true, + "durationMinutes": 1440, + "isCompleted": true, + "isSkipped": false, + "startEditable": true, + "durationEditable": true, + "editReason": "recorded-time", + "recurringKind": "recorded", + "instanceDate": "2026-03-05", + "recurringTemplateTime": "09:00" + } + ], + "edits": [] + }, + { + "name": "monthly-skips-missing-month-day", + "input": { + "tasks": [ + { + "id": "a", + "path": "a.md", + "title": "A", + "status": "open", + "priority": "normal", + "archived": false, + "scheduled": "2026-01-31", + "recurrence": "DTSTART:20260131;FREQ=MONTHLY;COUNT=3" + } + ], + "settings": {}, + "range": { + "start": "2026-02-01", + "end": "2026-06-01" + }, + "timezone": "UTC", + "now": "2026-03-08T16:00:00Z" + }, + "expectedEntries": [ + { + "id": "recurring-a-2026-03-31", + "taskId": "a", + "title": "A", + "type": "recurring", + "start": "2026-03-31", + "allDay": true, + "durationMinutes": 1440, + "isCompleted": false, + "isSkipped": false, + "startEditable": true, + "durationEditable": true, + "editReason": "series-time", + "recurringKind": "pattern", + "instanceDate": "2026-03-31", + "recurringTemplateTime": "09:00" + }, + { + "id": "recurring-a-2026-05-31", + "taskId": "a", + "title": "A", + "type": "recurring", + "start": "2026-05-31", + "allDay": true, + "durationMinutes": 1440, + "isCompleted": false, + "isSkipped": false, + "startEditable": true, + "durationEditable": true, + "editReason": "series-time", + "recurringKind": "pattern", + "instanceDate": "2026-05-31", + "recurringTemplateTime": "09:00" + } + ], + "edits": [] + }, + { + "name": "recurring-spans-readonly", + "input": { + "tasks": [ + { + "id": "a", + "path": "a.md", + "title": "A", + "status": "open", + "priority": "normal", + "archived": false, + "scheduled": "2026-03-07", + "due": "2026-03-09", + "recurrence": "DTSTART:20260307;FREQ=WEEKLY;COUNT=2" + } + ], + "settings": { + "showScheduledToDueSpan": true + }, + "range": { + "start": "2026-03-01", + "end": "2026-04-01" + }, + "timezone": "UTC", + "now": "2026-03-08T16:00:00Z" + }, + "expectedEntries": [ + { + "id": "span-next-scheduled-a-2026-03-07-2026-03-07", + "taskId": "a", + "title": "A", + "type": "scheduledToDueSpan", + "start": "2026-03-07", + "allDay": true, + "durationMinutes": 4320, + "isCompleted": false, + "isSkipped": false, + "startEditable": false, + "durationEditable": false, + "editReason": "recurring-span-readonly", + "end": "2026-03-10", + "recurringKind": "next-scheduled", + "instanceDate": "2026-03-07", + "recurringTemplateTime": "09:00" + }, + { + "id": "span-pattern-a-2026-03-14-2026-03-14", + "taskId": "a", + "title": "A", + "type": "scheduledToDueSpan", + "start": "2026-03-14", + "allDay": true, + "durationMinutes": 4320, + "isCompleted": false, + "isSkipped": false, + "startEditable": false, + "durationEditable": false, + "editReason": "recurring-span-readonly", + "end": "2026-03-17", + "recurringKind": "pattern", + "instanceDate": "2026-03-14", + "recurringTemplateTime": "09:00" + } + ], + "edits": [] + }, + { + "name": "rfc-dtstart-edit-preserves-lines", + "input": { + "tasks": [ + { + "id": "a", + "path": "a.md", + "title": "A", + "status": "open", + "priority": "normal", + "archived": false, + "scheduled": "2026-03-07T09:00", + "recurrence": "DTSTART:20260307T090000Z\nRRULE:FREQ=DAILY;COUNT=2\nEXDATE:20260308T090000Z" + } + ], + "settings": {}, + "range": { + "start": "2026-03-01", + "end": "2026-04-01" + }, + "timezone": "UTC", + "now": "2026-03-08T16:00:00Z" + }, + "expectedEntries": [ + { + "id": "next-scheduled-a-2026-03-07", + "taskId": "a", + "title": "A", + "type": "scheduled", + "start": "2026-03-07T09:00", + "allDay": false, + "durationMinutes": 60, + "isCompleted": false, + "isSkipped": false, + "startEditable": true, + "durationEditable": true, + "editReason": "next-occurrence", + "recurringKind": "next-scheduled", + "instanceDate": "2026-03-07", + "recurringTemplateTime": "09:00" + } + ], + "edits": [ + { + "entryId": "next-scheduled-a-2026-03-07", + "change": { + "kind": "drop", + "start": "2026-03-09T12:00", + "allDay": false + }, + "expectedUpdates": { + "scheduled": "2026-03-09T12:00", + "dateModified": "2026-03-08T16:00:00Z" + }, + "expectedFrontmatterPatch": [ + { + "op": "set", + "field": "scheduled", + "value": "2026-03-09T12:00" + }, + { + "op": "set", + "field": "dateModified", + "value": "2026-03-08T16:00:00Z" + } + ], + "expectedClearedInstances": { + "complete": [], + "skipped": [] + } + } + ] + }, + { + "name": "rfc-pattern-edit-preserves-rule", + "input": { + "tasks": [ + { + "id": "a", + "path": "a.md", + "title": "A", + "status": "open", + "priority": "normal", + "archived": false, + "scheduled": "2026-03-07T09:00", + "recurrence": "DTSTART:20260307T090000Z\nRRULE:FREQ=DAILY;COUNT=2" + } + ], + "settings": {}, + "range": { + "start": "2026-03-01", + "end": "2026-04-01" + }, + "timezone": "UTC", + "now": "2026-03-08T16:00:00Z" + }, + "expectedEntries": [ + { + "id": "next-scheduled-a-2026-03-07", + "taskId": "a", + "title": "A", + "type": "scheduled", + "start": "2026-03-07T09:00", + "allDay": false, + "durationMinutes": 60, + "isCompleted": false, + "isSkipped": false, + "startEditable": true, + "durationEditable": true, + "editReason": "next-occurrence", + "recurringKind": "next-scheduled", + "instanceDate": "2026-03-07", + "recurringTemplateTime": "09:00" + }, + { + "id": "recurring-a-2026-03-08", + "taskId": "a", + "title": "A", + "type": "recurring", + "start": "2026-03-08T09:00", + "allDay": false, + "durationMinutes": 60, + "isCompleted": false, + "isSkipped": false, + "startEditable": true, + "durationEditable": true, + "editReason": "series-time", + "recurringKind": "pattern", + "instanceDate": "2026-03-08", + "recurringTemplateTime": "09:00" + } + ], + "edits": [ + { + "entryId": "recurring-a-2026-03-08", + "change": { + "kind": "drop", + "start": "2026-03-15T13:00", + "allDay": false + }, + "expectedUpdates": { + "recurrence": "DTSTART:20260307T130000Z\nRRULE:FREQ=DAILY;COUNT=2", + "dateModified": "2026-03-08T16:00:00Z" + }, + "expectedFrontmatterPatch": [ + { + "op": "set", + "field": "recurrence", + "value": "DTSTART:20260307T130000Z\nRRULE:FREQ=DAILY;COUNT=2" + }, + { + "op": "set", + "field": "dateModified", + "value": "2026-03-08T16:00:00Z" + } + ], + "expectedClearedInstances": { + "complete": [], + "skipped": [] + } + } + ] + }, + { + "name": "finished-time-entries-exclusive-boundary-and-edits", + "input": { + "tasks": [ + { + "id": "a", + "path": "a.md", + "title": "A", + "status": "open", + "priority": "normal", + "archived": false, + "timeEntries": [ + { + "startTime": "2026-03-07T12:00:00Z", + "endTime": "2026-03-07T13:00:00Z", + "duration": 999, + "description": "focus" + }, + { + "startTime": "2026-03-08T12:00:00Z" + }, + { + "startTime": "2026-03-01T00:00:00Z", + "endTime": "2026-03-02T00:00:00Z" + } + ] + } + ], + "settings": {}, + "range": { + "start": "2026-03-02", + "end": "2026-03-09" + }, + "timezone": "America/Los_Angeles", + "now": "2026-03-08T16:00:00Z" + }, + "expectedEntries": [ + { + "id": "timeentry-a-0", + "taskId": "a", + "title": "A", + "type": "timeEntry", + "start": "2026-03-07T12:00:00Z", + "allDay": false, + "durationMinutes": 60, + "isCompleted": false, + "isSkipped": false, + "startEditable": true, + "durationEditable": true, + "editReason": "time-entry", + "end": "2026-03-07T13:00:00Z", + "timeEntryIndex": 0 + } + ], + "edits": [ + { + "entryId": "timeentry-a-0", + "change": { + "kind": "drop", + "start": "2026-03-07T06:00", + "allDay": false, + "end": "2026-03-07T07:00" + }, + "expectedUpdates": { + "timeEntries": [ + { + "startTime": "2026-03-07T14:00:00.000Z", + "endTime": "2026-03-07T15:00:00.000Z", + "description": "focus" + }, + { + "startTime": "2026-03-08T12:00:00Z" + }, + { + "startTime": "2026-03-01T00:00:00Z", + "endTime": "2026-03-02T00:00:00Z" + } + ], + "dateModified": "2026-03-08T16:00:00Z" + }, + "expectedFrontmatterPatch": [ + { + "op": "set", + "field": "timeEntries", + "value": [ + { + "startTime": "2026-03-07T14:00:00.000Z", + "endTime": "2026-03-07T15:00:00.000Z", + "description": "focus" + }, + { + "startTime": "2026-03-08T12:00:00Z" + }, + { + "startTime": "2026-03-01T00:00:00Z", + "endTime": "2026-03-02T00:00:00Z" + } + ] + }, + { + "op": "set", + "field": "dateModified", + "value": "2026-03-08T16:00:00Z" + } + ], + "expectedClearedInstances": { + "complete": [], + "skipped": [] + } + }, + { + "entryId": "timeentry-a-0", + "change": { + "kind": "resize", + "start": "2026-03-07T04:15", + "allDay": false, + "end": "2026-03-07T05:30" + }, + "expectedUpdates": { + "timeEntries": [ + { + "startTime": "2026-03-07T12:15:00.000Z", + "endTime": "2026-03-07T13:30:00.000Z", + "description": "focus" + }, + { + "startTime": "2026-03-08T12:00:00Z" + }, + { + "startTime": "2026-03-01T00:00:00Z", + "endTime": "2026-03-02T00:00:00Z" + } + ], + "dateModified": "2026-03-08T16:00:00Z" + }, + "expectedFrontmatterPatch": [ + { + "op": "set", + "field": "timeEntries", + "value": [ + { + "startTime": "2026-03-07T12:15:00.000Z", + "endTime": "2026-03-07T13:30:00.000Z", + "description": "focus" + }, + { + "startTime": "2026-03-08T12:00:00Z" + }, + { + "startTime": "2026-03-01T00:00:00Z", + "endTime": "2026-03-02T00:00:00Z" + } + ] + }, + { + "op": "set", + "field": "dateModified", + "value": "2026-03-08T16:00:00Z" + } + ], + "expectedClearedInstances": { + "complete": [], + "skipped": [] + } + } + ] + }, + { + "name": "materialized-child-suppresses-parent", + "input": { + "tasks": [ + { + "id": "a", + "path": "a.md", + "title": "A", + "status": "open", + "priority": "normal", + "archived": false, + "scheduled": "2026-03-07", + "recurrence": "DTSTART:20260307;FREQ=DAILY;COUNT=3" + }, + { + "id": "b", + "path": "b.md", + "title": "B", + "status": "open", + "priority": "normal", + "archived": false, + "scheduled": "2026-03-08T11:00", + "recurrence_parent": "[[a]]", + "occurrence_date": "2026-03-08" + } + ], + "settings": {}, + "range": { + "start": "2026-03-01", + "end": "2026-04-01" + }, + "timezone": "UTC", + "now": "2026-03-08T16:00:00Z" + }, + "expectedEntries": [ + { + "id": "next-scheduled-a-2026-03-07", + "taskId": "a", + "title": "A", + "type": "scheduled", + "start": "2026-03-07", + "allDay": true, + "durationMinutes": 1440, + "isCompleted": false, + "isSkipped": false, + "startEditable": true, + "durationEditable": true, + "editReason": "next-occurrence", + "recurringKind": "next-scheduled", + "instanceDate": "2026-03-07", + "recurringTemplateTime": "09:00" + }, + { + "id": "recurring-a-2026-03-09", + "taskId": "a", + "title": "A", + "type": "recurring", + "start": "2026-03-09", + "allDay": true, + "durationMinutes": 1440, + "isCompleted": false, + "isSkipped": false, + "startEditable": true, + "durationEditable": true, + "editReason": "series-time", + "recurringKind": "pattern", + "instanceDate": "2026-03-09", + "recurringTemplateTime": "09:00" + }, + { + "id": "scheduled-b", + "taskId": "b", + "title": "B", + "type": "scheduled", + "start": "2026-03-08T11:00", + "allDay": false, + "durationMinutes": 60, + "isCompleted": false, + "isSkipped": false, + "startEditable": true, + "durationEditable": true, + "editReason": "date", + "isMaterializedOccurrence": true, + "instanceDate": "2026-03-08", + "occurrenceParent": "[[a]]" + } + ], + "edits": [] + }, + { + "name": "external-materialized-identities", + "input": { + "tasks": [ + { + "id": "a", + "path": "a.md", + "title": "A", + "status": "open", + "priority": "normal", + "archived": false, + "scheduled": "2026-03-07", + "recurrence": "DTSTART:20260307;FREQ=DAILY;COUNT=3" + } + ], + "settings": {}, + "range": { + "start": "2026-03-01", + "end": "2026-04-01" + }, + "timezone": "UTC", + "now": "2026-03-08T16:00:00Z", + "materializedOccurrences": { + "a": [ + "2026-03-08" + ] + } + }, + "expectedEntries": [ + { + "id": "next-scheduled-a-2026-03-07", + "taskId": "a", + "title": "A", + "type": "scheduled", + "start": "2026-03-07", + "allDay": true, + "durationMinutes": 1440, + "isCompleted": false, + "isSkipped": false, + "startEditable": true, + "durationEditable": true, + "editReason": "next-occurrence", + "recurringKind": "next-scheduled", + "instanceDate": "2026-03-07", + "recurringTemplateTime": "09:00" + }, + { + "id": "recurring-a-2026-03-09", + "taskId": "a", + "title": "A", + "type": "recurring", + "start": "2026-03-09", + "allDay": true, + "durationMinutes": 1440, + "isCompleted": false, + "isSkipped": false, + "startEditable": true, + "durationEditable": true, + "editReason": "series-time", + "recurringKind": "pattern", + "instanceDate": "2026-03-09", + "recurringTemplateTime": "09:00" + } + ], + "edits": [] + }, + { + "name": "overdue-on-today-suppresses-date-only-due", + "input": { + "tasks": [ + { + "id": "a", + "path": "a.md", + "title": "A", + "status": "open", + "priority": "normal", + "archived": false, + "scheduled": "2026-03-01T10:00", + "due": "2026-03-02", + "timeEstimate": 90 + } + ], + "settings": { + "showOverdueOnToday": true + }, + "range": { + "start": "2026-03-08", + "end": "2026-03-09" + }, + "timezone": "UTC", + "now": "2026-03-08T16:00:00Z" + }, + "expectedEntries": [ + { + "id": "scheduled-a-overdue-today", + "taskId": "a", + "title": "A", + "type": "scheduled", + "start": "2026-03-08T10:00", + "allDay": false, + "durationMinutes": 90, + "isCompleted": false, + "isSkipped": false, + "startEditable": true, + "durationEditable": true, + "editReason": "date", + "end": "2026-03-08T11:30", + "isOverdueOnToday": true + } + ], + "edits": [] + }, + { + "name": "moved-original-dates-not-reprojected", + "input": { + "tasks": [ + { + "id": "a", + "path": "a.md", + "title": "A", + "status": "open", + "priority": "normal", + "archived": false, + "scheduled": "2026-03-07T09:00", + "recurrence": "DTSTART:20260307T090000Z;FREQ=DAILY;COUNT=2", + "googleCalendarExceptionOriginalScheduled": "2026-03-06", + "googleCalendarMovedOriginalDates": [ + "2026-03-08" + ] + } + ], + "settings": {}, + "range": { + "start": "2026-03-01", + "end": "2026-04-01" + }, + "timezone": "UTC", + "now": "2026-03-08T16:00:00Z" + }, + "expectedEntries": [ + { + "id": "next-scheduled-a-2026-03-07", + "taskId": "a", + "title": "A", + "type": "scheduled", + "start": "2026-03-07T09:00", + "allDay": false, + "durationMinutes": 60, + "isCompleted": false, + "isSkipped": false, + "startEditable": true, + "durationEditable": true, + "editReason": "next-occurrence", + "recurringKind": "next-scheduled", + "instanceDate": "2026-03-07", + "recurringTemplateTime": "09:00" + } + ], + "edits": [] + }, + { + "name": "fall-back-ambiguous-end-retains-instant", + "input": { + "tasks": [ + { + "id": "a", + "path": "a.md", + "title": "A", + "status": "open", + "priority": "normal", + "archived": false, + "scheduled": "2026-11-01T00:30", + "timeEstimate": 120 + } + ], + "settings": {}, + "range": { + "start": "2026-11-01", + "end": "2026-11-02" + }, + "timezone": "America/New_York", + "now": "2026-03-08T16:00:00Z" + }, + "expectedEntries": [ + { + "id": "scheduled-a", + "taskId": "a", + "title": "A", + "type": "scheduled", + "start": "2026-11-01T00:30", + "allDay": false, + "durationMinutes": 120, + "isCompleted": false, + "isSkipped": false, + "startEditable": true, + "durationEditable": true, + "editReason": "date", + "end": "2026-11-01T06:30:00.000Z" + } + ], + "edits": [] + }, + { + "name": "all-day-dst-is-a-calendar-day", + "input": { + "tasks": [ + { + "id": "a", + "path": "a.md", + "title": "A", + "status": "open", + "priority": "normal", + "archived": false, + "scheduled": "2026-03-08", + "timeEstimate": 1440 + } + ], + "settings": {}, + "range": { + "start": "2026-03-08", + "end": "2026-03-09" + }, + "timezone": "America/New_York", + "now": "2026-03-08T16:00:00Z" + }, + "expectedEntries": [ + { + "id": "scheduled-a", + "taskId": "a", + "title": "A", + "type": "scheduled", + "start": "2026-03-08", + "allDay": true, + "durationMinutes": 1440, + "isCompleted": false, + "isSkipped": false, + "startEditable": true, + "durationEditable": true, + "editReason": "date", + "end": "2026-03-09" + } + ], + "edits": [] + }, + { + "name": "rfc-tzid-expands-civil-occurrence-dates", + "input": { + "tasks": [ + { + "id": "a", + "path": "a.md", + "title": "A", + "status": "open", + "priority": "normal", + "archived": false, + "scheduled": "2026-03-07T23:30", + "recurrence": "DTSTART;TZID=America/New_York:20260307T233000\nRRULE:FREQ=DAILY;COUNT=3" + } + ], + "settings": {}, + "range": { + "start": "2026-03-07", + "end": "2026-03-12" + }, + "timezone": "America/New_York", + "now": "2026-03-08T16:00:00Z" + }, + "expectedEntries": [ + { + "id": "next-scheduled-a-2026-03-07", + "taskId": "a", + "title": "A", + "type": "scheduled", + "start": "2026-03-07T23:30", + "allDay": false, + "durationMinutes": 60, + "isCompleted": false, + "isSkipped": false, + "startEditable": true, + "durationEditable": true, + "editReason": "next-occurrence", + "recurringKind": "next-scheduled", + "instanceDate": "2026-03-07", + "recurringTemplateTime": "23:30" + }, + { + "id": "recurring-a-2026-03-08", + "taskId": "a", + "title": "A", + "type": "recurring", + "start": "2026-03-08T23:30", + "allDay": false, + "durationMinutes": 60, + "isCompleted": false, + "isSkipped": false, + "startEditable": true, + "durationEditable": true, + "editReason": "series-time", + "recurringKind": "pattern", + "instanceDate": "2026-03-08", + "recurringTemplateTime": "23:30" + }, + { + "id": "recurring-a-2026-03-09", + "taskId": "a", + "title": "A", + "type": "recurring", + "start": "2026-03-09T23:30", + "allDay": false, + "durationMinutes": 60, + "isCompleted": false, + "isSkipped": false, + "startEditable": true, + "durationEditable": true, + "editReason": "series-time", + "recurringKind": "pattern", + "instanceDate": "2026-03-09", + "recurringTemplateTime": "23:30" + } + ], + "edits": [ + { + "entryId": "recurring-a-2026-03-08", + "change": { + "kind": "drop", + "start": "2026-03-15T15:00", + "allDay": false + }, + "expectedUpdates": { + "recurrence": "DTSTART;TZID=America/New_York:20260307T150000\nRRULE:FREQ=DAILY;COUNT=3", + "dateModified": "2026-03-08T16:00:00Z" + }, + "expectedFrontmatterPatch": [ + { + "op": "set", + "field": "recurrence", + "value": "DTSTART;TZID=America/New_York:20260307T150000\nRRULE:FREQ=DAILY;COUNT=3" + }, + { + "op": "set", + "field": "dateModified", + "value": "2026-03-08T16:00:00Z" + } + ], + "expectedClearedInstances": { + "complete": [], + "skipped": [] + } + } + ] + }, + { + "name": "recurring-half-day-range", + "input": { + "tasks": [ + { + "id": "a", + "path": "a.md", + "title": "A", + "status": "open", + "priority": "normal", + "archived": false, + "scheduled": "2026-03-07T09:00", + "recurrence": "DTSTART:20260307T090000Z;FREQ=DAILY;COUNT=3" + } + ], + "settings": {}, + "range": { + "start": "2026-03-09T08:00", + "end": "2026-03-09T12:00" + }, + "timezone": "UTC", + "now": "2026-03-08T16:00:00Z" + }, + "expectedEntries": [ + { + "id": "recurring-a-2026-03-09", + "taskId": "a", + "title": "A", + "type": "recurring", + "start": "2026-03-09T09:00", + "allDay": false, + "durationMinutes": 60, + "isCompleted": false, + "isSkipped": false, + "startEditable": true, + "durationEditable": true, + "editReason": "series-time", + "recurringKind": "pattern", + "instanceDate": "2026-03-09", + "recurringTemplateTime": "09:00" + } + ], + "edits": [] + }, + { + "name": "reschedule-tracks-provider-original-date", + "input": { + "tasks": [ + { + "id": "a", + "path": "a.md", + "title": "A", + "status": "open", + "priority": "normal", + "archived": false, + "scheduled": "2026-03-07T09:00", + "recurrence": "DTSTART:20260307T090000Z;FREQ=DAILY;COUNT=2", + "googleCalendarEventId": "remote" + } + ], + "settings": {}, + "range": { + "start": "2026-03-01", + "end": "2026-04-01" + }, + "timezone": "UTC", + "now": "2026-03-08T16:00:00Z" + }, + "expectedEntries": [ + { + "id": "next-scheduled-a-2026-03-07", + "taskId": "a", + "title": "A", + "type": "scheduled", + "start": "2026-03-07T09:00", + "allDay": false, + "durationMinutes": 60, + "isCompleted": false, + "isSkipped": false, + "startEditable": true, + "durationEditable": true, + "editReason": "next-occurrence", + "recurringKind": "next-scheduled", + "instanceDate": "2026-03-07", + "recurringTemplateTime": "09:00" + }, + { + "id": "recurring-a-2026-03-08", + "taskId": "a", + "title": "A", + "type": "recurring", + "start": "2026-03-08T09:00", + "allDay": false, + "durationMinutes": 60, + "isCompleted": false, + "isSkipped": false, + "startEditable": true, + "durationEditable": true, + "editReason": "series-time", + "recurringKind": "pattern", + "instanceDate": "2026-03-08", + "recurringTemplateTime": "09:00" + } + ], + "edits": [ + { + "entryId": "next-scheduled-a-2026-03-07", + "change": { + "kind": "drop", + "start": "2026-03-09T11:00", + "allDay": false + }, + "expectedUpdates": { + "scheduled": "2026-03-09T11:00", + "googleCalendarExceptionOriginalScheduled": "2026-03-07", + "dateModified": "2026-03-08T16:00:00Z" + }, + "expectedFrontmatterPatch": [ + { + "op": "set", + "field": "scheduled", + "value": "2026-03-09T11:00" + }, + { + "op": "set", + "field": "googleCalendarExceptionOriginalScheduled", + "value": "2026-03-07" + }, + { + "op": "set", + "field": "dateModified", + "value": "2026-03-08T16:00:00Z" + } + ], + "expectedClearedInstances": { + "complete": [], + "skipped": [] + } + } + ] + }, + { + "name": "exclusive-range-boundaries", + "input": { + "tasks": [ + { + "id": "a", + "path": "a.md", + "title": "A", + "status": "open", + "priority": "normal", + "archived": false, + "scheduled": "2026-03-07T23:00", + "timeEstimate": 60, + "timeEntries": [ + { + "startTime": "2026-03-07T23:00:00Z", + "endTime": "2026-03-08T00:00:00Z" + }, + { + "startTime": "2026-03-09T00:00:00Z", + "endTime": "2026-03-09T01:00:00Z" + } + ] + } + ], + "settings": {}, + "range": { + "start": "2026-03-08", + "end": "2026-03-09" + }, + "timezone": "UTC", + "now": "2026-03-08T16:00:00Z" + }, + "expectedEntries": [], + "edits": [] + }, + { + "name": "recurring-due-remains-standalone-deadline", + "input": { + "tasks": [ + { + "id": "a", + "path": "a.md", + "title": "A", + "status": "open", + "priority": "normal", + "archived": false, + "scheduled": "2026-03-07T09:00", + "due": "2026-03-10T17:00", + "recurrence": "DTSTART:20260307T090000Z;FREQ=DAILY;COUNT=2" + } + ], + "settings": {}, + "range": { + "start": "2026-03-01", + "end": "2026-04-01" + }, + "timezone": "UTC", + "now": "2026-03-08T16:00:00Z" + }, + "expectedEntries": [ + { + "id": "next-scheduled-a-2026-03-07", + "taskId": "a", + "title": "A", + "type": "scheduled", + "start": "2026-03-07T09:00", + "allDay": false, + "durationMinutes": 60, + "isCompleted": false, + "isSkipped": false, + "startEditable": true, + "durationEditable": true, + "editReason": "next-occurrence", + "recurringKind": "next-scheduled", + "instanceDate": "2026-03-07", + "recurringTemplateTime": "09:00" + }, + { + "id": "recurring-a-2026-03-08", + "taskId": "a", + "title": "A", + "type": "recurring", + "start": "2026-03-08T09:00", + "allDay": false, + "durationMinutes": 60, + "isCompleted": false, + "isSkipped": false, + "startEditable": true, + "durationEditable": true, + "editReason": "series-time", + "recurringKind": "pattern", + "instanceDate": "2026-03-08", + "recurringTemplateTime": "09:00" + }, + { + "id": "due-a", + "taskId": "a", + "title": "A", + "type": "due", + "start": "2026-03-10T17:00", + "allDay": false, + "durationMinutes": 30, + "isCompleted": false, + "isSkipped": false, + "startEditable": true, + "durationEditable": false, + "editReason": "deadline-marker", + "end": "2026-03-10T17:30" + } + ], + "edits": [] + }, + { + "name": "bases-list-all-day-slot-off", + "input": { + "tasks": [ + { + "id": "a", + "path": "a.md", + "title": "A", + "status": "open", + "priority": "normal", + "archived": false, + "scheduled": "2026-03-07", + "due": "2026-03-08T12:00" + } + ], + "settings": { + "calendarView": "listWeek", + "showAllDaySlot": false + }, + "range": { + "start": "2026-03-01", + "end": "2026-04-01" + }, + "timezone": "UTC", + "now": "2026-03-08T16:00:00Z" + }, + "expectedEntries": [ + { + "id": "due-a", + "taskId": "a", + "title": "A", + "type": "due", + "start": "2026-03-08T12:00", + "allDay": false, + "durationMinutes": 30, + "isCompleted": false, + "isSkipped": false, + "startEditable": true, + "durationEditable": false, + "editReason": "deadline-marker", + "end": "2026-03-08T12:30" + } + ], + "edits": [] + }, + { + "name": "recurring-timed-spans-keep-occurrence-identities", + "input": { + "tasks": [ + { + "id": "a", + "path": "a.md", + "title": "A", + "status": "open", + "priority": "normal", + "archived": false, + "scheduled": "2026-03-07T09:00", + "due": "2026-03-08T17:00", + "recurrence": "DTSTART:20260307T090000Z;FREQ=DAILY;COUNT=2", + "timeEstimate": 30 + } + ], + "settings": { + "showScheduledToDueSpan": true + }, + "range": { + "start": "2026-03-01", + "end": "2026-04-01" + }, + "timezone": "UTC", + "now": "2026-03-08T16:00:00Z" + }, + "expectedEntries": [ + { + "id": "span-next-scheduled-a-2026-03-07-2026-03-07", + "taskId": "a", + "title": "A", + "type": "scheduledToDueSpan", + "start": "2026-03-07T09:00", + "allDay": false, + "durationMinutes": 30, + "isCompleted": false, + "isSkipped": false, + "startEditable": false, + "durationEditable": false, + "editReason": "recurring-span-readonly", + "end": "2026-03-07T09:30", + "recurringKind": "next-scheduled", + "instanceDate": "2026-03-07", + "recurringTemplateTime": "09:00" + }, + { + "id": "span-next-scheduled-a-2026-03-07-2026-03-08", + "taskId": "a", + "title": "A", + "type": "scheduledToDueSpan", + "start": "2026-03-08T09:00", + "allDay": false, + "durationMinutes": 30, + "isCompleted": false, + "isSkipped": false, + "startEditable": false, + "durationEditable": false, + "editReason": "recurring-span-readonly", + "end": "2026-03-08T09:30", + "recurringKind": "next-scheduled", + "instanceDate": "2026-03-07", + "recurringTemplateTime": "09:00" + }, + { + "id": "span-pattern-a-2026-03-08-2026-03-08", + "taskId": "a", + "title": "A", + "type": "scheduledToDueSpan", + "start": "2026-03-08T09:00", + "allDay": false, + "durationMinutes": 30, + "isCompleted": false, + "isSkipped": false, + "startEditable": false, + "durationEditable": false, + "editReason": "recurring-span-readonly", + "end": "2026-03-08T09:30", + "recurringKind": "pattern", + "instanceDate": "2026-03-08", + "recurringTemplateTime": "09:00" + }, + { + "id": "span-pattern-a-2026-03-08-2026-03-09", + "taskId": "a", + "title": "A", + "type": "scheduledToDueSpan", + "start": "2026-03-09T09:00", + "allDay": false, + "durationMinutes": 30, + "isCompleted": false, + "isSkipped": false, + "startEditable": false, + "durationEditable": false, + "editReason": "recurring-span-readonly", + "end": "2026-03-09T09:30", + "recurringKind": "pattern", + "instanceDate": "2026-03-08", + "recurringTemplateTime": "09:00" + } + ], + "edits": [] + }, + { + "name": "configured-completed-statuses-hide-overdue", + "input": { + "tasks": [ + { + "id": "a", + "path": "a.md", + "title": "A", + "status": "finished", + "priority": "normal", + "archived": false, + "scheduled": "2026-03-07T09:00" + } + ], + "settings": { + "completedStatuses": [ + "finished" + ], + "showOverdueOnToday": true + }, + "range": { + "start": "2026-03-08", + "end": "2026-03-09" + }, + "timezone": "UTC", + "now": "2026-03-08T16:00:00Z" + }, + "expectedEntries": [], + "edits": [] + }, + { + "name": "canonical-instant-recurring-anchor-is-local-day", + "input": { + "tasks": [ + { + "id": "a", + "path": "a.md", + "title": "A", + "status": "open", + "priority": "normal", + "archived": false, + "scheduled": "2026-03-08T04:30:00Z", + "due": "2026-03-07", + "recurrence": "FREQ=WEEKLY;COUNT=2" + } + ], + "settings": {}, + "range": { + "start": "2026-03-01", + "end": "2026-04-01" + }, + "timezone": "America/New_York", + "now": "2026-03-08T16:00:00Z" + }, + "expectedEntries": [ + { + "id": "next-scheduled-a-2026-03-07", + "taskId": "a", + "title": "A", + "type": "scheduled", + "start": "2026-03-08T04:30:00Z", + "allDay": false, + "durationMinutes": 60, + "isCompleted": false, + "isSkipped": false, + "startEditable": true, + "durationEditable": true, + "editReason": "next-occurrence", + "recurringKind": "next-scheduled", + "instanceDate": "2026-03-07", + "recurringTemplateTime": "23:30" + }, + { + "id": "recurring-a-2026-03-14", + "taskId": "a", + "title": "A", + "type": "recurring", + "start": "2026-03-14T23:30", + "allDay": false, + "durationMinutes": 60, + "isCompleted": false, + "isSkipped": false, + "startEditable": true, + "durationEditable": true, + "editReason": "series-time", + "recurringKind": "pattern", + "instanceDate": "2026-03-14", + "recurringTemplateTime": "23:30" + } + ], + "edits": [ + { + "entryId": "recurring-a-2026-03-14", + "change": { + "kind": "drop", + "start": "2026-03-15T17:00", + "allDay": false + }, + "expectedUpdates": { + "recurrence": "DTSTART:20260307T170000Z;FREQ=WEEKLY;COUNT=2", + "dateModified": "2026-03-08T16:00:00Z" + }, + "expectedFrontmatterPatch": [ + { + "op": "set", + "field": "recurrence", + "value": "DTSTART:20260307T170000Z;FREQ=WEEKLY;COUNT=2" + }, + { + "op": "set", + "field": "dateModified", + "value": "2026-03-08T16:00:00Z" + } + ], + "expectedClearedInstances": { + "complete": [], + "skipped": [] + } + } + ] + } +] diff --git a/package-lock.json b/package-lock.json index b407de3..e6dab91 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@tasknotes/model", - "version": "0.3.0-rc.16", + "version": "0.3.0-rc.17", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tasknotes/model", - "version": "0.3.0-rc.16", + "version": "0.3.0-rc.17", "license": "MIT", "dependencies": { "rrule": "^2.8.1", diff --git a/package.json b/package.json index a08caab..feaee21 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tasknotes/model", - "version": "0.3.0-rc.16", + "version": "0.3.0-rc.17", "description": "TaskNotes model, mapping, validation, recurrence, and operation-planning reference implementation.", "license": "MIT", "type": "module", @@ -17,6 +17,9 @@ "types": "./dist/types/index.d.ts", "typesVersions": { "*": { + "calendar/fixtures": [ + "dist/types/calendar-fixtures.d.ts" + ], "dist/types/index.d.ts": [ "dist/types/index.d.ts" ], @@ -101,6 +104,17 @@ "import": "./dist/esm/starter.js", "require": "./dist/cjs/starter.cjs" }, + "./calendar": { + "types": "./dist/types/calendar.d.ts", + "import": "./dist/esm/calendar.js", + "require": "./dist/cjs/calendar.cjs" + }, + "./calendar/fixtures": { + "types": "./dist/types/calendar-fixtures.d.ts", + "import": "./dist/esm/calendar-fixtures.js", + "require": "./dist/cjs/calendar-fixtures.cjs" + }, + "./fixtures/calendar/*": "./fixtures/calendar/*", "./conformance": { "types": "./dist/types/conformance.d.ts", "import": "./dist/esm/conformance.js", @@ -109,6 +123,7 @@ }, "files": [ "dist", + "fixtures/calendar", "README.md", "LICENSE" ], @@ -120,6 +135,7 @@ "sync:mdbase-contract": "node scripts/sync-tasknotes-contract.mjs", "build": "npm run clean && node scripts/build.mjs && tsc -p tsconfig.types.json", "prepack": "npm run build", + "typecheck": "tsc -p tsconfig.types.json --emitDeclarationOnly false --noEmit", "test": "npm run build && node --test test/**/*.test.mjs", "test:package": "node scripts/smoke-package.mjs" }, diff --git a/scripts/build.mjs b/scripts/build.mjs index d8fb568..482d964 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -18,6 +18,8 @@ const entries = [ "mdbase", "starter", "conformance", + "calendar", + "calendar-fixtures", ]; const external = ["rrule", "yaml", "zod"]; diff --git a/scripts/smoke-package.mjs b/scripts/smoke-package.mjs index 507cc44..e1bcd23 100644 --- a/scripts/smoke-package.mjs +++ b/scripts/smoke-package.mjs @@ -45,6 +45,12 @@ try { 'const { canonicalAttachmentReference } = require("@tasknotes/model/attachments");\nif (canonicalAttachmentReference("Attachments/image.png") !== "[[Attachments/image.png]]") throw new Error("CJS attachment export is unavailable");\n', ); + 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.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); + runNpm(["install", "--ignore-scripts", tarball], { cwd: tempRoot, stdio: "inherit", @@ -53,6 +59,13 @@ try { execFileSync(process.execPath, ["cjs.cjs"], { cwd: tempRoot, stdio: "inherit" }); execFileSync(process.execPath, ["attachments.mjs"], { cwd: tempRoot, stdio: "inherit" }); execFileSync(process.execPath, ["attachments.cjs"], { cwd: tempRoot, stdio: "inherit" }); + execFileSync(process.execPath, ["calendar.mjs"], { cwd: tempRoot, stdio: "inherit" }); + execFileSync(process.execPath, ["calendar.cjs"], { cwd: tempRoot, stdio: "inherit" }); + for (const resolution of ["nodenext", "node"]) { + execFileSync(process.execPath, [resolve(packageRoot, "node_modules/typescript/bin/tsc"), + "--noEmit", "--strict", "--target", "es2020", "--module", resolution === "nodenext" ? "nodenext" : "esnext", + "--moduleResolution", resolution, "calendar.ts"], { cwd: tempRoot, stdio: "inherit" }); + } } finally { if (tarball) rmSync(tarball, { force: true }); rmSync(tempRoot, { recursive: true, force: true }); diff --git a/src/calendar-date.ts b/src/calendar-date.ts new file mode 100644 index 0000000..394fcde --- /dev/null +++ b/src/calendar-date.ts @@ -0,0 +1,72 @@ +// Calendar dates are civil values in an explicit IANA zone, never the process zone. +const DAY = 86_400_000; +const CIVIL = /^(\d{4}-\d{2}-\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?)?$/; + +export function calendarDay(value: string): string { + return value.slice(0, 10); +} + +export function addCalendarDays(day: string, days: number): string { + return new Date(Date.parse(`${day}T00:00:00Z`) + days * DAY).toISOString().slice(0, 10); +} + +export function calendarDaysBetween(start: string, end: string): number { + return ( + (Date.parse(`${calendarDay(end)}T00:00:00Z`) - Date.parse(`${calendarDay(start)}T00:00:00Z`)) / + DAY + ); +} + +function formatter(timezone: string): Intl.DateTimeFormat { + return new Intl.DateTimeFormat("en-CA", { + timeZone: timezone, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hourCycle: "h23", + }); +} + +function civilAt(ms: number, format: Intl.DateTimeFormat): string { + const p = Object.fromEntries(format.formatToParts(ms).map(({ type, value }) => [type, value])); + return `${p.year}-${p.month}-${p.day}T${p.hour}:${p.minute}:${p.second}`; +} + +/** Date-only/floating values use timezone. Offset values are instants. + * DST overlaps choose the earlier instant; gaps move forward (Date-compatible). + */ +export function calendarDateToInstant(value: string, timezone: string): number { + const match = CIVIL.exec(value); + if (!match) { + if (!/T.*(?:Z|[+-]\d{2}:?\d{2})$/i.test(value)) throw new Error("invalid-calendar-date"); + const ms = Date.parse(value); + if (!Number.isFinite(ms)) throw new Error("invalid-calendar-date"); + return ms; + } + const [, day, hour = "00", minute = "00", second = "00", fraction = "0"] = match; + const civil = `${day}T${hour}:${minute}:${second}`; + const wall = Date.parse(`${civil}Z`); + if (!Number.isFinite(wall) || new Date(wall).toISOString().slice(0, 19) !== civil) { + throw new Error("invalid-calendar-date"); + } + const format = formatter(timezone); + const offsets = new Set(); + for (const delta of [-2 * DAY, -DAY, 0, DAY, 2 * DAY]) { + const probe = wall + delta; + offsets.add(Date.parse(`${civilAt(probe, format)}Z`) - probe); + } + const candidates = [...offsets].map((offset) => wall - offset); + const matching = candidates.filter((ms) => civilAt(ms, format) === civil); + return ( + (matching.length ? Math.min(...matching) : Math.max(...candidates)) + + Number(fraction.padEnd(3, "0")) + ); +} + +export function calendarInstantToDate(ms: number, timezone: string, allDay = false): string { + const civil = civilAt(ms, formatter(timezone)); + return civil.slice(0, allDay ? 10 : 16); +} diff --git a/src/calendar-fixtures.ts b/src/calendar-fixtures.ts new file mode 100644 index 0000000..31fae6d --- /dev/null +++ b/src/calendar-fixtures.ts @@ -0,0 +1,19 @@ +import scenarios from "../fixtures/calendar/scenarios.json"; +import type { BuildCalendarEntriesInput, CalendarChange, CalendarEntry } from "./calendar.js"; +import type { TaskInfo, TaskPatchOperation, TaskUpdateInput } from "./types.js"; + +export interface CalendarFixture { + name: string; + input: Omit & { tasks: TaskInfo[] }; + expectedEntries: CalendarEntry[]; + edits: Array<{ + entryId: string; + change: CalendarChange; + expectedUpdates: TaskUpdateInput; + expectedFrontmatterPatch: TaskPatchOperation[]; + expectedClearedInstances: { complete: string[]; skipped: string[] }; + }>; +} + +/** Static cross-host regression contract; no runtime projection used to produce expectations. */ +export const calendarFixtures = scenarios as CalendarFixture[]; diff --git a/src/calendar.ts b/src/calendar.ts new file mode 100644 index 0000000..f8a78a9 --- /dev/null +++ b/src/calendar.ts @@ -0,0 +1,730 @@ +import { generateRecurringInstances } from "./recurrence.js"; +import { normalizeTaskReference, taskInfoUpdatesToFrontmatterPatch } from "./operations.js"; +import type { FieldMapping, TaskInfo, TaskOperationPlan, TaskUpdateInput } from "./types.js"; +import { + addCalendarDays, + calendarDateToInstant, + calendarDay, + calendarDaysBetween, + calendarInstantToDate, +} from "./calendar-date.js"; +export { calendarDateToInstant, calendarInstantToDate } from "./calendar-date.js"; + +/** Hosts filter rows/archives and resolve storage identities before calling the model. */ +export type CalendarTask = Pick & Partial; +export interface CalendarRange { + start: string; + end: string; +} +export interface CalendarSettings { + showScheduled?: boolean; + showDue?: boolean; + showScheduledToDueSpan?: boolean; + showRecurring?: boolean; + showCompletedRecurringInstances?: boolean; + showSkippedRecurringInstances?: boolean; + showTimeEntries?: boolean; + showOverdueOnToday?: boolean; + /** Bases hides all-day entries in list views when the all-day slot is off. */ + showAllDaySlot?: boolean; + /** Active host view name (e.g. listWeek), not necessarily the saved initial view. */ + calendarView?: string; + hideCompletedFromOverdue?: boolean; + completedStatuses?: readonly string[]; + /** Implicit renderer lengths; absence of timeEstimate never writes an estimate. */ + defaultTimedMinutes?: number; + defaultAllDayDays?: number; +} +export interface CalendarContext { + timezone: string; + /** ISO instant (required even when overdue display is off). */ + now: string; + settings?: CalendarSettings; + /** Resolved parent task id -> dates, including children outside the filtered rows. */ + materializedOccurrences?: Readonly>; +} +export type CalendarEntryType = + "scheduled" | "due" | "scheduledToDueSpan" | "recurring" | "timeEntry"; +export type CalendarRecurringKind = "next-scheduled" | "pattern" | "recorded"; +export interface CalendarEntry { + id: string; + taskId: string; + title: string; + type: CalendarEntryType; + start: string; + /** Exclusive end; omitted when the renderer should use its default duration. */ + end?: string; + allDay: boolean; + durationMinutes: number; + isCompleted: boolean; + isSkipped: boolean; + startEditable: boolean; + durationEditable: boolean; + editReason: + | "date" + | "span-shift" + | "deadline-marker" + | "time-entry" + | "next-occurrence" + | "series-time" + | "recorded-time" + | "recurring-span-readonly"; + recurringKind?: CalendarRecurringKind; + instanceDate?: string; + recurringTemplateTime?: string; + timeEntryIndex?: number; + isMaterializedOccurrence?: boolean; + occurrenceParent?: string; + isOverdueOnToday?: boolean; +} +export interface BuildCalendarEntriesInput extends CalendarContext { + tasks: readonly CalendarTask[]; + range: CalendarRange; +} + +export function calendarTaskId(task: CalendarTask): string { + return task.id || task.path; +} + +function settings(context: CalendarContext) { + return { + showScheduled: true, + showDue: true, + showScheduledToDueSpan: false, + showRecurring: true, + showCompletedRecurringInstances: true, + showSkippedRecurringInstances: true, + showTimeEntries: true, + showOverdueOnToday: false, + hideCompletedFromOverdue: true, + completedStatuses: ["done"] as readonly string[], + defaultTimedMinutes: 60, + defaultAllDayDays: 1, + ...context.settings, + }; +} + +function materializedIndex( + tasks: readonly CalendarTask[], + context: CalendarContext, +): Map> { + const result = new Map>(); + const aliases = new Map(); + for (const task of tasks) { + aliases.set(normalizeTaskReference(task.path), calendarTaskId(task)); + aliases.set(normalizeTaskReference(calendarTaskId(task)), calendarTaskId(task)); + } + const add = (parent: string, date: string) => { + const key = aliases.get(normalizeTaskReference(parent)) || parent; + const dates = result.get(key) || new Set(); + dates.add(calendarDay(date)); + result.set(key, dates); + }; + for (const [parent, dates] of Object.entries(context.materializedOccurrences || {})) { + for (const date of dates) add(parent, date); + } + for (const task of tasks) { + if (task.recurrence_parent && task.occurrence_date) + add(task.recurrence_parent, task.occurrence_date); + } + return result; +} + +function rangeBounds(range: CalendarRange, context: CalendarContext) { + const start = calendarDateToInstant(range.start, context.timezone); + const end = calendarDateToInstant(range.end, context.timezone); + if (end <= start) throw new Error("invalid-calendar-range"); + return { start, end }; +} + +function buildTaskEntries( + task: CalendarTask, + range: CalendarRange, + context: CalendarContext, + materialized: ReadonlySet, +): CalendarEntry[] { + const s = settings(context); + const zone = context.timezone; + const bounds = rangeBounds(range, context); + const startDay = calendarInstantToDate(bounds.start, zone, true); + const endDate = calendarInstantToDate(bounds.end, zone, true); + const endDay = + bounds.end > calendarDateToInstant(endDate, zone) ? addCalendarDays(endDate, 1) : endDate; + const taskId = calendarTaskId(task); + const entries: CalendarEntry[] = []; + const isCompleted = s.completedStatuses.includes(task.status || ""); + const today = calendarInstantToDate(calendarDateToInstant(context.now, zone), zone, true); + const timed = (value: string) => value.includes("T"); + const instants = new Map(); + const instant = (value: string) => { + let ms = instants.get(value); + if (ms === undefined) { + ms = calendarDateToInstant(value, zone); + instants.set(value, ms); + } + return ms; + }; + const civil = (value: string) => + timed(value) ? calendarInstantToDate(instant(value), zone) : value; + const dayOf = (value: string) => calendarDay(civil(value)); + const endAt = (ms: number) => { + const civil = calendarInstantToDate(ms, zone); + // A second fall-back clock cannot be represented by a floating value alone. + return instant(civil) === ms ? civil : new Date(ms).toISOString(); + }; + const visible = (entry: CalendarEntry) => { + const start = instant(entry.start); + const end = entry.end ? instant(entry.end) : start; + return start < bounds.end && (end > start ? end > bounds.start : start >= bounds.start); + }; + const base = ( + type: CalendarEntryType, + start: string, + id: string, + end?: string, + ): CalendarEntry => { + const allDay = !timed(start); + const entry: CalendarEntry = { + id, + taskId, + title: task.title, + type, + start, + ...(end ? { end } : {}), + allDay, + durationMinutes: end + ? allDay + ? calendarDaysBetween(start, end) * 1440 + : Math.round((instant(end) - instant(start)) / 60000) + : allDay + ? s.defaultAllDayDays * 1440 + : s.defaultTimedMinutes, + isCompleted, + isSkipped: false, + startEditable: true, + durationEditable: type === "scheduled" || type === "recurring" || type === "timeEntry", + editReason: + type === "timeEntry" + ? "time-entry" + : type === "due" + ? "deadline-marker" + : type === "scheduledToDueSpan" + ? "span-shift" + : "date", + }; + if (task.recurrence_parent && task.occurrence_date) { + entry.isMaterializedOccurrence = true; + entry.instanceDate = calendarDay(task.occurrence_date); + entry.occurrenceParent = task.recurrence_parent; + } + return entry; + }; + const durationEnd = (start: string): string | undefined => + task.timeEstimate + ? timed(start) + ? endAt(instant(start) + task.timeEstimate * 60000) + : addCalendarDays(calendarDay(start), Math.ceil(task.timeEstimate / 1440)) + : undefined; + const scheduled = (start: string) => + base("scheduled", start, `scheduled-${taskId}`, durationEnd(start)); + const due = () => { + const start = task.due!; + const end = timed(start) + ? endAt( + Math.min( + instant(start) + 30 * 60000, + instant(addCalendarDays(calendarInstantToDate(instant(start), zone, true), 1)), + ), + ) + : undefined; + return base("due", start, `due-${taskId}`, end); + }; + const spans = (scheduledInput: string, dueInput: string): CalendarEntry[] => { + const scheduledValue = civil(scheduledInput); + const dueValue = civil(dueInput); + if (instant(dueValue) <= instant(scheduledValue)) return []; + if (!timed(scheduledValue)) { + return [ + base( + "scheduledToDueSpan", + calendarDay(scheduledValue), + `span-${taskId}`, + addCalendarDays(calendarDay(dueValue), 1), + ), + ].filter(visible); + } + const result: CalendarEntry[] = []; + // Clip long spans before iterating, retaining overnight work that overlaps the range. + const lookback = Math.max(1, Math.ceil((task.timeEstimate || 0) / 1440)); + const first = + calendarDay(scheduledValue) > addCalendarDays(startDay, -lookback) + ? calendarDay(scheduledValue) + : addCalendarDays(startDay, -lookback); + const last = calendarDay(dueValue) < endDay ? calendarDay(dueValue) : endDay; + for (let day = first; day <= last; day = addCalendarDays(day, 1)) { + const start = `${day}T${scheduledValue.slice(11, 16)}`; + const entry = base("scheduledToDueSpan", start, `span-${taskId}-${day}`, durationEnd(start)); + if (visible(entry)) result.push(entry); + } + return result; + }; + const generated: CalendarEntry[] = []; + let includeScheduled = s.showScheduled; + let includeDue = s.showDue; + let allowSpan = true; + if ( + task.recurrence && + task.scheduled && + (s.showRecurring || s.showCompletedRecurringInstances || s.showSkippedRecurringInstances) + ) { + const nextDate = dayOf(task.scheduled); + const dtstartTime = /DTSTART(?:;[^:\r\n]+)?:\d{8}T(\d{2})(\d{2})/.exec(task.recurrence); + const templateTime = dtstartTime + ? `${dtstartTime[1]}:${dtstartTime[2]}` + : civil(task.scheduled).slice(11, 16) || "09:00"; + const spanOffset = + s.showScheduledToDueSpan && task.due && instant(task.due) > instant(task.scheduled) + ? calendarDaysBetween(civil(task.scheduled), civil(task.due)) + : null; + const searchStart = addCalendarDays( + startDay, + -Math.max(spanOffset || 0, Math.ceil((task.timeEstimate || 0) / 1440)), + ); + const moved = new Set(task.googleCalendarMovedOriginalDates || []); + if (task.googleCalendarExceptionOriginalScheduled) + moved.add(calendarDay(task.googleCalendarExceptionOriginalScheduled)); + const dates = new Map(); + if (s.showRecurring) { + dates.set(nextDate, "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. + // Normalize parser input only; preserve all stored RFC properties for edits. + const civilTask = { + ...task, + scheduled: civil(task.scheduled), + recurrence: task.recurrence.replace(/;TZID=[^:;\r\n]+/g, ""), + }; + for (const date of generateRecurringInstances( + civilTask, + new Date(`${searchStart}T00:00:00Z`), + 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"); + } + } + 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"); + } + for (const [day, kind] of dates) { + const completed = !!task.complete_instances?.includes(day); + const skipped = !!task.skipped_instances?.includes(day); + if ( + materialized.has(day) || + (kind !== "recorded" && + ((completed && !s.showCompletedRecurringInstances) || + (skipped && !s.showSkippedRecurringInstances))) + ) + continue; + const clock = + kind === "next-scheduled" ? civil(task.scheduled).slice(11, 16) || "09:00" : templateTime; + const start = + kind === "next-scheduled" + ? task.scheduled + : timed(task.scheduled) + ? `${day}T${clock}` + : day; + const recurringEntries = + spanOffset !== null && task.due + ? spans( + start, + `${addCalendarDays(day, spanOffset)}${timed(task.due) ? `T${civil(task.due).slice(11, 16)}` : ""}`, + ) + : [scheduled(start)]; + for (const entry of recurringEntries) { + const span = entry.type === "scheduledToDueSpan"; + entry.id = span + ? `span-${kind}-${taskId}-${day}-${calendarDay(entry.start)}` + : `${kind === "next-scheduled" ? "next-scheduled" : kind === "pattern" ? "recurring" : "recurring-recorded"}-${taskId}-${day}`; + if (!span && kind !== "next-scheduled") entry.type = "recurring"; + Object.assign(entry, { + recurringKind: kind, + instanceDate: day, + recurringTemplateTime: clock, + isCompleted: completed, + isSkipped: skipped, + startEditable: !span, + durationEditable: !span, + editReason: span + ? "recurring-span-readonly" + : kind === "next-scheduled" + ? "next-occurrence" + : kind === "pattern" + ? "series-time" + : "recorded-time", + }); + if (visible(entry)) generated.push(entry); + } + } + entries.push(...generated); + if (s.showRecurring) { + includeScheduled = false; + allowSpan = false; + if (generated.some((e) => e.type === "scheduledToDueSpan")) includeDue = false; + } + } + let showedSpan = false; + if (allowSpan && s.showScheduledToDueSpan && task.scheduled && task.due) { + const spanEntries = spans(task.scheduled, task.due); + entries.push(...spanEntries); + showedSpan = spanEntries.length > 0; + } + const addDate = (entry: CalendarEntry, suppressOverdue = false): boolean => { + if (visible(entry)) { + entries.push(entry); + return false; + } + if ( + !s.showOverdueOnToday || + suppressOverdue || + dayOf(entry.start) >= today || + (s.hideCompletedFromOverdue && entry.isCompleted) || + instant(entry.start) >= instant(context.now) + ) + return false; + const start = `${today}${civil(entry.start).slice(10)}`; + const endMs = entry.end + ? instant(start) + instant(entry.end) - instant(entry.start) + : undefined; + const end = + endMs === undefined + ? undefined + : entry.allDay + ? calendarInstantToDate(endMs, zone, true) + : endAt(endMs); + const overdue = { + ...entry, + id: `${entry.id}-overdue-today`, + start, + ...(end ? { end } : {}), + isOverdueOnToday: true, + }; + if (!visible(overdue)) return false; + entries.push(overdue); + return true; + }; + if (!showedSpan) { + const scheduledOverdue = + includeScheduled && task.scheduled ? addDate(scheduled(task.scheduled)) : false; + const sameDayDue = + task.scheduled && task.due && !timed(task.due) && dayOf(task.scheduled) === dayOf(task.due); + if (includeDue && task.due && !((includeScheduled || generated.length > 0) && sameDayDue)) { + addDate( + due(), + !timed(task.due) && + (scheduledOverdue || generated.some((entry) => dayOf(entry.start) === today)), + ); + } + } + if (s.showTimeEntries) { + for (const [index, timeEntry] of (task.timeEntries || []).entries()) { + // Plugin semantics: only finished sessions appear on the calendar. + if (!timeEntry.endTime) continue; + const entry = base( + "timeEntry", + timeEntry.startTime, + `timeentry-${taskId}-${index}`, + timeEntry.endTime, + ); + entry.timeEntryIndex = index; + if (visible(entry)) entries.push(entry); + } + } + return s.showAllDaySlot === false && s.calendarView?.startsWith("list") + ? entries.filter((entry) => !entry.allDay) + : entries; +} + +/** Pure task-only calendar projection. Range end is exclusive. */ +export function buildCalendarEntries(input: BuildCalendarEntriesInput): CalendarEntry[] { + rangeBounds(input.range, input); + calendarDateToInstant(input.now, input.timezone); + const materialized = materializedIndex(input.tasks, input); + return input.tasks.flatMap((task) => + buildTaskEntries(task, input.range, input, materialized.get(calendarTaskId(task)) || new Set()), + ); +} + +export interface CalendarChange { + kind: "drop" | "drop-on-day" | "resize"; + start: string; + end?: string; + allDay: boolean; +} +export interface CalendarEditPlan extends TaskOperationPlan { + updates: TaskUpdateInput; + /** Caller may confirm these destructive removals before persisting the plan. */ + clearedInstances: { complete: string[]; skipped: string[] }; + /** Apply exact updates/patch; generic task.update recurrence progression must not run. */ + preserveRecurrenceSchedule: true; +} +export interface PlanCalendarEditInput { + task: TaskInfo; + entry: CalendarEntry; + change: CalendarChange; + timezone: string; + now: string; + fieldMapping: FieldMapping; +} + +/** Plans a calendar gesture against fresh task data; never mutates or materializes tasks. */ +export function planCalendarEdit({ + task, + entry, + change, + timezone, + now, + fieldMapping, +}: PlanCalendarEditInput): CalendarEditPlan { + if (entry.taskId !== calendarTaskId(task)) throw new Error("calendar-task-mismatch"); + if (change.kind === "resize" ? !entry.durationEditable : !entry.startEditable) + throw new Error("calendar-entry-readonly"); + const start = calendarDateToInstant(change.start, timezone); + const end = change.end ? calendarDateToInstant(change.end, timezone) : undefined; + calendarDateToInstant(now, timezone); + const value = calendarInstantToDate( + start, + timezone, + change.allDay || change.kind === "drop-on-day", + ); + const dayOf = (date: string) => + calendarInstantToDate(calendarDateToInstant(date, timezone), timezone, true); + const updates: TaskUpdateInput = {}; + const clearedInstances = { complete: [] as string[], skipped: [] as string[] }; + if (entry.type === "timeEntry") { + if (entry.timeEntryIndex === undefined || end === undefined) + throw new Error("missing-time-entry-range"); + const timeEntries = (task.timeEntries || []).map(({ duration: _duration, ...rest }) => ({ + ...rest, + })); + const current = timeEntries[entry.timeEntryIndex]; + if (!current?.endTime) throw new Error("missing-time-entry"); + if (change.kind === "resize") { + current.startTime = new Date(start).toISOString(); + current.endTime = new Date(end).toISOString(); + } else { + const delta = start - calendarDateToInstant(entry.start, timezone); + current.startTime = new Date( + calendarDateToInstant(current.startTime, timezone) + delta, + ).toISOString(); + current.endTime = new Date( + calendarDateToInstant(current.endTime, timezone) + delta, + ).toISOString(); + } + updates.timeEntries = timeEntries; + } else if (change.kind === "resize") { + if (end === undefined) throw new Error("missing-calendar-range"); + updates.timeEstimate = change.allDay + ? Math.round((end - start) / 86_400_000) * 1440 + : Math.round((end - start) / 60000); + } else if (entry.type === "scheduledToDueSpan") { + const delta = start - calendarDateToInstant(entry.start, timezone); + for (const property of ["scheduled", "due"] as const) { + if (task[property]) + updates[property] = calendarInstantToDate( + calendarDateToInstant(task[property]!, timezone) + delta, + timezone, + !task[property]!.includes("T"), + ); + } + } else if (entry.recurringKind === "pattern" && !entry.isMaterializedOccurrence) { + if (!task.recurrence) throw new Error("missing-recurrence"); + // Preserve the DTSTART date, parameters and RFC lines; only the pattern clock changes. + const pattern = /DTSTART(?:;[^:\r\n]+)?:([^;\r\n]+)/; + const match = pattern.exec(task.recurrence); + const source = + match?.[1].slice(0, 8) || + (task.scheduled || task.dateCreated + ? dayOf(task.scheduled || task.dateCreated!).replace(/-/g, "") + : ""); + if (!/^\d{8}$/.test(source)) throw new Error("missing-recurrence-start"); + const dateOnly = change.allDay || change.kind === "drop-on-day"; + const prefix = match ? match[0].slice(0, match[0].length - match[1].length) : "DTSTART:"; + const nextPrefix = dateOnly + ? prefix.replace(/;TZID=[^:;]+/, "").replace(/;VALUE=DATE-TIME/, ";VALUE=DATE") + : prefix.replace(/;VALUE=DATE(?=;|:)/, ""); + const dtstart = dateOnly + ? source + : `${source}T${value.slice(11, 16).replace(":", "")}00${nextPrefix.includes(";TZID=") ? "" : "Z"}`; + updates.recurrence = match + ? task.recurrence.replace(pattern, () => nextPrefix + dtstart) + : `${nextPrefix}${dtstart};${task.recurrence}`; + } else { + const property = entry.type === "due" ? "due" : "scheduled"; + updates[property] = + entry.recurringKind === "recorded" && !entry.isMaterializedOccurrence + ? `${dayOf(task.scheduled || entry.start)}${value.slice(10)}` + : value; + } + if ( + entry.type !== "scheduledToDueSpan" && + updates.scheduled && + task.recurrence && + task.scheduled && + dayOf(updates.scheduled) !== dayOf(task.scheduled) + ) { + const day = calendarDay(updates.scheduled); + clearedInstances.complete = (task.complete_instances || []).filter((date) => date >= day); + clearedInstances.skipped = (task.skipped_instances || []).filter((date) => date >= day); + if (clearedInstances.complete.length || clearedInstances.skipped.length) { + updates.complete_instances = (task.complete_instances || []).filter((date) => date < day); + updates.skipped_instances = (task.skipped_instances || []).filter((date) => date < day); + } + } + // Mirror the plugin's recurring-provider exception bookkeeping, without provider IO. + const tracksException = + !!task.recurrence && + (task.recurrence_anchor || "scheduled") === "scheduled" && + !!( + task.googleCalendarEventId || + task.googleCalendarExceptionEventId || + task.googleCalendarExceptionOriginalScheduled || + task.googleCalendarMovedOriginalDates?.length + ); + if ( + entry.type !== "scheduledToDueSpan" && + tracksException && + updates.scheduled && + updates.scheduled !== task.scheduled + ) { + const source = task.googleCalendarExceptionOriginalScheduled || task.scheduled || task.due; + const original = source ? dayOf(source) : ""; + if (original) + updates.googleCalendarExceptionOriginalScheduled = + calendarDay(updates.scheduled) !== original ? original : undefined; + } + updates.dateModified = now; + return { + kind: "calendar.edit", + updates, + updatedTask: { ...task, ...updates }, + frontmatterPatch: taskInfoUpdatesToFrontmatterPatch(updates, fieldMapping), + dateModified: now, + clearedInstances, + preserveRecurrenceSchedule: true, + }; +} + +export interface CalendarEntryIndex { + setTasks(tasks: readonly CalendarTask[]): void; + /** New object identity OR revision invalidates task-local range expansions. */ + upsert(taskId: string, task: CalendarTask, revision?: string | number): void; + remove(taskId: string): void; + setContext(context: CalendarContext): void; + entriesInRange(range: CalendarRange): CalendarEntry[]; + clear(): void; +} + +/** Bounded per-task/range cache. IdentityTasks outside the view go in materializedOccurrences. */ +export function createCalendarEntryIndex( + initialContext: CalendarContext, + maxRangesPerTask = 8, +): CalendarEntryIndex { + if (!Number.isInteger(maxRangesPerTask) || maxRangesPerTask < 1) + throw new Error("invalid-calendar-cache-size"); + const snapshot = (value: CalendarContext): CalendarContext => ({ + timezone: value.timezone, + now: value.now, + settings: value.settings + ? { + ...value.settings, + ...(value.settings.completedStatuses + ? { completedStatuses: [...value.settings.completedStatuses] } + : {}), + } + : undefined, + materializedOccurrences: value.materializedOccurrences + ? Object.fromEntries( + Object.entries(value.materializedOccurrences).map(([id, dates]) => [id, [...dates]]), + ) + : undefined, + }); + let context = snapshot(initialContext); + const cacheContextKey = (value: CalendarContext) => + JSON.stringify({ ...value, now: value.settings?.showOverdueOnToday ? value.now : "" }); + let contextKey = cacheContextKey(context); + const tasks = new Map< + string, + { task: CalendarTask; revision?: string | number; explicitRevision: boolean; ranges: Map } + >(); + const upsert: CalendarEntryIndex["upsert"] = (id, task, revision) => { + if (id !== calendarTaskId(task)) throw new Error("calendar-task-mismatch"); + const explicitRevision = revision !== undefined; + const effectiveRevision = revision ?? task.dateModified; + const previous = tasks.get(id); + if (previous?.task === task && previous.revision === effectiveRevision) { + previous.explicitRevision = explicitRevision; + return; + } + tasks.set(id, { task, revision: effectiveRevision, explicitRevision, ranges: new Map() }); + }; + return { + upsert, + setTasks(next) { + const ordered = next.map((task) => { + const id = calendarTaskId(task); + const previous = tasks.get(id); + // Membership/order updates must not replace a host revision with + // dateModified for the very same object. Default revisions still + // observe a changed dateModified on mutable task inputs. + upsert(id, task, previous?.task === task && previous.explicitRevision ? previous.revision : undefined); + return [id, tasks.get(id)!] as const; + }); + tasks.clear(); + for (const [id, value] of ordered) tasks.set(id, value); + }, + remove(id) { + tasks.delete(id); + }, + setContext(next) { + const copied = snapshot(next); + const key = cacheContextKey(copied); + if (key !== contextKey) { + for (const value of tasks.values()) value.ranges.clear(); + } + context = copied; + contextKey = key; + }, + entriesInRange(range) { + rangeBounds(range, context); + const materialized = materializedIndex( + [...tasks.values()].map(({ task }) => task), + context, + ); + return [...tasks].flatMap(([id, value]) => { + const dates = materialized.get(id) || new Set(); + const key = JSON.stringify([range, [...dates].sort()]); + let entries = value.ranges.get(key); + if (!entries) { + entries = buildTaskEntries(value.task, range, context, dates); + value.ranges.set(key, entries); + if (value.ranges.size > maxRangesPerTask) + value.ranges.delete(value.ranges.keys().next().value!); + } else { + value.ranges.delete(key); + value.ranges.set(key, entries); + } + // Callers may attach presentation metadata without poisoning cached projections. + return entries.map((entry) => ({ ...entry })); + }); + }, + clear() { + tasks.clear(); + }, + }; +} diff --git a/src/index.ts b/src/index.ts index a8336f6..bf6dbfd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,3 +13,4 @@ export * from "./frontmatter"; export * from "./mdbase"; export * from "./starter"; export * from "./conformance"; +export * from "./calendar"; diff --git a/src/operations.ts b/src/operations.ts index cd343d7..5af9d6e 100644 --- a/src/operations.ts +++ b/src/operations.ts @@ -1314,6 +1314,8 @@ function fieldNameForTaskProperty(fieldMapping: FieldMapping, property: keyof Ta completedDate: "completedDate", dateCreated: "dateCreated", dateModified: "dateModified", + googleCalendarExceptionOriginalScheduled: "googleCalendarExceptionOriginalScheduled", + googleCalendarMovedOriginalDates: "googleCalendarMovedOriginalDates", recurrence: "recurrence", recurrence_anchor: "recurrenceAnchor", complete_instances: "completeInstances", diff --git a/test/calendar.test.mjs b/test/calendar.test.mjs new file mode 100644 index 0000000..18fe931 --- /dev/null +++ b/test/calendar.test.mjs @@ -0,0 +1,222 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { test } from "node:test"; +import { + buildCalendarEntries, + createCalendarEntryIndex, + planCalendarEdit, + calendarDateToInstant, + calendarInstantToDate, +} from "../dist/esm/calendar.js"; +import { calendarFixtures } from "../dist/esm/calendar-fixtures.js"; +import { DEFAULT_FIELD_MAPPING } from "../dist/esm/defaults.js"; + +const byId = (entries) => [...entries].sort((a, b) => a.id.localeCompare(b.id)); +const byField = (patch) => [...patch].sort((a, b) => a.field.localeCompare(b.field)); +const fixture = (name) => structuredClone(calendarFixtures.find((f) => f.name === name)); + +for (const { name, input, expectedEntries, edits } of calendarFixtures) { + test(`calendar fixture: ${name}`, () => { + const before = structuredClone(input); + assert.deepEqual(byId(buildCalendarEntries(input)), byId(expectedEntries)); + const index = createCalendarEntryIndex(input); + index.setTasks(input.tasks); + for (let repeat = 0; repeat < 2; repeat++) { + assert.deepEqual(byId(index.entriesInRange(input.range)), byId(expectedEntries)); + } + for (const edit of edits) { + const entry = expectedEntries.find((entry) => entry.id === edit.entryId); + assert.ok(entry); + const task = input.tasks.find((task) => (task.id || task.path) === entry.taskId); + const plan = planCalendarEdit({ + task, + entry, + change: edit.change, + timezone: input.timezone, + now: input.now, + fieldMapping: DEFAULT_FIELD_MAPPING, + }); + assert.deepEqual(plan.updates, edit.expectedUpdates); + assert.deepEqual(byField(plan.frontmatterPatch), byField(edit.expectedFrontmatterPatch)); + assert.deepEqual(plan.clearedInstances, edit.expectedClearedInstances); + assert.deepEqual(plan.updatedTask, { ...task, ...edit.expectedUpdates }); + assert.equal(plan.preserveRecurrenceSchedule, true); + } + assert.deepEqual(input, before, "building and editing leave caller data intact"); + }); +} + +test("incremental index tracks identities, revisions, order, child dependencies and settings", () => { + const { input } = fixture("materialized-child-suppresses-parent"); + const index = createCalendarEntryIndex(input, 2); + let tasks = [...input.tasks]; + let context = { ...input }; + const check = () => + assert.deepEqual( + index.entriesInRange(input.range), + buildCalendarEntries({ ...context, tasks }), + ); + index.setTasks(tasks); + check(); + // Add/remove a materialization invalidates the parent's recurrence result, not just the child. + index.remove("b"); + tasks.pop(); + check(); + index.upsert("b", input.tasks[1]); + tasks.push(input.tasks[1]); + check(); + const renamed = { ...tasks[0], title: "Fresh title" }; + index.upsert("a", renamed); + tasks[0] = renamed; + check(); + // Mutable caches can supply a monotonic revision even when they reuse object identity. + renamed.scheduled = "2026-03-08"; + index.upsert("a", renamed, 1); + check(); + renamed.dateModified = "2026-03-08T17:00:00Z"; + renamed.timeEstimate = 60; + index.upsert("a", renamed); + check(); + tasks.reverse(); + index.setTasks(tasks); + check(); + context = { ...context, settings: { showRecurring: false, showTimeEntries: false } }; + index.setContext(context); + check(); + context = { ...context, settings: { showRecurring: true }, timezone: "Pacific/Auckland" }; + index.setContext(context); + check(); + // Evict ranges, then revisit: output must not depend on cache history. + index.entriesInRange({ start: "2026-01-01", end: "2026-02-01" }); + index.entriesInRange({ start: "2026-06-01", end: "2026-07-01" }); + check(); + const returned = index.entriesInRange(input.range); + returned[0].title = "Host annotation"; + check(); + index.clear(); + assert.deepEqual(index.entriesInRange(input.range), []); +}); + +test("unchanged tasks are not re-expanded when another task or an unused clock changes", () => { + const { input } = fixture("weekly-recorded-off-pattern-and-add-dtstart"); + const original = input.tasks[0]; + let recurrenceReads = 0; + const instrumented = { + ...original, + get recurrence() { + recurrenceReads++; + return original.recurrence; + }, + }; + const index = createCalendarEntryIndex(input); + index.setTasks([instrumented]); + index.entriesInRange(input.range); + const reads = recurrenceReads; + assert.ok(reads > 0); + index.entriesInRange(input.range); + index.upsert("other", { path: "other", title: "Other", scheduled: "2026-03-09" }); + index.setContext({ ...input, now: "2026-03-08T17:00:00Z" }); + index.entriesInRange(input.range); + assert.equal(recurrenceReads, reads); + index.upsert("a", instrumented, 2); + index.entriesInRange(input.range); + assert.ok(recurrenceReads > reads); + const explicitReads = recurrenceReads; + const other = { path: "other", title: "Other", scheduled: "2026-03-09" }; + index.setTasks([other, instrumented]); + index.entriesInRange(input.range); + assert.equal(recurrenceReads, explicitReads, "reordering/membership preserves explicit revision caches"); + index.setTasks([instrumented]); + index.entriesInRange(input.range); + assert.equal(recurrenceReads, explicitReads); + index.upsert("a", instrumented, 3); + index.entriesInRange(input.range); + assert.ok(recurrenceReads > explicitReads); + + const mutable = { ...original, dateModified: "v1" }; + index.setTasks([mutable]); + const before = index.entriesInRange(input.range); + mutable.dateModified = "v2"; + mutable.title = "Changed title"; + index.setTasks([mutable]); + assert.notDeepEqual(index.entriesInRange(input.range), before, "default revision changes still invalidate"); +}); + +test("explicit timezone is independent of the runtime timezone for every fixture and edit", () => { + const script = ` + import assert from 'node:assert/strict'; + 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'; + const sort = a => [...a].sort((x,y) => x.id.localeCompare(y.id)); + for (const f of calendarFixtures) { + assert.deepEqual(sort(buildCalendarEntries(f.input)), sort(f.expectedEntries), f.name); + const index = createCalendarEntryIndex(f.input); index.setTasks(f.input.tasks); + assert.deepEqual(sort(index.entriesInRange(f.input.range)), sort(f.expectedEntries), f.name); + for (const e of f.edits) { + const entry = f.expectedEntries.find(x => x.id === e.entryId); + const task = f.input.tasks.find(x => (x.id || x.path) === entry.taskId); + const plan = planCalendarEdit({task, entry, change:e.change, timezone:f.input.timezone, now:f.input.now, fieldMapping:DEFAULT_FIELD_MAPPING}); + assert.deepEqual(plan.updates,e.expectedUpdates,f.name); + } + }`; + for (const TZ of ["UTC", "America/Los_Angeles", "Pacific/Auckland"]) { + execFileSync(process.execPath, ["--input-type=module", "-e", script], { + env: { ...process.env, TZ }, + stdio: "pipe", + }); + } +}); + +test("civil DST disambiguation and offset instants have explicit compatible semantics", () => { + assert.equal( + new Date(calendarDateToInstant("2026-03-08T02:30", "America/New_York")).toISOString(), + "2026-03-08T07:30:00.000Z", + ); + assert.equal( + new Date(calendarDateToInstant("2026-11-01T01:30", "America/New_York")).toISOString(), + "2026-11-01T05:30:00.000Z", + ); + assert.equal( + calendarInstantToDate( + calendarDateToInstant("2026-03-08T15:30:00+13:00", "UTC"), + "Pacific/Auckland", + ), + "2026-03-08T15:30", + ); + assert.throws(() => calendarDateToInstant("2026-02-30", "UTC"), /invalid-calendar-date/); +}); + +test("edit guards prevent unsupported gestures and custom field patches remain storage-neutral", () => { + const { input, expectedEntries } = fixture("recurring-spans-readonly"); + const args = { + task: input.tasks[0], + entry: expectedEntries[0], + change: { kind: "drop", start: "2026-03-08", allDay: true }, + timezone: input.timezone, + now: input.now, + fieldMapping: DEFAULT_FIELD_MAPPING, + }; + assert.throws(() => planCalendarEdit(args), /readonly/); + assert.throws( + () => + planCalendarEdit({ ...args, change: { ...args.change, kind: "resize", end: "2026-03-09" } }), + /readonly/, + ); + const due = fixture("due-only"); + args.task = due.input.tasks[0]; + args.entry = due.expectedEntries[0]; + assert.throws( + () => + planCalendarEdit({ ...args, change: { ...args.change, kind: "resize", end: "2026-03-09" } }), + /readonly/, + ); + const plan = planCalendarEdit({ + ...args, + fieldMapping: { ...DEFAULT_FIELD_MAPPING, due: "deadline", dateModified: "modified" }, + }); + assert.deepEqual(plan.frontmatterPatch, [ + { op: "set", field: "deadline", value: "2026-03-08" }, + { op: "set", field: "modified", value: input.now }, + ]); +}); diff --git a/tsconfig.types.json b/tsconfig.types.json index 1b71008..e8e4c1a 100644 --- a/tsconfig.types.json +++ b/tsconfig.types.json @@ -11,6 +11,7 @@ "strict": true, "skipLibCheck": true, "esModuleInterop": true, + "resolveJsonModule": true, "forceConsistentCasingInFileNames": true }, "include": [