diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f45919..687a822 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 0.3.0-rc.19 + +### Added + +- `planCalendarOccurrenceChoice` describes both choices for a calendar gesture on a generated recurring occurrence: editing only that occurrence (the occurrence date and the `scheduled`/`due`/`timeEstimate` overrides for its occurrence note, with the due date keeping its offset) or the whole series (`time`, `all-day`, `timed` or `duration`; absent when a series edit would change nothing, such as a move to another day at the same time). `buildInheritedOccurrenceTask` is exported. + +### Fixed + +- Materializing a recurring occurrence the parent records as completed or skipped creates the note with the completed (and `completedDate`) or skipped status instead of reopening it. `buildMaterializeOccurrencePlan` accepts `completedStatus` and `skippedStatus`, and throws `missing_skipped_status` for a skipped date when no skipped status is available. +- Moving a scheduled-to-due span shifts both dates by calendar days and wall-clock time, so a daylight-saving change inside the span no longer moves the due time by an hour. Dropping a timed span on a day (all-day row or month cell) stores date-only scheduled and due dates instead of midnight times. +- Calendar date/timezone conversions reuse one `Intl.DateTimeFormat` per zone and memoize results. Projecting 2,000 tasks into a new week range drops from about 970 ms to 15 ms (TaskNotes calendar view switches with large vaults). + ## 0.3.0-rc.18 ### Fixed diff --git a/fixtures/calendar/scenarios.json b/fixtures/calendar/scenarios.json index 999e172..93779e6 100644 --- a/fixtures/calendar/scenarios.json +++ b/fixtures/calendar/scenarios.json @@ -2587,5 +2587,131 @@ } } ] + }, + { + "name": "timed-span-moves-by-wall-clock-across-dst", + "input": { + "tasks": [ + { + "id": "a", + "path": "a.md", + "title": "A", + "status": "open", + "priority": "normal", + "archived": false, + "scheduled": "2026-10-03T09:00", + "due": "2026-10-04T09:00" + } + ], + "settings": { + "showScheduledToDueSpan": true + }, + "range": { + "start": "2026-09-28", + "end": "2026-10-05" + }, + "timezone": "Australia/Melbourne", + "now": "2026-09-28T08:00:00Z" + }, + "expectedEntries": [ + { + "id": "span-a-2026-10-03", + "taskId": "a", + "title": "A", + "type": "scheduledToDueSpan", + "start": "2026-10-03T09:00", + "allDay": false, + "durationMinutes": 60, + "isCompleted": false, + "isSkipped": false, + "startEditable": true, + "durationEditable": false, + "editReason": "span-shift" + }, + { + "id": "span-a-2026-10-04", + "taskId": "a", + "title": "A", + "type": "scheduledToDueSpan", + "start": "2026-10-04T09:00", + "allDay": false, + "durationMinutes": 60, + "isCompleted": false, + "isSkipped": false, + "startEditable": true, + "durationEditable": false, + "editReason": "span-shift" + } + ], + "edits": [ + { + "entryId": "span-a-2026-10-03", + "change": { + "kind": "drop", + "start": "2026-10-04T09:00", + "allDay": false + }, + "expectedUpdates": { + "scheduled": "2026-10-04T09:00", + "due": "2026-10-05T09:00", + "dateModified": "2026-09-28T08:00:00Z" + }, + "expectedFrontmatterPatch": [ + { + "op": "set", + "field": "scheduled", + "value": "2026-10-04T09:00" + }, + { + "op": "set", + "field": "due", + "value": "2026-10-05T09:00" + }, + { + "op": "set", + "field": "dateModified", + "value": "2026-09-28T08:00:00Z" + } + ], + "expectedClearedInstances": { + "complete": [], + "skipped": [] + } + }, + { + "entryId": "span-a-2026-10-03", + "change": { + "kind": "drop", + "start": "2026-10-04", + "allDay": true + }, + "expectedUpdates": { + "scheduled": "2026-10-04", + "due": "2026-10-05", + "dateModified": "2026-09-28T08:00:00Z" + }, + "expectedFrontmatterPatch": [ + { + "op": "set", + "field": "scheduled", + "value": "2026-10-04" + }, + { + "op": "set", + "field": "due", + "value": "2026-10-05" + }, + { + "op": "set", + "field": "dateModified", + "value": "2026-09-28T08:00:00Z" + } + ], + "expectedClearedInstances": { + "complete": [], + "skipped": [] + } + } + ] } ] diff --git a/package-lock.json b/package-lock.json index ad43bc5..4f4245d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@tasknotes/model", - "version": "0.3.0-rc.18", + "version": "0.3.0-rc.19", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tasknotes/model", - "version": "0.3.0-rc.18", + "version": "0.3.0-rc.19", "license": "MIT", "dependencies": { "rrule": "^2.8.1", diff --git a/package.json b/package.json index 6666344..9f4420c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tasknotes/model", - "version": "0.3.0-rc.18", + "version": "0.3.0-rc.19", "description": "TaskNotes model, mapping, validation, recurrence, and operation-planning reference implementation.", "license": "MIT", "type": "module", diff --git a/src/calendar-date.ts b/src/calendar-date.ts index 394fcde..787813f 100644 --- a/src/calendar-date.ts +++ b/src/calendar-date.ts @@ -17,7 +17,28 @@ export function calendarDaysBetween(start: string, end: string): number { ); } +// Formatter construction dominates conversion cost; conversions are pure, so both are memoized. +const formatters = new Map(); +const CACHE_LIMIT = 20_000; +const toInstant = new Map(); +const toCivil = new Map(); + +function remember(cache: Map, key: string, value: T): T { + if (cache.size >= CACHE_LIMIT) cache.clear(); + cache.set(key, value); + return value; +} + function formatter(timezone: string): Intl.DateTimeFormat { + let format = formatters.get(timezone); + if (!format) { + format = createFormatter(timezone); + formatters.set(timezone, format); + } + return format; +} + +function createFormatter(timezone: string): Intl.DateTimeFormat { return new Intl.DateTimeFormat("en-CA", { timeZone: timezone, year: "numeric", @@ -39,6 +60,12 @@ function civilAt(ms: number, format: Intl.DateTimeFormat): string { * DST overlaps choose the earlier instant; gaps move forward (Date-compatible). */ export function calendarDateToInstant(value: string, timezone: string): number { + const key = `${timezone}\n${value}`; + const cached = toInstant.get(key); + return cached !== undefined ? cached : remember(toInstant, key, dateToInstant(value, timezone)); +} + +function dateToInstant(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"); @@ -67,6 +94,7 @@ export function calendarDateToInstant(value: string, timezone: string): number { } export function calendarInstantToDate(ms: number, timezone: string, allDay = false): string { - const civil = civilAt(ms, formatter(timezone)); + const key = `${timezone}\n${ms}`; + const civil = toCivil.get(key) ?? remember(toCivil, key, civilAt(ms, formatter(timezone))); return civil.slice(0, allDay ? 10 : 16); } diff --git a/src/calendar.ts b/src/calendar.ts index a9f4bb9..2d081b6 100644 --- a/src/calendar.ts +++ b/src/calendar.ts @@ -1,6 +1,10 @@ import { addDTSTARTToRecurrenceRule, generateRecurringInstances } from "./recurrence.js"; import { normalizeRecurrenceOriginalDate } from "./mapping.js"; -import { normalizeTaskReference, taskInfoUpdatesToFrontmatterPatch } from "./operations.js"; +import { + buildInheritedOccurrenceTask, + normalizeTaskReference, + taskInfoUpdatesToFrontmatterPatch, +} from "./operations.js"; import type { FieldMapping, TaskInfo, TaskOperationPlan, TaskUpdateInput } from "./types.js"; import { addCalendarDays, @@ -545,14 +549,28 @@ export function planCalendarEdit({ ? 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); + // Shift by calendar days and wall-clock minutes, not elapsed time, so a + // daylight-saving change inside the span never moves either clock. A drop + // on a day (all-day row, month cell) makes both dates date-only. + const toDay = change.allDay || change.kind === "drop-on-day"; + const from = calendarInstantToDate(calendarDateToInstant(entry.start, timezone), timezone, entry.allDay); + const days = calendarDaysBetween(from, value); + const clock = (civil: string) => Number(civil.slice(11, 13)) * 60 + Number(civil.slice(14, 16)); + const minutes = !toDay && from.includes("T") ? clock(value) - clock(from) : 0; for (const property of ["scheduled", "due"] as const) { - if (task[property]) - updates[property] = calendarInstantToDate( - calendarDateToInstant(task[property]!, timezone) + delta, - timezone, - !task[property]!.includes("T"), - ); + const current = task[property]; + if (!current) continue; + const civil = calendarInstantToDate( + calendarDateToInstant(current, timezone), + timezone, + !current.includes("T"), + ); + updates[property] = + toDay || !civil.includes("T") + ? addCalendarDays(calendarDay(civil), days) + : new Date(Date.parse(`${civil}Z`) + days * 86_400_000 + minutes * 60_000) + .toISOString() + .slice(0, 16); } } else if (entry.recurringKind === "pattern" && !entry.isMaterializedOccurrence) { if (!task.recurrence) throw new Error("missing-recurrence"); @@ -682,6 +700,75 @@ export function planCalendarEdit({ }; } +/** + * What a series-wide calendar edit of a pattern occurrence changes: its clock + * ("time"), all occurrences to all-day ("all-day") or to timed ("timed"), or + * their duration ("duration"). A pattern drop never moves the series' days. + */ +export type CalendarSeriesEffect = "time" | "all-day" | "timed" | "duration"; +export interface CalendarOccurrenceChoice { + /** Edit only this occurrence: materialize its note with these overrides. */ + occurrence: { + occurrenceDate: string; + overrides: Pick, "scheduled" | "due" | "timeEstimate">; + }; + /** Edit every occurrence through planCalendarEdit; absent when that would change nothing. */ + series?: CalendarSeriesEffect; +} +export interface PlanCalendarOccurrenceChoiceInput { + task: TaskInfo; + entry: CalendarEntry; + change: CalendarChange; + timezone: string; +} + +/** + * For a gesture on a generated (pattern) occurrence, describes both choices a + * host should offer: this occurrence only, or the whole series. Returns + * undefined for any other entry, which planCalendarEdit handles directly. + */ +export function planCalendarOccurrenceChoice({ + task, + entry, + change, + timezone, +}: PlanCalendarOccurrenceChoiceInput): CalendarOccurrenceChoice | undefined { + if (entry.recurringKind !== "pattern" || entry.isMaterializedOccurrence) return undefined; + if (entry.taskId !== calendarTaskId(task)) throw new Error("calendar-task-mismatch"); + const occurrenceDate = entry.instanceDate || calendarDay(entry.start); + const start = calendarDateToInstant(change.start, timezone); + if (change.kind === "resize") { + if (!change.end) throw new Error("missing-calendar-range"); + const end = calendarDateToInstant(change.end, timezone); + const timeEstimate = change.allDay + ? Math.round((end - start) / 86_400_000) * 1440 + : Math.round((end - start) / 60000); + return { + occurrence: { occurrenceDate, overrides: { timeEstimate } }, + series: "duration", + }; + } + const toDay = change.allDay || change.kind === "drop-on-day"; + const scheduled = calendarInstantToDate(start, timezone, toDay); + const overrides: CalendarOccurrenceChoice["occurrence"]["overrides"] = { scheduled }; + // The due date keeps its offset from the occurrence's day. + const inheritedDue = buildInheritedOccurrenceTask(task, occurrenceDate).due; + if (inheritedDue) { + const days = calendarDaysBetween(occurrenceDate, calendarDay(scheduled)); + overrides.due = `${addCalendarDays(calendarDay(inheritedDue), days)}${inheritedDue.slice(10)}`; + } + const from = calendarInstantToDate(calendarDateToInstant(entry.start, timezone), timezone, entry.allDay); + const series: CalendarSeriesEffect | undefined = + toDay && !entry.allDay + ? "all-day" + : !toDay && entry.allDay + ? "timed" + : !toDay && from.slice(11, 16) !== scheduled.slice(11, 16) + ? "time" + : undefined; + return { occurrence: { occurrenceDate, overrides }, series }; +} + export interface CalendarEntryIndex { setTasks(tasks: readonly CalendarTask[]): void; /** New object identity OR revision invalidates task-local range expansions. */ diff --git a/src/operations.ts b/src/operations.ts index 728dd61..abf59dc 100644 --- a/src/operations.ts +++ b/src/operations.ts @@ -88,6 +88,10 @@ export interface BuildMaterializeOccurrencePlanInput { parentLink?: string; defaultStatus?: string; defaultPriority?: string; + /** Status for a date the parent records in `complete_instances`. */ + completedStatus?: string; + /** Status for a date the parent records in `skipped_instances`. */ + skippedStatus?: string; templateTask?: Partial; overrides?: Partial; allowNonGeneratedTarget?: boolean; @@ -632,6 +636,8 @@ export function buildMaterializeOccurrencePlan({ parentLink, defaultStatus = parentTask.status, defaultPriority = parentTask.priority, + completedStatus, + skippedStatus, templateTask = {}, overrides = {}, allowNonGeneratedTarget = true, @@ -666,6 +672,19 @@ export function buildMaterializeOccurrencePlan({ }; } + // The note must agree with the state the parent already records for this date. + const recordedCompleted = getStringArray(parentTask.complete_instances).includes(dateStr); + const recordedSkipped = + !recordedCompleted && getStringArray(parentTask.skipped_instances).includes(dateStr); + if (recordedSkipped && !skippedStatus && overrides.status === undefined) { + throw new Error("missing_skipped_status"); + } + const recordedStatus = recordedCompleted + ? completedStatus || "done" + : recordedSkipped + ? skippedStatus + : undefined; + const inheritedTask = buildInheritedOccurrenceTask(parentTask, dateStr); const customProperties = mergeCustomProperties( inheritedTask.customProperties, @@ -679,7 +698,7 @@ export function buildMaterializeOccurrencePlan({ id: undefined, path: undefined, archived: undefined, - status: overrides.status ?? templateTask.status ?? defaultStatus, + status: overrides.status ?? recordedStatus ?? templateTask.status ?? defaultStatus, priority: overrides.priority ?? templateTask.priority ?? parentTask.priority ?? defaultPriority, scheduled: overrides.scheduled ?? templateTask.scheduled ?? inheritedTask.scheduled ?? dateStr, due: overrides.due ?? templateTask.due ?? inheritedTask.due, @@ -695,7 +714,7 @@ export function buildMaterializeOccurrencePlan({ occurrence_template: undefined, occurrence_past_horizon: undefined, occurrence_future_horizon: undefined, - completedDate: undefined, + completedDate: recordedCompleted && overrides.status === undefined ? dateStr : undefined, timeEntries: undefined, totalTrackedTime: undefined, icsEventId: undefined, @@ -1371,7 +1390,8 @@ function assertMaterializedOccurrence(task: TaskInfo, targetDate: string): void } } -function buildInheritedOccurrenceTask(parentTask: TaskInfo, targetDate: string): Partial { +/** Fields an occurrence note for `targetDate` inherits from its recurring parent. */ +export function buildInheritedOccurrenceTask(parentTask: TaskInfo, targetDate: string): Partial { const scheduled = parentTask.scheduled ? rebaseDateLikeToOccurrence(parentTask.scheduled, parentTask.scheduled, targetDate) : targetDate; diff --git a/test/calendar.test.mjs b/test/calendar.test.mjs index 18fe931..9395cca 100644 --- a/test/calendar.test.mjs +++ b/test/calendar.test.mjs @@ -220,3 +220,40 @@ test("edit guards prevent unsupported gestures and custom field patches remain s { op: "set", field: "modified", value: input.now }, ]); }); + +test("pattern gestures offer this occurrence as a moved note or the series clock", async () => { + const { planCalendarOccurrenceChoice } = await import("../dist/esm/calendar.js"); + const task = { + id: "t", + path: "t.md", + title: "Weekly", + status: "open", + scheduled: "2026-03-02T09:00", + due: "2026-03-03", + recurrence: "DTSTART:20260302T090000Z;FREQ=WEEKLY", + }; + const input = { + tasks: [task], + range: { start: "2026-03-01", end: "2026-03-22" }, + timezone: "UTC", + now: "2026-03-01T00:00:00Z", + }; + const entries = buildCalendarEntries(input); + const pattern = entries.find((e) => e.recurringKind === "pattern" && e.instanceDate === "2026-03-09"); + const plan = (change, entry = pattern) => + planCalendarOccurrenceChoice({ task, entry, change, timezone: "UTC" }); + + // Another day, same clock: only this occurrence can move; the due date keeps its offset. + assert.deepEqual(plan({ kind: "drop", start: "2026-03-11T09:00", allDay: false }), { + occurrence: { occurrenceDate: "2026-03-09", overrides: { scheduled: "2026-03-11T09:00", due: "2026-03-12" } }, + series: undefined, + }); + assert.equal(plan({ kind: "drop", start: "2026-03-09T14:00", allDay: false }).series, "time"); + assert.equal(plan({ kind: "drop-on-day", start: "2026-03-10", allDay: true }).series, "all-day"); + assert.deepEqual( + plan({ kind: "resize", start: "2026-03-09T09:00", end: "2026-03-09T10:30", allDay: false }), + { occurrence: { occurrenceDate: "2026-03-09", overrides: { timeEstimate: 90 } }, series: "duration" }, + ); + const next = entries.find((e) => e.recurringKind === "next-scheduled"); + assert.equal(plan({ kind: "drop", start: "2026-03-03T09:00", allDay: false }, next), undefined); +}); diff --git a/test/model.test.mjs b/test/model.test.mjs index 520c662..dd906c6 100644 --- a/test/model.test.mjs +++ b/test/model.test.mjs @@ -292,6 +292,33 @@ test("materializes recurring occurrences idempotently", () => { assert.equal(duplicate.existingOccurrence.path, "Tasks/Daily task 2026-06-01.md"); }); +test("materialized occurrences keep the state the parent records for their date", () => { + const parent = { + title: "Daily task", + status: "open", + path: "Tasks/Daily task.md", + recurrence: "DTSTART:20260601;FREQ=DAILY", + scheduled: "2026-06-01", + complete_instances: ["2026-06-02"], + skipped_instances: ["2026-06-03"], + }; + const plan = (targetDate, statuses = {}) => + buildMaterializeOccurrencePlan({ + parentTask: parent, + targetDate, + currentTimestamp: "2026-06-04T08:00:00Z", + defaultStatus: "open", + ...statuses, + }).occurrenceTask; + + const completed = plan("2026-06-02", { completedStatus: "done" }); + assert.equal(completed.status, "done"); + assert.equal(completed.completedDate, "2026-06-02"); + assert.equal(plan("2026-06-03", { skippedStatus: "skipped" }).status, "skipped"); + assert.throws(() => plan("2026-06-03"), /missing_skipped_status/); + assert.equal(plan("2026-06-04").status, "open"); +}); + test("materialized occurrences inherit planning fields but not parent history", () => { const parent = { title: "Morning review",